mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-02-18 13:29:36 +01:00
Prefer arrow functions and callbacks (#1210)
This commit is contained in:
parent
33fe223b5d
commit
c427aa3cce
@ -18,15 +18,15 @@ import {
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
|
||||
function getElementIndices(
|
||||
const getElementIndices = (
|
||||
direction: "left" | "right",
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
const selectedIndices: number[] = [];
|
||||
let deletedIndicesCache: number[] = [];
|
||||
|
||||
function cb(element: ExcalidrawElement, index: number) {
|
||||
const cb = (element: ExcalidrawElement, index: number) => {
|
||||
if (element.isDeleted) {
|
||||
// we want to build an array of deleted elements that are preceeding
|
||||
// a selected element so that we move them together
|
||||
@ -39,7 +39,7 @@ function getElementIndices(
|
||||
// of selected/deleted elements, of after encountering non-deleted elem
|
||||
deletedIndicesCache = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// sending back → select contiguous deleted elements that are to the left of
|
||||
// selected element(s)
|
||||
@ -59,19 +59,19 @@ function getElementIndices(
|
||||
}
|
||||
// sort in case we were gathering indexes from right to left
|
||||
return selectedIndices.sort();
|
||||
}
|
||||
};
|
||||
|
||||
function moveElements(
|
||||
const moveElements = (
|
||||
func: typeof moveOneLeft,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
const _elements = elements.slice();
|
||||
const direction =
|
||||
func === moveOneLeft || func === moveAllLeft ? "left" : "right";
|
||||
const indices = getElementIndices(direction, _elements, appState);
|
||||
return func(_elements, indices);
|
||||
}
|
||||
};
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
|
@ -2,7 +2,7 @@ import { Action } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
|
||||
export function register(action: Action): Action {
|
||||
export const register = (action: Action): Action => {
|
||||
actions = actions.concat(action);
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
@ -6,7 +6,7 @@ import { t } from "./i18n";
|
||||
export const DEFAULT_FONT = "20px Virgil";
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
|
||||
export function getDefaultAppState(): AppState {
|
||||
export const getDefaultAppState = (): AppState => {
|
||||
return {
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
@ -49,9 +49,9 @@ export function getDefaultAppState(): AppState {
|
||||
showShortcutsDialog: false,
|
||||
zenModeEnabled: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function clearAppStateForLocalStorage(appState: AppState) {
|
||||
export const clearAppStateForLocalStorage = (appState: AppState) => {
|
||||
const {
|
||||
draggingElement,
|
||||
resizingElement,
|
||||
@ -68,11 +68,11 @@ export function clearAppStateForLocalStorage(appState: AppState) {
|
||||
...exportedState
|
||||
} = appState;
|
||||
return exportedState;
|
||||
}
|
||||
};
|
||||
|
||||
export function clearAppStatePropertiesForHistory(
|
||||
export const clearAppStatePropertiesForHistory = (
|
||||
appState: AppState,
|
||||
): Partial<AppState> {
|
||||
): Partial<AppState> => {
|
||||
return {
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
exportBackground: appState.exportBackground,
|
||||
@ -88,10 +88,10 @@ export function clearAppStatePropertiesForHistory(
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
name: appState.name,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function cleanAppStateForExport(appState: AppState) {
|
||||
export const cleanAppStateForExport = (appState: AppState) => {
|
||||
return {
|
||||
viewBackgroundColor: appState.viewBackgroundColor,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -21,10 +21,10 @@ export const probablySupportsClipboardBlob =
|
||||
"ClipboardItem" in window &&
|
||||
"toBlob" in HTMLCanvasElement.prototype;
|
||||
|
||||
export async function copyToAppClipboard(
|
||||
export const copyToAppClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
|
||||
try {
|
||||
// when copying to in-app clipboard, clear system clipboard so that if
|
||||
@ -38,11 +38,11 @@ export async function copyToAppClipboard(
|
||||
// we can't be sure of the order of copy operations
|
||||
PREFER_APP_CLIPBOARD = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getAppClipboard(): {
|
||||
export const getAppClipboard = (): {
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
} {
|
||||
} => {
|
||||
if (!CLIPBOARD) {
|
||||
return {};
|
||||
}
|
||||
@ -62,14 +62,14 @@ export function getAppClipboard(): {
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export async function getClipboardContent(
|
||||
export const getClipboardContent = async (
|
||||
event: ClipboardEvent | null,
|
||||
): Promise<{
|
||||
text?: string;
|
||||
elements?: readonly ExcalidrawElement[];
|
||||
}> {
|
||||
}> => {
|
||||
try {
|
||||
const text = event
|
||||
? event.clipboardData?.getData("text/plain").trim()
|
||||
@ -84,12 +84,12 @@ export async function getClipboardContent(
|
||||
}
|
||||
|
||||
return getAppClipboard();
|
||||
}
|
||||
};
|
||||
|
||||
export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||
return new Promise((resolve, reject) => {
|
||||
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) =>
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
canvas.toBlob(async function (blob: any) {
|
||||
canvas.toBlob(async (blob: any) => {
|
||||
try {
|
||||
await navigator.clipboard.write([
|
||||
new window.ClipboardItem({ "image/png": blob }),
|
||||
@ -103,17 +103,16 @@ export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyCanvasToClipboardAsSvg(svgroot: SVGSVGElement) {
|
||||
export const copyCanvasToClipboardAsSvg = async (svgroot: SVGSVGElement) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(svgroot.outerHTML);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function copyTextToSystemClipboard(text: string | null) {
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
let copied = false;
|
||||
if (probablySupportsClipboardWriteText) {
|
||||
try {
|
||||
@ -131,10 +130,10 @@ export async function copyTextToSystemClipboard(text: string | null) {
|
||||
if (!copied && !copyTextViaExecCommand(text || " ")) {
|
||||
throw new Error("couldn't copy");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
||||
function copyTextViaExecCommand(text: string) {
|
||||
const copyTextViaExecCommand = (text: string) => {
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
const textarea = document.createElement("textarea");
|
||||
@ -168,4 +167,4 @@ function copyTextViaExecCommand(text: string) {
|
||||
textarea.remove();
|
||||
|
||||
return success;
|
||||
}
|
||||
};
|
||||
|
@ -11,7 +11,7 @@ import Stack from "./Stack";
|
||||
import useIsMobile from "../is-mobile";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
|
||||
export function SelectedShapeActions({
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
elements,
|
||||
renderAction,
|
||||
@ -21,7 +21,7 @@ export function SelectedShapeActions({
|
||||
elements: readonly ExcalidrawElement[];
|
||||
renderAction: ActionManager["renderAction"];
|
||||
elementType: ExcalidrawElement["type"];
|
||||
}) {
|
||||
}) => {
|
||||
const targetElements = getTargetElement(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
@ -83,65 +83,61 @@ export function SelectedShapeActions({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ShapesSwitcher({
|
||||
export const ShapesSwitcher = ({
|
||||
elementType,
|
||||
setAppState,
|
||||
}: {
|
||||
elementType: ExcalidrawElement["type"];
|
||||
setAppState: any;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const shortcut = `${capitalizeString(key)} ${t("shortcutsDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
return (
|
||||
<ToolButton
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={elementType === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={`${key} ${index + 1}`}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(value);
|
||||
setAppState({});
|
||||
}}
|
||||
></ToolButton>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const shortcut = `${capitalizeString(key)} ${t("shortcutsDialog.or")} ${
|
||||
index + 1
|
||||
}`;
|
||||
return (
|
||||
<ToolButton
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={elementType === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={`${index + 1}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={`${key} ${index + 1}`}
|
||||
data-testid={value}
|
||||
onChange={() => {
|
||||
setAppState({
|
||||
elementType: value,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(value);
|
||||
setAppState({});
|
||||
}}
|
||||
></ToolButton>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
export function ZoomActions({
|
||||
export const ZoomActions = ({
|
||||
renderAction,
|
||||
zoom,
|
||||
}: {
|
||||
renderAction: ActionManager["renderAction"];
|
||||
zoom: number;
|
||||
}) {
|
||||
return (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<Stack.Col gap={1}>
|
||||
<Stack.Row gap={1} align="center">
|
||||
{renderAction("zoomIn")}
|
||||
{renderAction("zoomOut")}
|
||||
{renderAction("resetZoom")}
|
||||
<div style={{ marginInlineStart: 4 }}>{(zoom * 100).toFixed(0)}%</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
);
|
||||
|
@ -136,13 +136,14 @@ import throttle from "lodash.throttle";
|
||||
/**
|
||||
* @param func handler taking at most single parameter (event).
|
||||
*/
|
||||
function withBatchedUpdates<
|
||||
const withBatchedUpdates = <
|
||||
TFunction extends ((event: any) => void) | (() => void)
|
||||
>(func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never) {
|
||||
return ((event) => {
|
||||
>(
|
||||
func: Parameters<TFunction>["length"] extends 0 | 1 ? TFunction : never,
|
||||
) =>
|
||||
((event) => {
|
||||
unstable_batchedUpdates(func as TFunction, event);
|
||||
}) as TFunction;
|
||||
}
|
||||
|
||||
const { history } = createHistory();
|
||||
|
||||
@ -2748,9 +2749,7 @@ if (
|
||||
},
|
||||
},
|
||||
history: {
|
||||
get() {
|
||||
return history;
|
||||
},
|
||||
get: () => history,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
|
||||
export function ButtonSelect<T>({
|
||||
export const ButtonSelect = <T extends Object>({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
@ -10,23 +10,21 @@ export function ButtonSelect<T>({
|
||||
value: T | null;
|
||||
onChange: (value: T) => void;
|
||||
group: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="buttonList">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={value === option.value ? "active" : ""}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value ? true : false}
|
||||
/>
|
||||
{option.text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<div className="buttonList">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.text}
|
||||
className={value === option.value ? "active" : ""}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={group}
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value ? true : false}
|
||||
/>
|
||||
{option.text}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
@ -7,11 +7,11 @@ import { t, getLanguage } from "../i18n";
|
||||
import { isWritableElement } from "../utils";
|
||||
import colors from "../colors";
|
||||
|
||||
function isValidColor(color: string) {
|
||||
const isValidColor = (color: string) => {
|
||||
const style = new Option().style;
|
||||
style.color = color;
|
||||
return !!style.color;
|
||||
}
|
||||
};
|
||||
|
||||
const getColor = (color: string): string | null => {
|
||||
if (color === "transparent") {
|
||||
@ -36,7 +36,7 @@ const keyBindings = [
|
||||
["a", "s", "d", "f", "g"],
|
||||
].flat();
|
||||
|
||||
const Picker = function ({
|
||||
const Picker = ({
|
||||
colors,
|
||||
color,
|
||||
onChange,
|
||||
@ -50,7 +50,7 @@ const Picker = function ({
|
||||
onClose: () => void;
|
||||
label: string;
|
||||
showInput: boolean;
|
||||
}) {
|
||||
}) => {
|
||||
const firstItem = React.useRef<HTMLButtonElement>();
|
||||
const activeItem = React.useRef<HTMLButtonElement>();
|
||||
const gallery = React.useRef<HTMLDivElement>();
|
||||
@ -235,7 +235,7 @@ const ColorInput = React.forwardRef(
|
||||
},
|
||||
);
|
||||
|
||||
export function ColorPicker({
|
||||
export const ColorPicker = ({
|
||||
type,
|
||||
color,
|
||||
onChange,
|
||||
@ -245,7 +245,7 @@ export function ColorPicker({
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
}) => {
|
||||
const [isActive, setActive] = React.useState(false);
|
||||
const pickerButton = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
@ -296,4 +296,4 @@ export function ColorPicker({
|
||||
</React.Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -16,45 +16,41 @@ type Props = {
|
||||
left: number;
|
||||
};
|
||||
|
||||
function ContextMenu({ options, onCloseRequest, top, left }: Props) {
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
const ContextMenu = ({ options, onCloseRequest, top, left }: Props) => (
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{options.map((option, idx) => (
|
||||
<li key={idx} onClick={onCloseRequest}>
|
||||
<ContextMenuOption {...option} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
{options.map((option, idx) => (
|
||||
<li key={idx} onClick={onCloseRequest}>
|
||||
<ContextMenuOption {...option} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
function ContextMenuOption({ label, action }: ContextMenuOption) {
|
||||
return (
|
||||
<button className="context-menu-option" onClick={action}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const ContextMenuOption = ({ label, action }: ContextMenuOption) => (
|
||||
<button className="context-menu-option" onClick={action}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
let contextMenuNode: HTMLDivElement;
|
||||
function getContextMenuNode(): HTMLDivElement {
|
||||
const getContextMenuNode = (): HTMLDivElement => {
|
||||
if (contextMenuNode) {
|
||||
return contextMenuNode;
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
return (contextMenuNode = div);
|
||||
}
|
||||
};
|
||||
|
||||
type ContextMenuParams = {
|
||||
options: (ContextMenuOption | false | null | undefined)[];
|
||||
@ -62,9 +58,9 @@ type ContextMenuParams = {
|
||||
left: number;
|
||||
};
|
||||
|
||||
function handleClose() {
|
||||
const handleClose = () => {
|
||||
unmountComponentAtNode(getContextMenuNode());
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
push(params: ContextMenuParams) {
|
||||
|
@ -8,13 +8,13 @@ import { KEYS } from "../keys";
|
||||
|
||||
import "./Dialog.scss";
|
||||
|
||||
export function Dialog(props: {
|
||||
export const Dialog = (props: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
maxWidth?: number;
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode;
|
||||
}) {
|
||||
}) => {
|
||||
const islandRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -31,7 +31,7 @@ export function Dialog(props: {
|
||||
return;
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === KEYS.TAB) {
|
||||
const focusableElements = queryFocusableElements();
|
||||
const { activeElement } = document;
|
||||
@ -50,7 +50,7 @@ export function Dialog(props: {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const node = islandRef.current;
|
||||
node.addEventListener("keydown", handleKeyDown);
|
||||
@ -58,13 +58,13 @@ export function Dialog(props: {
|
||||
return () => node.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function queryFocusableElements() {
|
||||
const queryFocusableElements = () => {
|
||||
const focusableElements = islandRef.current?.querySelectorAll<HTMLElement>(
|
||||
"button, a, input, select, textarea, div[tabindex]",
|
||||
);
|
||||
|
||||
return focusableElements ? Array.from(focusableElements) : [];
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -88,4 +88,4 @@ export function Dialog(props: {
|
||||
</Island>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -3,13 +3,13 @@ import { t } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
export function ErrorDialog({
|
||||
export const ErrorDialog = ({
|
||||
message,
|
||||
onClose,
|
||||
}: {
|
||||
message: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(!!message);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
@ -33,4 +33,4 @@ export function ErrorDialog({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -24,7 +24,7 @@ export type ExportCB = (
|
||||
scale?: number,
|
||||
) => void;
|
||||
|
||||
function ExportModal({
|
||||
const ExportModal = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
@ -43,7 +43,7 @@ function ExportModal({
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
onCloseRequest: () => void;
|
||||
}) {
|
||||
}) => {
|
||||
const someElementIsSelected = isSomeElementSelected(elements, appState);
|
||||
const [scale, setScale] = useState(defaultScale);
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
@ -160,9 +160,9 @@ function ExportModal({
|
||||
</Stack.Col>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function ExportDialog({
|
||||
export const ExportDialog = ({
|
||||
elements,
|
||||
appState,
|
||||
exportPadding = 10,
|
||||
@ -180,7 +180,7 @@ export function ExportDialog({
|
||||
onExportToSvg: ExportCB;
|
||||
onExportToClipboard: ExportCB;
|
||||
onExportToBackend: ExportCB;
|
||||
}) {
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const triggerButton = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@ -221,4 +221,4 @@ export function ExportDialog({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -8,16 +8,14 @@ type FixedSideContainerProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function FixedSideContainer({
|
||||
export const FixedSideContainer = ({
|
||||
children,
|
||||
side,
|
||||
className,
|
||||
}: FixedSideContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`FixedSideContainer FixedSideContainer_side_${side} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}: FixedSideContainerProps) => (
|
||||
<div
|
||||
className={`FixedSideContainer FixedSideContainer_side_${side} ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
@ -18,10 +18,8 @@ const ICON = (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function HelpIcon(props: HelpIconProps) {
|
||||
return (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{ICON}</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
export const HelpIcon = (props: HelpIconProps) => (
|
||||
<label title={`${props.title} — ?`} className="help-icon">
|
||||
<div onClick={props.onClick}>{ICON}</div>
|
||||
</label>
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import * as i18n from "../i18n";
|
||||
|
||||
export function LanguageList({
|
||||
export const LanguageList = ({
|
||||
onChange,
|
||||
languages = i18n.languages,
|
||||
currentLanguage = i18n.getLanguage().lng,
|
||||
@ -11,23 +11,21 @@ export function LanguageList({
|
||||
onChange: (value: string) => void;
|
||||
currentLanguage?: string;
|
||||
floating?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<select
|
||||
className={`dropdown-select dropdown-select__language${
|
||||
floating ? " dropdown-select--floating" : ""
|
||||
}`}
|
||||
onChange={({ target }) => onChange(target.value)}
|
||||
value={currentLanguage}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<option key={language.lng} value={language.lng}>
|
||||
{language.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<React.Fragment>
|
||||
<select
|
||||
className={`dropdown-select dropdown-select__language${
|
||||
floating ? " dropdown-select--floating" : ""
|
||||
}`}
|
||||
onChange={({ target }) => onChange(target.value)}
|
||||
value={currentLanguage}
|
||||
aria-label={i18n.t("buttons.selectLanguage")}
|
||||
>
|
||||
{languages.map((language) => (
|
||||
<option key={language.lng} value={language.lng}>
|
||||
{language.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
@ -40,7 +40,7 @@ const ICONS = {
|
||||
),
|
||||
};
|
||||
|
||||
export function LockIcon(props: LockIconProps) {
|
||||
export const LockIcon = (props: LockIconProps) => {
|
||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||
|
||||
return (
|
||||
@ -64,4 +64,4 @@ export function LockIcon(props: LockIconProps) {
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -29,7 +29,7 @@ type MobileMenuProps = {
|
||||
onLockToggle: () => void;
|
||||
};
|
||||
|
||||
export function MobileMenu({
|
||||
export const MobileMenu = ({
|
||||
appState,
|
||||
elements,
|
||||
actionManager,
|
||||
@ -39,108 +39,106 @@ export function MobileMenu({
|
||||
onUsernameChange,
|
||||
onRoomDestroy,
|
||||
onLockToggle,
|
||||
}: MobileMenuProps) {
|
||||
return (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
<FixedSideContainer side="top">
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1}>
|
||||
<Island padding={1}>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
</FixedSideContainer>
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
}}
|
||||
>
|
||||
<Island padding={3}>
|
||||
{appState.openMenu === "canvas" ? (
|
||||
<Section className="App-mobile-menu" heading="canvasActions">
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<RoomDialog
|
||||
isCollaborating={appState.isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
username={appState.username}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
}: MobileMenuProps) => (
|
||||
<>
|
||||
{appState.isLoading && <LoadingMessage />}
|
||||
<FixedSideContainer side="top">
|
||||
<Section heading="shapes">
|
||||
{(heading) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1}>
|
||||
<Island padding={1}>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
elementType={appState.elementType}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
<fieldset>
|
||||
<legend>{t("labels.language")}</legend>
|
||||
<LanguageList
|
||||
onChange={(lng) => {
|
||||
setLanguage(lng);
|
||||
setAppState({});
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
</Section>
|
||||
) : appState.openMenu === "shape" &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
<LockIcon
|
||||
checked={appState.elementLocked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
<footer className="App-toolbar">
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer appState={appState} elements={elements} />
|
||||
</FixedSideContainer>
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
}}
|
||||
>
|
||||
<Island padding={3}>
|
||||
{appState.openMenu === "canvas" ? (
|
||||
<Section className="App-mobile-menu" heading="canvasActions">
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={4}>
|
||||
{actionManager.renderAction("loadScene")}
|
||||
{actionManager.renderAction("saveScene")}
|
||||
{exportButton}
|
||||
{actionManager.renderAction("clearCanvas")}
|
||||
<RoomDialog
|
||||
isCollaborating={appState.isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
username={appState.username}
|
||||
onUsernameChange={onUsernameChange}
|
||||
onRoomCreate={onRoomCreate}
|
||||
onRoomDestroy={onRoomDestroy}
|
||||
/>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
<fieldset>
|
||||
<legend>{t("labels.language")}</legend>
|
||||
<LanguageList
|
||||
onChange={(lng) => {
|
||||
setLanguage(lng);
|
||||
setAppState({});
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({ ...calculateScrollCenter(elements) });
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
</Section>
|
||||
) : appState.openMenu === "shape" &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
renderAction={actionManager.renderAction}
|
||||
elementType={appState.elementType}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
<footer className="App-toolbar">
|
||||
<div className="App-toolbar-content">
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
</footer>
|
||||
</Island>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState({ ...calculateScrollCenter(elements) });
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</Island>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -4,13 +4,13 @@ import React, { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export function Modal(props: {
|
||||
export const Modal = (props: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number;
|
||||
onCloseRequest(): void;
|
||||
labelledBy: string;
|
||||
}) {
|
||||
}) => {
|
||||
const modalRoot = useBodyRoot();
|
||||
|
||||
const handleKeydown = (event: React.KeyboardEvent) => {
|
||||
@ -44,14 +44,14 @@ export function Modal(props: {
|
||||
</div>,
|
||||
modalRoot,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function useBodyRoot() {
|
||||
function createDiv() {
|
||||
const useBodyRoot = () => {
|
||||
const createDiv = () => {
|
||||
const div = document.createElement("div");
|
||||
document.body.appendChild(div);
|
||||
return div;
|
||||
}
|
||||
};
|
||||
const [div] = useState(createDiv);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -59,4 +59,4 @@ function useBodyRoot() {
|
||||
};
|
||||
}, [div]);
|
||||
return div;
|
||||
}
|
||||
};
|
||||
|
@ -10,13 +10,13 @@ type Props = {
|
||||
fitInViewport?: boolean;
|
||||
};
|
||||
|
||||
export function Popover({
|
||||
export const Popover = ({
|
||||
children,
|
||||
left,
|
||||
top,
|
||||
onCloseRequest,
|
||||
fitInViewport = false,
|
||||
}: Props) {
|
||||
}: Props) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ensure the popover doesn't overflow the viewport
|
||||
@ -53,4 +53,4 @@ export function Popover({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { AppState } from "../types";
|
||||
|
||||
function RoomModal({
|
||||
const RoomModal = ({
|
||||
activeRoomLink,
|
||||
username,
|
||||
onUsernameChange,
|
||||
@ -23,21 +23,21 @@ function RoomModal({
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
onPressingEnter: () => void;
|
||||
}) {
|
||||
}) => {
|
||||
const roomLinkInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
function copyRoomLink() {
|
||||
const copyRoomLink = () => {
|
||||
copyTextToSystemClipboard(activeRoomLink);
|
||||
if (roomLinkInput.current) {
|
||||
roomLinkInput.current.select();
|
||||
}
|
||||
}
|
||||
function selectInput(event: React.MouseEvent<HTMLInputElement>) {
|
||||
};
|
||||
const selectInput = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
if (event.target !== document.activeElement) {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLInputElement).select();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="RoomDialog-modal">
|
||||
@ -113,9 +113,9 @@ function RoomModal({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function RoomDialog({
|
||||
export const RoomDialog = ({
|
||||
isCollaborating,
|
||||
collaboratorCount,
|
||||
username,
|
||||
@ -129,7 +129,7 @@ export function RoomDialog({
|
||||
onUsernameChange: (username: string) => void;
|
||||
onRoomCreate: () => void;
|
||||
onRoomDestroy: () => void;
|
||||
}) {
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
const [activeRoomLink, setActiveRoomLink] = useState("");
|
||||
|
||||
@ -182,4 +182,4 @@ export function RoomDialog({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -6,7 +6,7 @@ interface SectionProps extends React.HTMLProps<HTMLElement> {
|
||||
children: React.ReactNode | ((header: React.ReactNode) => React.ReactNode);
|
||||
}
|
||||
|
||||
export function Section({ heading, children, ...props }: SectionProps) {
|
||||
export const Section = ({ heading, children, ...props }: SectionProps) => {
|
||||
const header = (
|
||||
<h2 className="visually-hidden" id={`${heading}-title`}>
|
||||
{t(`headings.${heading}`)}
|
||||
@ -24,4 +24,4 @@ export function Section({ heading, children, ...props }: SectionProps) {
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -10,13 +10,13 @@ type StackProps = {
|
||||
className?: string | boolean;
|
||||
};
|
||||
|
||||
function RowStack({
|
||||
const RowStack = ({
|
||||
children,
|
||||
gap,
|
||||
align,
|
||||
justifyContent,
|
||||
className,
|
||||
}: StackProps) {
|
||||
}: StackProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`Stack Stack_horizontal ${className || ""}`}
|
||||
@ -31,15 +31,15 @@ function RowStack({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
function ColStack({
|
||||
const ColStack = ({
|
||||
children,
|
||||
gap,
|
||||
align,
|
||||
justifyContent,
|
||||
className,
|
||||
}: StackProps) {
|
||||
}: StackProps) => {
|
||||
return (
|
||||
<div
|
||||
className={`Stack Stack_vertical ${className || ""}`}
|
||||
@ -54,7 +54,7 @@ function ColStack({
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
Row: RowStack,
|
||||
|
@ -36,10 +36,7 @@ type ToolButtonProps =
|
||||
|
||||
const DEFAULT_SIZE: ToolIconSize = "m";
|
||||
|
||||
export const ToolButton = React.forwardRef(function (
|
||||
props: ToolButtonProps,
|
||||
ref,
|
||||
) {
|
||||
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
const innerRef = React.useRef(null);
|
||||
React.useImperativeHandle(ref, () => innerRef.current);
|
||||
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
|
||||
|
@ -2,7 +2,7 @@ import { getDefaultAppState } from "../appState";
|
||||
import { restore } from "./restore";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export async function loadFromBlob(blob: any) {
|
||||
export const loadFromBlob = async (blob: any) => {
|
||||
const updateAppState = (contents: string) => {
|
||||
const defaultAppState = getDefaultAppState();
|
||||
let elements = [];
|
||||
@ -40,4 +40,4 @@ export async function loadFromBlob(blob: any) {
|
||||
|
||||
const { elements, appState } = updateAppState(contents);
|
||||
return restore(elements, appState, { scrollToContent: true });
|
||||
}
|
||||
};
|
||||
|
@ -72,17 +72,15 @@ export type SocketUpdateDataIncoming =
|
||||
// part of `AppState`.
|
||||
(window as any).handle = null;
|
||||
|
||||
function byteToHex(byte: number): string {
|
||||
return `0${byte.toString(16)}`.slice(-2);
|
||||
}
|
||||
const byteToHex = (byte: number): string => `0${byte.toString(16)}`.slice(-2);
|
||||
|
||||
async function generateRandomID() {
|
||||
const generateRandomID = async () => {
|
||||
const arr = new Uint8Array(10);
|
||||
window.crypto.getRandomValues(arr);
|
||||
return Array.from(arr, byteToHex).join("");
|
||||
}
|
||||
};
|
||||
|
||||
async function generateEncryptionKey() {
|
||||
const generateEncryptionKey = async () => {
|
||||
const key = await window.crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
@ -92,29 +90,29 @@ async function generateEncryptionKey() {
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
return (await window.crypto.subtle.exportKey("jwk", key)).k;
|
||||
}
|
||||
};
|
||||
|
||||
function createIV() {
|
||||
const createIV = () => {
|
||||
const arr = new Uint8Array(12);
|
||||
return window.crypto.getRandomValues(arr);
|
||||
}
|
||||
};
|
||||
|
||||
export function getCollaborationLinkData(link: string) {
|
||||
export const getCollaborationLinkData = (link: string) => {
|
||||
if (link.length === 0) {
|
||||
return;
|
||||
}
|
||||
const hash = new URL(link).hash;
|
||||
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
|
||||
}
|
||||
};
|
||||
|
||||
export async function generateCollaborationLink() {
|
||||
export const generateCollaborationLink = async () => {
|
||||
const id = await generateRandomID();
|
||||
const key = await generateEncryptionKey();
|
||||
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
|
||||
}
|
||||
};
|
||||
|
||||
function getImportedKey(key: string, usage: string) {
|
||||
return window.crypto.subtle.importKey(
|
||||
const getImportedKey = (key: string, usage: string) =>
|
||||
window.crypto.subtle.importKey(
|
||||
"jwk",
|
||||
{
|
||||
alg: "A128GCM",
|
||||
@ -130,12 +128,11 @@ function getImportedKey(key: string, usage: string) {
|
||||
false, // extractable
|
||||
[usage],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptAESGEM(
|
||||
export const encryptAESGEM = async (
|
||||
data: Uint8Array,
|
||||
key: string,
|
||||
): Promise<EncryptedData> {
|
||||
): Promise<EncryptedData> => {
|
||||
const importedKey = await getImportedKey(key, "encrypt");
|
||||
const iv = createIV();
|
||||
return {
|
||||
@ -149,13 +146,13 @@ export async function encryptAESGEM(
|
||||
),
|
||||
iv,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export async function decryptAESGEM(
|
||||
export const decryptAESGEM = async (
|
||||
data: ArrayBuffer,
|
||||
key: string,
|
||||
iv: Uint8Array,
|
||||
): Promise<SocketUpdateDataIncoming> {
|
||||
): Promise<SocketUpdateDataIncoming> => {
|
||||
try {
|
||||
const importedKey = await getImportedKey(key, "decrypt");
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
@ -178,12 +175,12 @@ export async function decryptAESGEM(
|
||||
return {
|
||||
type: "INVALID_RESPONSE",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export async function exportToBackend(
|
||||
export const exportToBackend = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
const json = serializeAsJSON(elements, appState);
|
||||
const encoded = new TextEncoder().encode(json);
|
||||
|
||||
@ -233,12 +230,12 @@ export async function exportToBackend(
|
||||
console.error(error);
|
||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function importFromBackend(
|
||||
export const importFromBackend = async (
|
||||
id: string | null,
|
||||
privateKey: string | undefined,
|
||||
) {
|
||||
) => {
|
||||
let elements: readonly ExcalidrawElement[] = [];
|
||||
let appState: AppState = getDefaultAppState();
|
||||
|
||||
@ -281,9 +278,9 @@ export async function importFromBackend(
|
||||
} finally {
|
||||
return restore(elements, appState, { scrollToContent: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function exportCanvas(
|
||||
export const exportCanvas = async (
|
||||
type: ExportType,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
@ -303,7 +300,7 @@ export async function exportCanvas(
|
||||
scale?: number;
|
||||
shouldAddWatermark: boolean;
|
||||
},
|
||||
) {
|
||||
) => {
|
||||
if (elements.length === 0) {
|
||||
return window.alert(t("alerts.cannotExportEmptyCanvas"));
|
||||
}
|
||||
@ -362,9 +359,9 @@ export async function exportCanvas(
|
||||
if (tempCanvas !== canvas) {
|
||||
tempCanvas.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function loadScene(id: string | null, privateKey?: string) {
|
||||
export const loadScene = async (id: string | null, privateKey?: string) => {
|
||||
let data;
|
||||
if (id != null) {
|
||||
// the private key is used to decrypt the content from the server, take
|
||||
@ -380,4 +377,4 @@ export async function loadScene(id: string | null, privateKey?: string) {
|
||||
appState: data.appState && { ...data.appState },
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -5,11 +5,11 @@ import { cleanAppStateForExport } from "../appState";
|
||||
import { fileOpen, fileSave } from "browser-nativefs";
|
||||
import { loadFromBlob } from "./blob";
|
||||
|
||||
export function serializeAsJSON(
|
||||
export const serializeAsJSON = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
): string =>
|
||||
JSON.stringify(
|
||||
{
|
||||
type: "excalidraw",
|
||||
version: 1,
|
||||
@ -20,12 +20,11 @@ export function serializeAsJSON(
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
export async function saveAsJSON(
|
||||
export const saveAsJSON = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
const serialized = serializeAsJSON(elements, appState);
|
||||
|
||||
const name = `${appState.name}.excalidraw`;
|
||||
@ -41,12 +40,12 @@ export async function saveAsJSON(
|
||||
},
|
||||
(window as any).handle,
|
||||
);
|
||||
}
|
||||
export async function loadFromJSON() {
|
||||
};
|
||||
export const loadFromJSON = async () => {
|
||||
const blob = await fileOpen({
|
||||
description: "Excalidraw files",
|
||||
extensions: ["json", "excalidraw"],
|
||||
mimeTypes: ["application/json", "application/vnd.excalidraw+json"],
|
||||
});
|
||||
return loadFromBlob(blob);
|
||||
}
|
||||
};
|
||||
|
@ -7,7 +7,7 @@ const LOCAL_STORAGE_KEY = "excalidraw";
|
||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
||||
const LOCAL_STORAGE_KEY_COLLAB = "excalidraw-collab";
|
||||
|
||||
export function saveUsernameToLocalStorage(username: string) {
|
||||
export const saveUsernameToLocalStorage = (username: string) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_KEY_COLLAB,
|
||||
@ -17,9 +17,9 @@ export function saveUsernameToLocalStorage(username: string) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function restoreUsernameFromLocalStorage(): string | null {
|
||||
export const restoreUsernameFromLocalStorage = (): string | null => {
|
||||
try {
|
||||
const data = localStorage.getItem(LOCAL_STORAGE_KEY_COLLAB);
|
||||
if (data) {
|
||||
@ -31,12 +31,12 @@ export function restoreUsernameFromLocalStorage(): string | null {
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function saveToLocalStorage(
|
||||
export const saveToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
LOCAL_STORAGE_KEY,
|
||||
@ -50,9 +50,9 @@ export function saveToLocalStorage(
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function restoreFromLocalStorage() {
|
||||
export const restoreFromLocalStorage = () => {
|
||||
let savedElements = null;
|
||||
let savedState = null;
|
||||
|
||||
@ -86,4 +86,4 @@ export function restoreFromLocalStorage() {
|
||||
}
|
||||
|
||||
return restore(elements, appState);
|
||||
}
|
||||
};
|
||||
|
@ -12,13 +12,13 @@ import { calculateScrollCenter } from "../scene";
|
||||
import { randomId } from "../random";
|
||||
import { DEFAULT_TEXT_ALIGN } from "../appState";
|
||||
|
||||
export function restore(
|
||||
export const restore = (
|
||||
// we're making the elements mutable for this API because we want to
|
||||
// efficiently remove/tweak properties on them (to migrate old scenes)
|
||||
savedElements: readonly Mutable<ExcalidrawElement>[],
|
||||
savedState: AppState | null,
|
||||
opts?: { scrollToContent: boolean },
|
||||
): DataState {
|
||||
): DataState => {
|
||||
const elements = savedElements
|
||||
.filter((el) => {
|
||||
// filtering out selection, which is legacy, no longer kept in elements,
|
||||
@ -94,4 +94,4 @@ export function restore(
|
||||
elements: elements,
|
||||
appState: savedState,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -12,9 +12,9 @@ import { rescalePoints } from "../points";
|
||||
|
||||
// If the element is created from right to left, the width is going to be negative
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export function getElementAbsoluteCoords(
|
||||
export const getElementAbsoluteCoords = (
|
||||
element: ExcalidrawElement,
|
||||
): [number, number, number, number] {
|
||||
): [number, number, number, number] => {
|
||||
if (isLinearElement(element)) {
|
||||
return getLinearElementAbsoluteCoords(element);
|
||||
}
|
||||
@ -24,9 +24,9 @@ export function getElementAbsoluteCoords(
|
||||
element.x + element.width,
|
||||
element.y + element.height,
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
export function getDiamondPoints(element: ExcalidrawElement) {
|
||||
export const getDiamondPoints = (element: ExcalidrawElement) => {
|
||||
// Here we add +1 to avoid these numbers to be 0
|
||||
// otherwise rough.js will throw an error complaining about it
|
||||
const topX = Math.floor(element.width / 2) + 1;
|
||||
@ -39,16 +39,16 @@ export function getDiamondPoints(element: ExcalidrawElement) {
|
||||
const leftY = rightY;
|
||||
|
||||
return [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY];
|
||||
}
|
||||
};
|
||||
|
||||
export function getCurvePathOps(shape: Drawable): Op[] {
|
||||
export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||
for (const set of shape.sets) {
|
||||
if (set.type === "path") {
|
||||
return set.ops;
|
||||
}
|
||||
}
|
||||
return shape.sets[0].ops;
|
||||
}
|
||||
};
|
||||
|
||||
const getMinMaxXYFromCurvePathOps = (
|
||||
ops: Op[],
|
||||
@ -150,10 +150,10 @@ const getLinearElementAbsoluteCoords = (
|
||||
];
|
||||
};
|
||||
|
||||
export function getArrowPoints(
|
||||
export const getArrowPoints = (
|
||||
element: ExcalidrawLinearElement,
|
||||
shape: Drawable[],
|
||||
) {
|
||||
) => {
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
|
||||
const data = ops[ops.length - 1].data;
|
||||
@ -212,7 +212,7 @@ export function getArrowPoints(
|
||||
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
|
||||
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
}
|
||||
};
|
||||
|
||||
const getLinearElementRotatedBounds = (
|
||||
element: ExcalidrawLinearElement,
|
||||
|
@ -19,10 +19,10 @@ import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
|
||||
function isElementDraggableFromInside(
|
||||
const isElementDraggableFromInside = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
): boolean {
|
||||
): boolean => {
|
||||
const dragFromInside =
|
||||
element.backgroundColor !== "transparent" ||
|
||||
appState.selectedElementIds[element.id];
|
||||
@ -30,15 +30,15 @@ function isElementDraggableFromInside(
|
||||
return dragFromInside && isPathALoop(element.points);
|
||||
}
|
||||
return dragFromInside;
|
||||
}
|
||||
};
|
||||
|
||||
export function hitTest(
|
||||
export const hitTest = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number,
|
||||
): boolean {
|
||||
): boolean => {
|
||||
// For shapes that are composed of lines, we only enable point-selection when the distance
|
||||
// of the click is less than x pixels of any of the lines that the shape is composed of
|
||||
const lineThreshold = 10 / zoom;
|
||||
@ -210,7 +210,7 @@ export function hitTest(
|
||||
return false;
|
||||
}
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
};
|
||||
|
||||
const pointInBezierEquation = (
|
||||
p0: Point,
|
||||
|
@ -21,7 +21,7 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
function generateHandler(
|
||||
const generateHandler = (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
@ -29,18 +29,18 @@ function generateHandler(
|
||||
cx: number,
|
||||
cy: number,
|
||||
angle: number,
|
||||
): [number, number, number, number] {
|
||||
): [number, number, number, number] => {
|
||||
const [xx, yy] = rotate(x + width / 2, y + height / 2, cx, cy, angle);
|
||||
return [xx - width / 2, yy - height / 2, width, height];
|
||||
}
|
||||
};
|
||||
|
||||
export function handlerRectanglesFromCoords(
|
||||
export const handlerRectanglesFromCoords = (
|
||||
[x1, y1, x2, y2]: [number, number, number, number],
|
||||
angle: number,
|
||||
zoom: number,
|
||||
pointerType: PointerType = "mouse",
|
||||
omitSides: { [T in Sides]?: boolean } = {},
|
||||
): Partial<{ [T in Sides]: [number, number, number, number] }> {
|
||||
): Partial<{ [T in Sides]: [number, number, number, number] }> => {
|
||||
const size = handleSizes[pointerType];
|
||||
const handlerWidth = size / zoom;
|
||||
const handlerHeight = size / zoom;
|
||||
@ -173,13 +173,13 @@ export function handlerRectanglesFromCoords(
|
||||
}
|
||||
|
||||
return handlers;
|
||||
}
|
||||
};
|
||||
|
||||
export function handlerRectangles(
|
||||
export const handlerRectangles = (
|
||||
element: ExcalidrawElement,
|
||||
zoom: number,
|
||||
pointerType: PointerType = "mouse",
|
||||
) {
|
||||
) => {
|
||||
const handlers = handlerRectanglesFromCoords(
|
||||
getElementAbsoluteCoords(element),
|
||||
element.angle,
|
||||
@ -234,4 +234,4 @@ export function handlerRectangles(
|
||||
}
|
||||
|
||||
return handlers;
|
||||
}
|
||||
};
|
||||
|
@ -49,35 +49,30 @@ export {
|
||||
} from "./sizeHelpers";
|
||||
export { showSelectedShapeActions } from "./showSelectedShapeActions";
|
||||
|
||||
export function getSyncableElements(elements: readonly ExcalidrawElement[]) {
|
||||
// There are places in Excalidraw where synthetic invisibly small elements are added and removed.
|
||||
export const getSyncableElements = (
|
||||
elements: readonly ExcalidrawElement[], // There are places in Excalidraw where synthetic invisibly small elements are added and removed.
|
||||
) =>
|
||||
// It's probably best to keep those local otherwise there might be a race condition that
|
||||
// gets the app into an invalid state. I've never seen it happen but I'm worried about it :)
|
||||
return elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
|
||||
}
|
||||
elements.filter((el) => el.isDeleted || !isInvisiblySmallElement(el));
|
||||
|
||||
export function getElementMap(elements: readonly ExcalidrawElement[]) {
|
||||
return elements.reduce(
|
||||
export const getElementMap = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.reduce(
|
||||
(acc: { [key: string]: ExcalidrawElement }, element: ExcalidrawElement) => {
|
||||
acc[element.id] = element;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export function getDrawingVersion(elements: readonly ExcalidrawElement[]) {
|
||||
return elements.reduce((acc, el) => acc + el.version, 0);
|
||||
}
|
||||
export const getDrawingVersion = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.reduce((acc, el) => acc + el.version, 0);
|
||||
|
||||
export function getNonDeletedElements(elements: readonly ExcalidrawElement[]) {
|
||||
return elements.filter(
|
||||
export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
||||
elements.filter(
|
||||
(element) => !element.isDeleted,
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
}
|
||||
|
||||
export function isNonDeletedElement<T extends ExcalidrawElement>(
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> {
|
||||
return !element.isDeleted;
|
||||
}
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
@ -13,10 +13,10 @@ type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
// The version is used to compare updates when more than one user is working in
|
||||
// the same drawing. Note: this will trigger the component to update. Make sure you
|
||||
// are calling it either from a React event handler or within unstable_batchedUpdates().
|
||||
export function mutateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||
export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
) {
|
||||
) => {
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
const { points } = updates as any;
|
||||
@ -45,16 +45,14 @@ export function mutateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||
element.versionNonce = randomInteger();
|
||||
|
||||
globalSceneState.informMutation();
|
||||
}
|
||||
};
|
||||
|
||||
export function newElementWith<TElement extends ExcalidrawElement>(
|
||||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
): TElement {
|
||||
return {
|
||||
...element,
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
): TElement => ({
|
||||
...element,
|
||||
version: element.version + 1,
|
||||
versionNonce: randomInteger(),
|
||||
...updates,
|
||||
});
|
||||
|
@ -5,12 +5,12 @@ import {
|
||||
} from "./newElement";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
|
||||
function isPrimitive(val: any) {
|
||||
const isPrimitive = (val: any) => {
|
||||
const type = typeof val;
|
||||
return val == null || (type !== "object" && type !== "function");
|
||||
}
|
||||
};
|
||||
|
||||
function assertCloneObjects(source: any, clone: any) {
|
||||
const assertCloneObjects = (source: any, clone: any) => {
|
||||
for (const key in clone) {
|
||||
if (clone.hasOwnProperty(key) && !isPrimitive(clone[key])) {
|
||||
expect(clone[key]).not.toBe(source[key]);
|
||||
@ -19,7 +19,7 @@ function assertCloneObjects(source: any, clone: any) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
it("clones arrow element", () => {
|
||||
const element = newLinearElement({
|
||||
|
@ -25,7 +25,7 @@ type ElementConstructorOpts = {
|
||||
angle?: ExcalidrawGenericElement["angle"];
|
||||
};
|
||||
|
||||
function _newElementBase<T extends ExcalidrawElement>(
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
type: T["type"],
|
||||
{
|
||||
x,
|
||||
@ -42,44 +42,41 @@ function _newElementBase<T extends ExcalidrawElement>(
|
||||
angle = 0,
|
||||
...rest
|
||||
}: ElementConstructorOpts & Partial<ExcalidrawGenericElement>,
|
||||
) {
|
||||
return {
|
||||
id: rest.id || randomId(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
strokeColor,
|
||||
backgroundColor,
|
||||
fillStyle,
|
||||
strokeWidth,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
opacity,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
};
|
||||
}
|
||||
) => ({
|
||||
id: rest.id || randomId(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
angle,
|
||||
strokeColor,
|
||||
backgroundColor,
|
||||
fillStyle,
|
||||
strokeWidth,
|
||||
strokeStyle,
|
||||
roughness,
|
||||
opacity,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
versionNonce: rest.versionNonce ?? 0,
|
||||
isDeleted: false as false,
|
||||
});
|
||||
|
||||
export function newElement(
|
||||
export const newElement = (
|
||||
opts: {
|
||||
type: ExcalidrawGenericElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawGenericElement> {
|
||||
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
}
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
export function newTextElement(
|
||||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
font: string;
|
||||
textAlign: TextAlign;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> {
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const metrics = measureText(opts.text, opts.font);
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
@ -98,26 +95,26 @@ export function newTextElement(
|
||||
);
|
||||
|
||||
return textElement;
|
||||
}
|
||||
};
|
||||
|
||||
export function newLinearElement(
|
||||
export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
lastCommittedPoint?: ExcalidrawLinearElement["lastCommittedPoint"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> {
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: [],
|
||||
lastCommittedPoint: opts.lastCommittedPoint || null,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Simplified deep clone for the purpose of cloning ExcalidrawElement only
|
||||
// (doesn't clone Date, RegExp, Map, Set, Typed arrays etc.)
|
||||
//
|
||||
// Adapted from https://github.com/lukeed/klona
|
||||
function _duplicateElement(val: any, depth: number = 0) {
|
||||
const _duplicateElement = (val: any, depth: number = 0) => {
|
||||
if (val == null || typeof val !== "object") {
|
||||
return val;
|
||||
}
|
||||
@ -149,12 +146,12 @@ function _duplicateElement(val: any, depth: number = 0) {
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
};
|
||||
|
||||
export function duplicateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||
export const duplicateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
overrides?: Partial<TElement>,
|
||||
): TElement {
|
||||
): TElement => {
|
||||
let copy: TElement = _duplicateElement(element);
|
||||
copy.id = randomId();
|
||||
copy.seed = randomInteger();
|
||||
@ -162,4 +159,4 @@ export function duplicateElement<TElement extends Mutable<ExcalidrawElement>>(
|
||||
copy = Object.assign(copy, overrides);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
};
|
||||
|
@ -13,27 +13,24 @@ import { AppState } from "../types";
|
||||
|
||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||
|
||||
function isInHandlerRect(
|
||||
const isInHandlerRect = (
|
||||
handler: [number, number, number, number],
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
return (
|
||||
x >= handler[0] &&
|
||||
x <= handler[0] + handler[2] &&
|
||||
y >= handler[1] &&
|
||||
y <= handler[1] + handler[3]
|
||||
);
|
||||
}
|
||||
) =>
|
||||
x >= handler[0] &&
|
||||
x <= handler[0] + handler[2] &&
|
||||
y >= handler[1] &&
|
||||
y <= handler[1] + handler[3];
|
||||
|
||||
export function resizeTest(
|
||||
export const resizeTest = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number,
|
||||
pointerType: PointerType,
|
||||
): HandlerRectanglesRet | false {
|
||||
): HandlerRectanglesRet | false => {
|
||||
if (!appState.selectedElementIds[element.id]) {
|
||||
return false;
|
||||
}
|
||||
@ -66,30 +63,29 @@ export function resizeTest(
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export function getElementWithResizeHandler(
|
||||
export const getElementWithResizeHandler = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
{ x, y }: { x: number; y: number },
|
||||
zoom: number,
|
||||
pointerType: PointerType,
|
||||
) {
|
||||
return elements.reduce((result, element) => {
|
||||
) =>
|
||||
elements.reduce((result, element) => {
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
const resizeHandle = resizeTest(element, appState, x, y, zoom, pointerType);
|
||||
return resizeHandle ? { element, resizeHandle } : null;
|
||||
}, null as { element: NonDeletedExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
|
||||
}
|
||||
|
||||
export function getResizeHandlerFromCoords(
|
||||
export const getResizeHandlerFromCoords = (
|
||||
[x1, y1, x2, y2]: readonly [number, number, number, number],
|
||||
{ x, y }: { x: number; y: number },
|
||||
zoom: number,
|
||||
pointerType: PointerType,
|
||||
) {
|
||||
) => {
|
||||
const handlers = handlerRectanglesFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
0,
|
||||
@ -103,7 +99,7 @@ export function getResizeHandlerFromCoords(
|
||||
return handler && isInHandlerRect(handler, x, y);
|
||||
});
|
||||
return (found || false) as HandlerRectanglesRet;
|
||||
}
|
||||
};
|
||||
|
||||
const RESIZE_CURSORS = ["ns", "nesw", "ew", "nwse"];
|
||||
const rotateResizeCursor = (cursor: string, angle: number) => {
|
||||
@ -118,10 +114,10 @@ const rotateResizeCursor = (cursor: string, angle: number) => {
|
||||
/*
|
||||
* Returns bi-directional cursor for the element being resized
|
||||
*/
|
||||
export function getCursorForResizingElement(resizingElement: {
|
||||
export const getCursorForResizingElement = (resizingElement: {
|
||||
element?: ExcalidrawElement;
|
||||
resizeHandle: ReturnType<typeof resizeTest>;
|
||||
}): string {
|
||||
}): string => {
|
||||
const { element, resizeHandle } = resizingElement;
|
||||
const shouldSwapCursors =
|
||||
element && Math.sign(element.height) * Math.sign(element.width) === -1;
|
||||
@ -161,12 +157,12 @@ export function getCursorForResizingElement(resizingElement: {
|
||||
}
|
||||
|
||||
return cursor ? `${cursor}-resize` : "";
|
||||
}
|
||||
};
|
||||
|
||||
export function normalizeResizeHandle(
|
||||
export const normalizeResizeHandle = (
|
||||
element: ExcalidrawElement,
|
||||
resizeHandle: HandlerRectanglesRet,
|
||||
): HandlerRectanglesRet {
|
||||
): HandlerRectanglesRet => {
|
||||
if (element.width >= 0 && element.height >= 0) {
|
||||
return resizeHandle;
|
||||
}
|
||||
@ -215,4 +211,4 @@ export function normalizeResizeHandle(
|
||||
}
|
||||
|
||||
return resizeHandle;
|
||||
}
|
||||
};
|
||||
|
@ -3,21 +3,23 @@ import { mutateElement } from "./mutateElement";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
|
||||
export function isInvisiblySmallElement(element: ExcalidrawElement): boolean {
|
||||
export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element)) {
|
||||
return element.points.length < 2;
|
||||
}
|
||||
return element.width === 0 && element.height === 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a perfect shape or diagonal/horizontal/vertical line
|
||||
*/
|
||||
export function getPerfectElementSize(
|
||||
export const getPerfectElementSize = (
|
||||
elementType: string,
|
||||
width: number,
|
||||
height: number,
|
||||
): { width: number; height: number } {
|
||||
): { width: number; height: number } => {
|
||||
const absWidth = Math.abs(width);
|
||||
const absHeight = Math.abs(height);
|
||||
|
||||
@ -42,13 +44,13 @@ export function getPerfectElementSize(
|
||||
height = absWidth * Math.sign(height);
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
};
|
||||
|
||||
export function resizePerfectLineForNWHandler(
|
||||
export const resizePerfectLineForNWHandler = (
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
) => {
|
||||
const anchorX = element.x + element.width;
|
||||
const anchorY = element.y + element.height;
|
||||
const distanceToAnchorX = x - anchorX;
|
||||
@ -77,14 +79,14 @@ export function resizePerfectLineForNWHandler(
|
||||
height: nextHeight,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns {boolean} whether element was normalized
|
||||
*/
|
||||
export function normalizeDimensions(
|
||||
export const normalizeDimensions = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawElement {
|
||||
): element is ExcalidrawElement => {
|
||||
if (!element || (element.width >= 0 && element.height >= 0)) {
|
||||
return false;
|
||||
}
|
||||
@ -106,4 +108,4 @@ export function normalizeDimensions(
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
@ -4,7 +4,7 @@ import { globalSceneState } from "../scene";
|
||||
import { isTextElement } from "./typeChecks";
|
||||
import { CLASSES } from "../constants";
|
||||
|
||||
function trimText(text: string) {
|
||||
const trimText = (text: string) => {
|
||||
// whitespace only → trim all because we'd end up inserting invisible element
|
||||
if (!text.trim()) {
|
||||
return "";
|
||||
@ -13,7 +13,7 @@ function trimText(text: string) {
|
||||
// box calculation (there's also a bug in FF which inserts trailing newline
|
||||
// for multiline texts)
|
||||
return text.replace(/^\n+|\n+$/g, "");
|
||||
}
|
||||
};
|
||||
|
||||
type TextWysiwygParams = {
|
||||
id: string;
|
||||
@ -31,7 +31,7 @@ type TextWysiwygParams = {
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export function textWysiwyg({
|
||||
export const textWysiwyg = ({
|
||||
id,
|
||||
initText,
|
||||
x,
|
||||
@ -45,7 +45,7 @@ export function textWysiwyg({
|
||||
textAlign,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: TextWysiwygParams) {
|
||||
}: TextWysiwygParams) => {
|
||||
const editable = document.createElement("div");
|
||||
try {
|
||||
editable.contentEditable = "plaintext-only";
|
||||
@ -126,20 +126,20 @@ export function textWysiwyg({
|
||||
}
|
||||
};
|
||||
|
||||
function stopEvent(event: Event) {
|
||||
const stopEvent = (event: Event) => {
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
function handleSubmit() {
|
||||
const handleSubmit = () => {
|
||||
if (editable.innerText) {
|
||||
onSubmit(trimText(editable.innerText));
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
function cleanup() {
|
||||
const cleanup = () => {
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
@ -158,7 +158,7 @@ export function textWysiwyg({
|
||||
unbindUpdate();
|
||||
|
||||
document.body.removeChild(editable);
|
||||
}
|
||||
};
|
||||
|
||||
const rebindBlur = () => {
|
||||
window.removeEventListener("pointerup", rebindBlur);
|
||||
@ -210,4 +210,4 @@ export function textWysiwyg({
|
||||
document.body.appendChild(editable);
|
||||
editable.focus();
|
||||
selectNode(editable);
|
||||
}
|
||||
};
|
||||
|
@ -4,24 +4,24 @@ import {
|
||||
ExcalidrawLinearElement,
|
||||
} from "./types";
|
||||
|
||||
export function isTextElement(
|
||||
export const isTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElement {
|
||||
): element is ExcalidrawTextElement => {
|
||||
return element != null && element.type === "text";
|
||||
}
|
||||
};
|
||||
|
||||
export function isLinearElement(
|
||||
export const isLinearElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLinearElement {
|
||||
): element is ExcalidrawLinearElement => {
|
||||
return (
|
||||
element != null &&
|
||||
(element.type === "arrow" ||
|
||||
element.type === "line" ||
|
||||
element.type === "draw")
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function isExcalidrawElement(element: any): boolean {
|
||||
export const isExcalidrawElement = (element: any): boolean => {
|
||||
return (
|
||||
element?.type === "text" ||
|
||||
element?.type === "diamond" ||
|
||||
@ -31,4 +31,4 @@ export function isExcalidrawElement(element: any): boolean {
|
||||
element?.type === "draw" ||
|
||||
element?.type === "line"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@ -1,18 +1,16 @@
|
||||
import { PointerCoords } from "./types";
|
||||
import { normalizeScroll } from "./scene";
|
||||
|
||||
export function getCenter(pointers: Map<number, PointerCoords>) {
|
||||
export const getCenter = (pointers: Map<number, PointerCoords>) => {
|
||||
const allCoords = Array.from(pointers.values());
|
||||
return {
|
||||
x: normalizeScroll(sum(allCoords, (coords) => coords.x) / allCoords.length),
|
||||
y: normalizeScroll(sum(allCoords, (coords) => coords.y) / allCoords.length),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function getDistance([a, b]: readonly PointerCoords[]) {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
export const getDistance = ([a, b]: readonly PointerCoords[]) =>
|
||||
Math.hypot(a.x - b.x, a.y - b.y);
|
||||
|
||||
function sum<T>(array: readonly T[], mapper: (item: T) => number): number {
|
||||
return array.reduce((acc, item) => acc + mapper(item), 0);
|
||||
}
|
||||
const sum = <T>(array: readonly T[], mapper: (item: T) => number): number =>
|
||||
array.reduce((acc, item) => acc + mapper(item), 0);
|
||||
|
@ -27,11 +27,11 @@ export class SceneHistory {
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
private generateEntry(
|
||||
private generateEntry = (
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) {
|
||||
return JSON.stringify({
|
||||
) =>
|
||||
JSON.stringify({
|
||||
appState: clearAppStatePropertiesForHistory(appState),
|
||||
elements: elements.reduce((elements, element) => {
|
||||
if (
|
||||
@ -69,7 +69,6 @@ export class SceneHistory {
|
||||
return elements;
|
||||
}, [] as Mutable<typeof elements>),
|
||||
});
|
||||
}
|
||||
|
||||
pushEntry(appState: AppState, elements: readonly ExcalidrawElement[]) {
|
||||
const newEntry = this.generateEntry(appState, elements);
|
||||
|
20
src/i18n.ts
20
src/i18n.ts
@ -43,20 +43,18 @@ export const languages = [
|
||||
let currentLanguage = languages[0];
|
||||
const fallbackLanguage = languages[0];
|
||||
|
||||
export function setLanguage(newLng: string | undefined) {
|
||||
export const setLanguage = (newLng: string | undefined) => {
|
||||
currentLanguage =
|
||||
languages.find((language) => language.lng === newLng) || fallbackLanguage;
|
||||
|
||||
document.documentElement.dir = currentLanguage.rtl ? "rtl" : "ltr";
|
||||
|
||||
languageDetector.cacheUserLanguage(currentLanguage.lng);
|
||||
}
|
||||
};
|
||||
|
||||
export function getLanguage() {
|
||||
return currentLanguage;
|
||||
}
|
||||
export const getLanguage = () => currentLanguage;
|
||||
|
||||
function findPartsForData(data: any, parts: string[]) {
|
||||
const findPartsForData = (data: any, parts: string[]) => {
|
||||
for (var i = 0; i < parts.length; ++i) {
|
||||
const part = parts[i];
|
||||
if (data[part] === undefined) {
|
||||
@ -68,9 +66,9 @@ function findPartsForData(data: any, parts: string[]) {
|
||||
return undefined;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
};
|
||||
|
||||
export function t(path: string, replacement?: { [key: string]: string }) {
|
||||
export const t = (path: string, replacement?: { [key: string]: string }) => {
|
||||
const parts = path.split(".");
|
||||
let translation =
|
||||
findPartsForData(currentLanguage.data, parts) ||
|
||||
@ -85,14 +83,12 @@ export function t(path: string, replacement?: { [key: string]: string }) {
|
||||
}
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
};
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {
|
||||
formatLanguageCode: function (lng: string) {
|
||||
return lng;
|
||||
},
|
||||
formatLanguageCode: (lng: string) => lng,
|
||||
isWhitelisted: () => true,
|
||||
},
|
||||
checkWhitelist: false,
|
||||
|
@ -50,7 +50,7 @@ Sentry.init({
|
||||
// Block pinch-zooming on iOS outside of the content area
|
||||
document.addEventListener(
|
||||
"touchmove",
|
||||
function (event) {
|
||||
(event) => {
|
||||
// @ts-ignore
|
||||
if (event.scale !== 1) {
|
||||
event.preventDefault();
|
||||
|
@ -2,7 +2,11 @@ import React, { useState, useEffect, useRef, useContext } from "react";
|
||||
|
||||
const context = React.createContext(false);
|
||||
|
||||
export function IsMobileProvider({ children }: { children: React.ReactNode }) {
|
||||
export const IsMobileProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const query = useRef<MediaQueryList>();
|
||||
if (!query.current) {
|
||||
query.current = window.matchMedia
|
||||
@ -24,7 +28,7 @@ export function IsMobileProvider({ children }: { children: React.ReactNode }) {
|
||||
}, []);
|
||||
|
||||
return <context.Provider value={isMobile}>{children}</context.Provider>;
|
||||
}
|
||||
};
|
||||
|
||||
export default function useIsMobile() {
|
||||
return useContext(context);
|
||||
|
14
src/keys.ts
14
src/keys.ts
@ -20,16 +20,14 @@ export const KEYS = {
|
||||
|
||||
export type Key = keyof typeof KEYS;
|
||||
|
||||
export function isArrowKey(keyCode: string) {
|
||||
return (
|
||||
keyCode === KEYS.ARROW_LEFT ||
|
||||
keyCode === KEYS.ARROW_RIGHT ||
|
||||
keyCode === KEYS.ARROW_DOWN ||
|
||||
keyCode === KEYS.ARROW_UP
|
||||
);
|
||||
}
|
||||
export const isArrowKey = (keyCode: string) =>
|
||||
keyCode === KEYS.ARROW_LEFT ||
|
||||
keyCode === KEYS.ARROW_RIGHT ||
|
||||
keyCode === KEYS.ARROW_DOWN ||
|
||||
keyCode === KEYS.ARROW_UP;
|
||||
|
||||
export const getResizeCenterPointKey = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.altKey || event.which === KEYS.ALT_KEY_CODE;
|
||||
|
||||
export const getResizeWithSidesSameLengthKey = (event: MouseEvent) =>
|
||||
event.shiftKey;
|
||||
|
39
src/math.ts
39
src/math.ts
@ -2,14 +2,14 @@ import { Point } from "./types";
|
||||
import { LINE_CONFIRM_THRESHOLD } from "./constants";
|
||||
|
||||
// https://stackoverflow.com/a/6853926/232122
|
||||
export function distanceBetweenPointAndSegment(
|
||||
export const distanceBetweenPointAndSegment = (
|
||||
x: number,
|
||||
y: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
) {
|
||||
) => {
|
||||
const A = x - x1;
|
||||
const B = y - y1;
|
||||
const C = x2 - x1;
|
||||
@ -38,23 +38,22 @@ export function distanceBetweenPointAndSegment(
|
||||
const dx = x - xx;
|
||||
const dy = y - yy;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
};
|
||||
|
||||
export function rotate(
|
||||
export const rotate = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
angle: number,
|
||||
): [number, number] {
|
||||
): [number, number] =>
|
||||
// 𝑎′𝑥=(𝑎𝑥−𝑐𝑥)cos𝜃−(𝑎𝑦−𝑐𝑦)sin𝜃+𝑐𝑥
|
||||
// 𝑎′𝑦=(𝑎𝑥−𝑐𝑥)sin𝜃+(𝑎𝑦−𝑐𝑦)cos𝜃+𝑐𝑦.
|
||||
// https://math.stackexchange.com/questions/2204520/how-do-i-rotate-a-line-segment-in-a-specific-point-on-the-line
|
||||
return [
|
||||
[
|
||||
(x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle) + x2,
|
||||
(x1 - x2) * Math.sin(angle) + (y1 - y2) * Math.cos(angle) + y2,
|
||||
];
|
||||
}
|
||||
|
||||
export const adjustXYWithRotation = (
|
||||
side: "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se",
|
||||
@ -233,15 +232,15 @@ export const getPointOnAPath = (point: Point, path: Point[]) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
export function distance2d(x1: number, y1: number, x2: number, y2: number) {
|
||||
export const distance2d = (x1: number, y1: number, x2: number, y2: number) => {
|
||||
const xd = x2 - x1;
|
||||
const yd = y2 - y1;
|
||||
return Math.hypot(xd, yd);
|
||||
}
|
||||
};
|
||||
|
||||
// Checks if the first and last point are close enough
|
||||
// to be considered a loop
|
||||
export function isPathALoop(points: Point[]): boolean {
|
||||
export const isPathALoop = (points: Point[]): boolean => {
|
||||
if (points.length >= 3) {
|
||||
const [firstPoint, lastPoint] = [points[0], points[points.length - 1]];
|
||||
return (
|
||||
@ -250,16 +249,16 @@ export function isPathALoop(points: Point[]): boolean {
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Draw a line from the point to the right till infiinty
|
||||
// Check how many lines of the polygon does this infinite line intersects with
|
||||
// If the number of intersections is odd, point is in the polygon
|
||||
export function isPointInPolygon(
|
||||
export const isPointInPolygon = (
|
||||
points: Point[],
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean {
|
||||
): boolean => {
|
||||
const vertices = points.length;
|
||||
|
||||
// There must be at least 3 vertices in polygon
|
||||
@ -281,32 +280,32 @@ export function isPointInPolygon(
|
||||
}
|
||||
// true if count is off
|
||||
return count % 2 === 1;
|
||||
}
|
||||
};
|
||||
|
||||
// Check if q lies on the line segment pr
|
||||
function onSegment(p: Point, q: Point, r: Point) {
|
||||
const onSegment = (p: Point, q: Point, r: Point) => {
|
||||
return (
|
||||
q[0] <= Math.max(p[0], r[0]) &&
|
||||
q[0] >= Math.min(p[0], r[0]) &&
|
||||
q[1] <= Math.max(p[1], r[1]) &&
|
||||
q[1] >= Math.min(p[1], r[1])
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// For the ordered points p, q, r, return
|
||||
// 0 if p, q, r are collinear
|
||||
// 1 if Clockwise
|
||||
// 2 if counterclickwise
|
||||
function orientation(p: Point, q: Point, r: Point) {
|
||||
const orientation = (p: Point, q: Point, r: Point) => {
|
||||
const val = (q[1] - p[1]) * (r[0] - q[0]) - (q[0] - p[0]) * (r[1] - q[1]);
|
||||
if (val === 0) {
|
||||
return 0;
|
||||
}
|
||||
return val > 0 ? 1 : 2;
|
||||
}
|
||||
};
|
||||
|
||||
// Check is p1q1 intersects with p2q2
|
||||
function doIntersect(p1: Point, q1: Point, p2: Point, q2: Point) {
|
||||
const doIntersect = (p1: Point, q1: Point, p2: Point, q2: Point) => {
|
||||
const o1 = orientation(p1, q1, p2);
|
||||
const o2 = orientation(p1, q1, q2);
|
||||
const o3 = orientation(p2, q2, p1);
|
||||
@ -337,4 +336,4 @@ function doIntersect(p1: Point, q1: Point, p2: Point, q2: Point) {
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { Point } from "./types";
|
||||
|
||||
export function getSizeFromPoints(points: readonly Point[]) {
|
||||
export const getSizeFromPoints = (points: readonly Point[]) => {
|
||||
const xs = points.map((point) => point[0]);
|
||||
const ys = points.map((point) => point[1]);
|
||||
return {
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
};
|
||||
}
|
||||
export function rescalePoints(
|
||||
};
|
||||
export const rescalePoints = (
|
||||
dimension: 0 | 1,
|
||||
nextDimensionSize: number,
|
||||
prevPoints: readonly Point[],
|
||||
): Point[] {
|
||||
): Point[] => {
|
||||
const prevDimValues = prevPoints.map((point) => point[dimension]);
|
||||
const prevMaxDimension = Math.max(...prevDimValues);
|
||||
const prevMinDimension = Math.min(...prevDimValues);
|
||||
@ -50,4 +50,4 @@ export function rescalePoints(
|
||||
);
|
||||
|
||||
return nextPoints;
|
||||
}
|
||||
};
|
||||
|
@ -4,15 +4,12 @@ import nanoid from "nanoid";
|
||||
let random = new Random(Date.now());
|
||||
let testIdBase = 0;
|
||||
|
||||
export function randomInteger() {
|
||||
return Math.floor(random.next() * 2 ** 31);
|
||||
}
|
||||
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
|
||||
|
||||
export function reseed(seed: number) {
|
||||
export const reseed = (seed: number) => {
|
||||
random = new Random(seed);
|
||||
testIdBase = 0;
|
||||
}
|
||||
};
|
||||
|
||||
export function randomId() {
|
||||
return process.env.NODE_ENV === "test" ? `id${testIdBase++}` : nanoid();
|
||||
}
|
||||
export const randomId = () =>
|
||||
process.env.NODE_ENV === "test" ? `id${testIdBase++}` : nanoid();
|
||||
|
@ -31,10 +31,10 @@ export interface ExcalidrawElementWithCanvas {
|
||||
canvasOffsetY: number;
|
||||
}
|
||||
|
||||
function generateElementCanvas(
|
||||
const generateElementCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
zoom: number,
|
||||
): ExcalidrawElementWithCanvas {
|
||||
): ExcalidrawElementWithCanvas => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
@ -75,13 +75,13 @@ function generateElementCanvas(
|
||||
1 / (window.devicePixelRatio * zoom),
|
||||
);
|
||||
return { element, canvas, canvasZoom: zoom, canvasOffsetX, canvasOffsetY };
|
||||
}
|
||||
};
|
||||
|
||||
function drawElementOnCanvas(
|
||||
const drawElementOnCanvas = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
) {
|
||||
) => {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
@ -132,7 +132,7 @@ function drawElementOnCanvas(
|
||||
}
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
}
|
||||
};
|
||||
|
||||
const elementWithCanvasCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
@ -144,15 +144,13 @@ const shapeCache = new WeakMap<
|
||||
Drawable | Drawable[] | null
|
||||
>();
|
||||
|
||||
export function getShapeForElement(element: ExcalidrawElement) {
|
||||
return shapeCache.get(element);
|
||||
}
|
||||
export const getShapeForElement = (element: ExcalidrawElement) =>
|
||||
shapeCache.get(element);
|
||||
|
||||
export function invalidateShapeForElement(element: ExcalidrawElement) {
|
||||
export const invalidateShapeForElement = (element: ExcalidrawElement) =>
|
||||
shapeCache.delete(element);
|
||||
}
|
||||
|
||||
export function generateRoughOptions(element: ExcalidrawElement): Options {
|
||||
export const generateRoughOptions = (element: ExcalidrawElement): Options => {
|
||||
const options: Options = {
|
||||
seed: element.seed,
|
||||
strokeLineDash:
|
||||
@ -214,13 +212,13 @@ export function generateRoughOptions(element: ExcalidrawElement): Options {
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function generateElement(
|
||||
const generateElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
generator: RoughGenerator,
|
||||
sceneState?: SceneState,
|
||||
) {
|
||||
) => {
|
||||
let shape = shapeCache.get(element) || null;
|
||||
if (!shape) {
|
||||
elementWithCanvasCache.delete(element);
|
||||
@ -319,14 +317,14 @@ function generateElement(
|
||||
return elementWithCanvas;
|
||||
}
|
||||
return prevElementWithCanvas;
|
||||
}
|
||||
};
|
||||
|
||||
function drawElementFromCanvas(
|
||||
const drawElementFromCanvas = (
|
||||
elementWithCanvas: ExcalidrawElementWithCanvas,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
sceneState: SceneState,
|
||||
) {
|
||||
) => {
|
||||
const element = elementWithCanvas.element;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = ((x1 + x2) / 2 + sceneState.scrollX) * window.devicePixelRatio;
|
||||
@ -346,15 +344,15 @@ function drawElementFromCanvas(
|
||||
context.rotate(-element.angle);
|
||||
context.translate(-cx, -cy);
|
||||
context.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
}
|
||||
};
|
||||
|
||||
export function renderElement(
|
||||
export const renderElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rc: RoughCanvas,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderOptimizations: boolean,
|
||||
sceneState: SceneState,
|
||||
) {
|
||||
) => {
|
||||
const generator = rc.generator;
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
@ -404,15 +402,15 @@ export function renderElement(
|
||||
throw new Error(`Unimplemented type ${element.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function renderElementToSvg(
|
||||
export const renderElementToSvg = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
offsetX?: number,
|
||||
offsetY?: number,
|
||||
) {
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x2 - x1) / 2 - (element.x - x1);
|
||||
const cy = (y2 - y1) / 2 - (element.y - y1);
|
||||
@ -528,4 +526,4 @@ export function renderElementToSvg(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -30,7 +30,7 @@ import colors from "../colors";
|
||||
|
||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||
|
||||
function colorsForClientId(clientId: string) {
|
||||
const colorsForClientId = (clientId: string) => {
|
||||
// Naive way of getting an integer out of the clientId
|
||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||
|
||||
@ -41,9 +41,9 @@ function colorsForClientId(clientId: string) {
|
||||
background: backgrounds[sum % backgrounds.length],
|
||||
stroke: strokes[sum % strokes.length],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function strokeRectWithRotation(
|
||||
const strokeRectWithRotation = (
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
@ -53,7 +53,7 @@ function strokeRectWithRotation(
|
||||
cy: number,
|
||||
angle: number,
|
||||
fill?: boolean,
|
||||
) {
|
||||
) => {
|
||||
context.translate(cx, cy);
|
||||
context.rotate(angle);
|
||||
if (fill) {
|
||||
@ -62,22 +62,22 @@ function strokeRectWithRotation(
|
||||
context.strokeRect(x - cx, y - cy, width, height);
|
||||
context.rotate(-angle);
|
||||
context.translate(-cx, -cy);
|
||||
}
|
||||
};
|
||||
|
||||
function strokeCircle(
|
||||
const strokeCircle = (
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
) {
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
export function renderScene(
|
||||
export const renderScene = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
selectionElement: NonDeletedExcalidrawElement | null,
|
||||
@ -98,7 +98,7 @@ export function renderScene(
|
||||
renderSelection?: boolean;
|
||||
renderOptimizations?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
) => {
|
||||
if (!canvas) {
|
||||
return { atLeastOneVisibleElement: false };
|
||||
}
|
||||
@ -461,9 +461,9 @@ export function renderScene(
|
||||
context.scale(1 / scale, 1 / scale);
|
||||
|
||||
return { atLeastOneVisibleElement: visibleElements.length > 0, scrollBars };
|
||||
}
|
||||
};
|
||||
|
||||
function isVisibleElement(
|
||||
const isVisibleElement = (
|
||||
element: ExcalidrawElement,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
@ -476,7 +476,7 @@ function isVisibleElement(
|
||||
scrollY: FlooredNumber;
|
||||
zoom: number;
|
||||
},
|
||||
) {
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
// Apply zoom
|
||||
@ -492,10 +492,10 @@ function isVisibleElement(
|
||||
y2 + scrollY - viewportHeightDiff / 2 >= 0 &&
|
||||
y1 + scrollY - viewportHeightDiff / 2 <= viewportHeightWithZoom
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// This should be only called for exporting purposes
|
||||
export function renderSceneToSvg(
|
||||
export const renderSceneToSvg = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
rsvg: RoughSVG,
|
||||
svgRoot: SVGElement,
|
||||
@ -506,7 +506,7 @@ export function renderSceneToSvg(
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
} = {},
|
||||
) {
|
||||
) => {
|
||||
if (!svgRoot) {
|
||||
return;
|
||||
}
|
||||
@ -522,4 +522,4 @@ export function renderSceneToSvg(
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -8,14 +8,14 @@
|
||||
* @param {Number} height The height of the rectangle
|
||||
* @param {Number} radius The corner radius
|
||||
*/
|
||||
export function roundRect(
|
||||
export const roundRect = (
|
||||
context: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
) {
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.moveTo(x + radius, y);
|
||||
context.lineTo(x + width - radius, y);
|
||||
@ -34,4 +34,4 @@ export function roundRect(
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
};
|
||||
|
@ -23,13 +23,13 @@ export const hasStroke = (type: string) =>
|
||||
|
||||
export const hasText = (type: string) => type === "text";
|
||||
|
||||
export function getElementAtPosition(
|
||||
export const getElementAtPosition = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number,
|
||||
) {
|
||||
) => {
|
||||
let hitElement = null;
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
for (let i = elements.length - 1; i >= 0; --i) {
|
||||
@ -43,13 +43,13 @@ export function getElementAtPosition(
|
||||
}
|
||||
|
||||
return hitElement;
|
||||
}
|
||||
};
|
||||
|
||||
export function getElementContainingPosition(
|
||||
export const getElementContainingPosition = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
) => {
|
||||
let hitElement = null;
|
||||
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||
for (let i = elements.length - 1; i >= 0; --i) {
|
||||
@ -63,4 +63,4 @@ export function getElementContainingPosition(
|
||||
}
|
||||
}
|
||||
return hitElement;
|
||||
}
|
||||
};
|
||||
|
@ -11,7 +11,7 @@ import { t } from "../i18n";
|
||||
|
||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
export function exportToCanvas(
|
||||
export const exportToCanvas = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
{
|
||||
@ -27,16 +27,13 @@ export function exportToCanvas(
|
||||
viewBackgroundColor: string;
|
||||
shouldAddWatermark: boolean;
|
||||
},
|
||||
createCanvas: (width: number, height: number) => any = function (
|
||||
width,
|
||||
height,
|
||||
) {
|
||||
createCanvas: (width: number, height: number) => any = (width, height) => {
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = width * scale;
|
||||
tempCanvas.height = height * scale;
|
||||
return tempCanvas;
|
||||
},
|
||||
) {
|
||||
) => {
|
||||
let sceneElements = elements;
|
||||
if (shouldAddWatermark) {
|
||||
const [, , maxX, maxY] = getCommonBounds(elements);
|
||||
@ -78,9 +75,9 @@ export function exportToCanvas(
|
||||
);
|
||||
|
||||
return tempCanvas;
|
||||
}
|
||||
};
|
||||
|
||||
export function exportToSvg(
|
||||
export const exportToSvg = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
{
|
||||
exportBackground,
|
||||
@ -93,7 +90,7 @@ export function exportToSvg(
|
||||
viewBackgroundColor: string;
|
||||
shouldAddWatermark: boolean;
|
||||
},
|
||||
): SVGSVGElement {
|
||||
): SVGSVGElement => {
|
||||
let sceneElements = elements;
|
||||
if (shouldAddWatermark) {
|
||||
const [, , maxX, maxY] = getCommonBounds(elements);
|
||||
@ -148,9 +145,9 @@ export function exportToSvg(
|
||||
});
|
||||
|
||||
return svgRoot;
|
||||
}
|
||||
};
|
||||
|
||||
function getWatermarkElement(maxX: number, maxY: number) {
|
||||
const getWatermarkElement = (maxX: number, maxY: number) => {
|
||||
const text = t("labels.madeWithExcalidraw");
|
||||
const font = "16px Virgil";
|
||||
const { width: textWidth } = measureText(text, font);
|
||||
@ -169,4 +166,4 @@ function getWatermarkElement(maxX: number, maxY: number) {
|
||||
roughness: 1,
|
||||
opacity: 100,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
@ -2,13 +2,12 @@ import { FlooredNumber } from "../types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getCommonBounds } from "../element";
|
||||
|
||||
export function normalizeScroll(pos: number) {
|
||||
return Math.floor(pos) as FlooredNumber;
|
||||
}
|
||||
export const normalizeScroll = (pos: number) =>
|
||||
Math.floor(pos) as FlooredNumber;
|
||||
|
||||
export function calculateScrollCenter(
|
||||
export const calculateScrollCenter = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): { scrollX: FlooredNumber; scrollY: FlooredNumber } {
|
||||
): { scrollX: FlooredNumber; scrollY: FlooredNumber } => {
|
||||
if (!elements.length) {
|
||||
return {
|
||||
scrollX: normalizeScroll(0),
|
||||
@ -25,4 +24,4 @@ export function calculateScrollCenter(
|
||||
scrollX: normalizeScroll(window.innerWidth / 2 - centerX),
|
||||
scrollY: normalizeScroll(window.innerHeight / 2 - centerY),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ export const SCROLLBAR_MARGIN = 4;
|
||||
export const SCROLLBAR_WIDTH = 6;
|
||||
export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)";
|
||||
|
||||
export function getScrollBars(
|
||||
export const getScrollBars = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
@ -22,7 +22,7 @@ export function getScrollBars(
|
||||
scrollY: FlooredNumber;
|
||||
zoom: number;
|
||||
},
|
||||
): ScrollBars {
|
||||
): ScrollBars => {
|
||||
// This is the bounding box of all the elements
|
||||
const [
|
||||
elementsMinX,
|
||||
@ -100,9 +100,13 @@ export function getScrollBars(
|
||||
Math.max(SCROLLBAR_MARGIN * 2, safeArea.top + safeArea.bottom),
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function isOverScrollBars(scrollBars: ScrollBars, x: number, y: number) {
|
||||
export const isOverScrollBars = (
|
||||
scrollBars: ScrollBars,
|
||||
x: number,
|
||||
y: number,
|
||||
) => {
|
||||
const [isOverHorizontalScrollBar, isOverVerticalScrollBar] = [
|
||||
scrollBars.horizontal,
|
||||
scrollBars.vertical,
|
||||
@ -120,4 +124,4 @@ export function isOverScrollBars(scrollBars: ScrollBars, x: number, y: number) {
|
||||
isOverHorizontalScrollBar,
|
||||
isOverVerticalScrollBar,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
@ -6,10 +6,10 @@ import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||
import { AppState } from "../types";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
|
||||
export function getElementsWithinSelection(
|
||||
export const getElementsWithinSelection = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
selection: NonDeletedExcalidrawElement,
|
||||
) {
|
||||
) => {
|
||||
const [
|
||||
selectionX1,
|
||||
selectionY1,
|
||||
@ -29,12 +29,12 @@ export function getElementsWithinSelection(
|
||||
selectionY2 >= elementY2
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export function deleteSelectedElements(
|
||||
export const deleteSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
@ -47,24 +47,24 @@ export function deleteSelectedElements(
|
||||
selectedElementIds: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function isSomeElementSelected(
|
||||
export const isSomeElementSelected = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
): boolean {
|
||||
): boolean => {
|
||||
return elements.some((element) => appState.selectedElementIds[element.id]);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns common attribute (picked by `getAttribute` callback) of selected
|
||||
* elements. If elements don't share the same value, returns `null`.
|
||||
*/
|
||||
export function getCommonAttributeOfSelectedElements<T>(
|
||||
export const getCommonAttributeOfSelectedElements = <T>(
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
): T | null {
|
||||
): T | null => {
|
||||
const attributes = Array.from(
|
||||
new Set(
|
||||
getSelectedElements(elements, appState).map((element) =>
|
||||
@ -73,20 +73,20 @@ export function getCommonAttributeOfSelectedElements<T>(
|
||||
),
|
||||
);
|
||||
return attributes.length === 1 ? attributes[0] : null;
|
||||
}
|
||||
};
|
||||
|
||||
export function getSelectedElements(
|
||||
export const getSelectedElements = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
return elements.filter((element) => appState.selectedElementIds[element.id]);
|
||||
}
|
||||
};
|
||||
|
||||
export function getTargetElement(
|
||||
export const getTargetElement = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) {
|
||||
) => {
|
||||
return appState.editingElement
|
||||
? [appState.editingElement]
|
||||
: getSelectedElements(elements, appState);
|
||||
}
|
||||
};
|
||||
|
@ -1,4 +1,7 @@
|
||||
export function getZoomOrigin(canvas: HTMLCanvasElement | null, scale: number) {
|
||||
export const getZoomOrigin = (
|
||||
canvas: HTMLCanvasElement | null,
|
||||
scale: number,
|
||||
) => {
|
||||
if (canvas === null) {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
@ -14,10 +17,10 @@ export function getZoomOrigin(canvas: HTMLCanvasElement | null, scale: number) {
|
||||
x: normalizedCanvasWidth / 2,
|
||||
y: normalizedCanvasHeight / 2,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function getNormalizedZoom(zoom: number): number {
|
||||
export const getNormalizedZoom = (zoom: number): number => {
|
||||
const normalizedZoom = parseFloat(zoom.toFixed(2));
|
||||
const clampedZoom = Math.max(0.1, Math.min(normalizedZoom, 2));
|
||||
return clampedZoom;
|
||||
}
|
||||
};
|
||||
|
@ -25,7 +25,7 @@ type Config = {
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
export const register = (config?: Config) => {
|
||||
if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||
@ -57,9 +57,9 @@ export function register(config?: Config) {
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
const registerValidSW = (swUrl: string, config?: Config) => {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then((registration) => {
|
||||
@ -103,9 +103,9 @@ function registerValidSW(swUrl: string, config?: Config) {
|
||||
.catch((error) => {
|
||||
console.error("Error during service worker registration:", error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
const checkValidServiceWorker = (swUrl: string, config?: Config) => {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { "Service-Worker": "script" },
|
||||
@ -133,9 +133,9 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
// "No internet connection found. App is running in offline mode.",
|
||||
// );
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export function unregister() {
|
||||
export const unregister = () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
navigator.serviceWorker.ready
|
||||
.then((registration) => {
|
||||
@ -145,4 +145,4 @@ export function unregister() {
|
||||
console.error(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -100,10 +100,7 @@ export const shapesShortcutKeys = SHAPES.map((shape, index) => [
|
||||
(index + 1).toString(),
|
||||
]).flat(1);
|
||||
|
||||
export function findShapeByKey(key: string) {
|
||||
return (
|
||||
SHAPES.find((shape, index) => {
|
||||
return shape.key === key.toLowerCase() || key === (index + 1).toString();
|
||||
})?.value || "selection"
|
||||
);
|
||||
}
|
||||
export const findShapeByKey = (key: string) =>
|
||||
SHAPES.find((shape, index) => {
|
||||
return shape.key === key.toLowerCase() || key === (index + 1).toString();
|
||||
})?.value || "selection";
|
||||
|
@ -16,20 +16,20 @@ const renderScene = jest.spyOn(Renderer, "renderScene");
|
||||
let getByToolName: (name: string) => HTMLElement = null!;
|
||||
let canvas: HTMLCanvasElement = null!;
|
||||
|
||||
function clickTool(toolName: ToolName) {
|
||||
const clickTool = (toolName: ToolName) => {
|
||||
fireEvent.click(getByToolName(toolName));
|
||||
}
|
||||
};
|
||||
|
||||
let lastClientX = 0;
|
||||
let lastClientY = 0;
|
||||
let pointerType: "mouse" | "pen" | "touch" = "mouse";
|
||||
|
||||
function pointerDown(
|
||||
const pointerDown = (
|
||||
clientX: number = lastClientX,
|
||||
clientY: number = lastClientY,
|
||||
altKey: boolean = false,
|
||||
shiftKey: boolean = false,
|
||||
) {
|
||||
) => {
|
||||
lastClientX = clientX;
|
||||
lastClientY = clientY;
|
||||
fireEvent.pointerDown(canvas, {
|
||||
@ -40,41 +40,41 @@ function pointerDown(
|
||||
pointerId: 1,
|
||||
pointerType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function pointer2Down(clientX: number, clientY: number) {
|
||||
const pointer2Down = (clientX: number, clientY: number) => {
|
||||
fireEvent.pointerDown(canvas, {
|
||||
clientX,
|
||||
clientY,
|
||||
pointerId: 2,
|
||||
pointerType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function pointer2Move(clientX: number, clientY: number) {
|
||||
const pointer2Move = (clientX: number, clientY: number) => {
|
||||
fireEvent.pointerMove(canvas, {
|
||||
clientX,
|
||||
clientY,
|
||||
pointerId: 2,
|
||||
pointerType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function pointer2Up(clientX: number, clientY: number) {
|
||||
const pointer2Up = (clientX: number, clientY: number) => {
|
||||
fireEvent.pointerUp(canvas, {
|
||||
clientX,
|
||||
clientY,
|
||||
pointerId: 2,
|
||||
pointerType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function pointerMove(
|
||||
const pointerMove = (
|
||||
clientX: number = lastClientX,
|
||||
clientY: number = lastClientY,
|
||||
altKey: boolean = false,
|
||||
shiftKey: boolean = false,
|
||||
) {
|
||||
) => {
|
||||
lastClientX = clientX;
|
||||
lastClientY = clientY;
|
||||
fireEvent.pointerMove(canvas, {
|
||||
@ -85,72 +85,72 @@ function pointerMove(
|
||||
pointerId: 1,
|
||||
pointerType,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function pointerUp(
|
||||
const pointerUp = (
|
||||
clientX: number = lastClientX,
|
||||
clientY: number = lastClientY,
|
||||
altKey: boolean = false,
|
||||
shiftKey: boolean = false,
|
||||
) {
|
||||
) => {
|
||||
lastClientX = clientX;
|
||||
lastClientY = clientY;
|
||||
fireEvent.pointerUp(canvas, { pointerId: 1, pointerType, shiftKey, altKey });
|
||||
}
|
||||
};
|
||||
|
||||
function hotkeyDown(key: Key) {
|
||||
const hotkeyDown = (key: Key) => {
|
||||
fireEvent.keyDown(document, { key: KEYS[key] });
|
||||
}
|
||||
};
|
||||
|
||||
function hotkeyUp(key: Key) {
|
||||
const hotkeyUp = (key: Key) => {
|
||||
fireEvent.keyUp(document, {
|
||||
key: KEYS[key],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function keyDown(
|
||||
const keyDown = (
|
||||
key: string,
|
||||
ctrlKey: boolean = false,
|
||||
shiftKey: boolean = false,
|
||||
) {
|
||||
) => {
|
||||
fireEvent.keyDown(document, { key, ctrlKey, shiftKey });
|
||||
}
|
||||
};
|
||||
|
||||
function keyUp(
|
||||
const keyUp = (
|
||||
key: string,
|
||||
ctrlKey: boolean = false,
|
||||
shiftKey: boolean = false,
|
||||
) {
|
||||
) => {
|
||||
fireEvent.keyUp(document, {
|
||||
key,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function hotkeyPress(key: Key) {
|
||||
const hotkeyPress = (key: Key) => {
|
||||
hotkeyDown(key);
|
||||
hotkeyUp(key);
|
||||
}
|
||||
};
|
||||
|
||||
function keyPress(
|
||||
const keyPress = (
|
||||
key: string,
|
||||
ctrlKey: boolean = false,
|
||||
shiftKey: boolean = false,
|
||||
) {
|
||||
) => {
|
||||
keyDown(key, ctrlKey, shiftKey);
|
||||
keyUp(key, ctrlKey, shiftKey);
|
||||
}
|
||||
};
|
||||
|
||||
function clickLabeledElement(label: string) {
|
||||
const clickLabeledElement = (label: string) => {
|
||||
const element = document.querySelector(`[aria-label='${label}']`);
|
||||
if (!element) {
|
||||
throw new Error(`No labeled element found: ${label}`);
|
||||
}
|
||||
fireEvent.click(element);
|
||||
}
|
||||
};
|
||||
|
||||
function getSelectedElement(): ExcalidrawElement {
|
||||
const getSelectedElement = (): ExcalidrawElement => {
|
||||
const selectedElements = h.elements.filter(
|
||||
(element) => h.state.selectedElementIds[element.id],
|
||||
);
|
||||
@ -160,10 +160,10 @@ function getSelectedElement(): ExcalidrawElement {
|
||||
);
|
||||
}
|
||||
return selectedElements[0];
|
||||
}
|
||||
};
|
||||
|
||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||
function getResizeHandles() {
|
||||
const getResizeHandles = () => {
|
||||
const rects = handlerRectangles(
|
||||
getSelectedElement(),
|
||||
h.state.zoom,
|
||||
@ -181,14 +181,14 @@ function getResizeHandles() {
|
||||
}
|
||||
|
||||
return rv;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This is always called at the end of your test, so usually you don't need to call it.
|
||||
* However, if you have a long test, you might want to call it during the test so it's easier
|
||||
* to debug where a test failure came from.
|
||||
*/
|
||||
function checkpoint(name: string) {
|
||||
const checkpoint = (name: string) => {
|
||||
expect(renderScene.mock.calls.length).toMatchSnapshot(
|
||||
`[${name}] number of renders`,
|
||||
);
|
||||
@ -198,7 +198,7 @@ function checkpoint(name: string) {
|
||||
h.elements.forEach((element, i) =>
|
||||
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Unmount ReactDOM from root
|
||||
|
@ -22,9 +22,9 @@ beforeEach(() => {
|
||||
|
||||
const { h } = window;
|
||||
|
||||
function populateElements(
|
||||
const populateElements = (
|
||||
elements: { id: string; isDeleted?: boolean; isSelected?: boolean }[],
|
||||
) {
|
||||
) => {
|
||||
const selectedElementIds: any = {};
|
||||
|
||||
h.elements = elements.map(({ id, isDeleted = false, isSelected = false }) => {
|
||||
@ -54,7 +54,7 @@ function populateElements(
|
||||
});
|
||||
|
||||
return selectedElementIds;
|
||||
}
|
||||
};
|
||||
|
||||
type Actions =
|
||||
| typeof actionBringForward
|
||||
@ -62,20 +62,20 @@ type Actions =
|
||||
| typeof actionBringToFront
|
||||
| typeof actionSendToBack;
|
||||
|
||||
function assertZindex({
|
||||
const assertZindex = ({
|
||||
elements,
|
||||
operations,
|
||||
}: {
|
||||
elements: { id: string; isDeleted?: true; isSelected?: true }[];
|
||||
operations: [Actions, string[]][];
|
||||
}) {
|
||||
}) => {
|
||||
const selectedElementIds = populateElements(elements);
|
||||
operations.forEach(([action, expected]) => {
|
||||
h.app.actionManager.executeAction(action);
|
||||
expect(h.elements.map((element) => element.id)).toEqual(expected);
|
||||
expect(h.state.selectedElementIds).toEqual(selectedElementIds);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
describe("z-index manipulation", () => {
|
||||
it("send back", () => {
|
||||
|
99
src/utils.ts
99
src/utils.ts
@ -6,9 +6,9 @@ export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
let mockDateTime: string | null = null;
|
||||
|
||||
export function setDateTimeForTests(dateTime: string) {
|
||||
export const setDateTimeForTests = (dateTime: string) => {
|
||||
mockDateTime = dateTime;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDateTime = () => {
|
||||
if (mockDateTime) {
|
||||
@ -25,51 +25,43 @@ export const getDateTime = () => {
|
||||
return `${year}-${month}-${day}-${hr}${min}`;
|
||||
};
|
||||
|
||||
export function capitalizeString(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
export const capitalizeString = (str: string) =>
|
||||
str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
export function isToolIcon(
|
||||
export const isToolIcon = (
|
||||
target: Element | EventTarget | null,
|
||||
): target is HTMLElement {
|
||||
return target instanceof HTMLElement && target.className.includes("ToolIcon");
|
||||
}
|
||||
): target is HTMLElement =>
|
||||
target instanceof HTMLElement && target.className.includes("ToolIcon");
|
||||
|
||||
export function isInputLike(
|
||||
export const isInputLike = (
|
||||
target: Element | EventTarget | null,
|
||||
): target is
|
||||
| HTMLInputElement
|
||||
| HTMLTextAreaElement
|
||||
| HTMLSelectElement
|
||||
| HTMLBRElement
|
||||
| HTMLDivElement {
|
||||
return (
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
);
|
||||
}
|
||||
| HTMLDivElement =>
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement;
|
||||
|
||||
export function isWritableElement(
|
||||
export const isWritableElement = (
|
||||
target: Element | EventTarget | null,
|
||||
): target is
|
||||
| HTMLInputElement
|
||||
| HTMLTextAreaElement
|
||||
| HTMLBRElement
|
||||
| HTMLDivElement {
|
||||
return (
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" || target.type === "number"))
|
||||
);
|
||||
}
|
||||
| HTMLDivElement =>
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLBRElement || // newline in wysiwyg
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" || target.type === "number"));
|
||||
|
||||
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||
export function measureText(text: string, font: string) {
|
||||
export const measureText = (text: string, font: string) => {
|
||||
const line = document.createElement("div");
|
||||
const body = document.body;
|
||||
line.style.position = "absolute";
|
||||
@ -93,12 +85,12 @@ export function measureText(text: string, font: string) {
|
||||
document.body.removeChild(line);
|
||||
|
||||
return { width, height, baseline };
|
||||
}
|
||||
};
|
||||
|
||||
export function debounce<T extends any[]>(
|
||||
export const debounce = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
timeout: number,
|
||||
) {
|
||||
) => {
|
||||
let handle = 0;
|
||||
let lastArgs: T;
|
||||
const ret = (...args: T) => {
|
||||
@ -111,9 +103,9 @@ export function debounce<T extends any[]>(
|
||||
fn(...lastArgs);
|
||||
};
|
||||
return ret;
|
||||
}
|
||||
};
|
||||
|
||||
export function selectNode(node: Element) {
|
||||
export const selectNode = (node: Element) => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
const range = document.createRange();
|
||||
@ -121,30 +113,28 @@ export function selectNode(node: Element) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function removeSelection() {
|
||||
export const removeSelection = () => {
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function distance(x: number, y: number) {
|
||||
return Math.abs(x - y);
|
||||
}
|
||||
export const distance = (x: number, y: number) => Math.abs(x - y);
|
||||
|
||||
export function resetCursor() {
|
||||
export const resetCursor = () => {
|
||||
document.documentElement.style.cursor = "";
|
||||
}
|
||||
};
|
||||
|
||||
export function setCursorForShape(shape: string) {
|
||||
export const setCursorForShape = (shape: string) => {
|
||||
if (shape === "selection") {
|
||||
resetCursor();
|
||||
} else {
|
||||
document.documentElement.style.cursor = CURSOR_TYPE.CROSSHAIR;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const isFullScreen = () =>
|
||||
document.fullscreenElement?.nodeName === "HTML";
|
||||
@ -165,7 +155,7 @@ export const getShortcutKey = (shortcut: string): string => {
|
||||
}
|
||||
return `${shortcut.replace(/CtrlOrCmd/i, "Ctrl")}`;
|
||||
};
|
||||
export function viewportCoordsToSceneCoords(
|
||||
export const viewportCoordsToSceneCoords = (
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
{
|
||||
scrollX,
|
||||
@ -178,7 +168,7 @@ export function viewportCoordsToSceneCoords(
|
||||
},
|
||||
canvas: HTMLCanvasElement | null,
|
||||
scale: number,
|
||||
) {
|
||||
) => {
|
||||
const zoomOrigin = getZoomOrigin(canvas, scale);
|
||||
const clientXWithZoom = zoomOrigin.x + (clientX - zoomOrigin.x) / zoom;
|
||||
const clientYWithZoom = zoomOrigin.y + (clientY - zoomOrigin.y) / zoom;
|
||||
@ -187,9 +177,9 @@ export function viewportCoordsToSceneCoords(
|
||||
const y = clientYWithZoom - scrollY;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
};
|
||||
|
||||
export function sceneCoordsToViewportCoords(
|
||||
export const sceneCoordsToViewportCoords = (
|
||||
{ sceneX, sceneY }: { sceneX: number; sceneY: number },
|
||||
{
|
||||
scrollX,
|
||||
@ -202,7 +192,7 @@ export function sceneCoordsToViewportCoords(
|
||||
},
|
||||
canvas: HTMLCanvasElement | null,
|
||||
scale: number,
|
||||
) {
|
||||
) => {
|
||||
const zoomOrigin = getZoomOrigin(canvas, scale);
|
||||
const sceneXWithZoomAndScroll =
|
||||
zoomOrigin.x - (zoomOrigin.x - sceneX - scrollX) * zoom;
|
||||
@ -213,10 +203,7 @@ export function sceneCoordsToViewportCoords(
|
||||
const y = sceneYWithZoomAndScroll;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
};
|
||||
|
||||
export function getGlobalCSSVariable(name: string) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(
|
||||
`--${name}`,
|
||||
);
|
||||
}
|
||||
export const getGlobalCSSVariable = (name: string) =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue(`--${name}`);
|
||||
|
@ -1,14 +1,14 @@
|
||||
import { moveOneLeft, moveOneRight, moveAllLeft, moveAllRight } from "./zindex";
|
||||
|
||||
function expectMove<T>(
|
||||
const expectMove = <T>(
|
||||
fn: (elements: T[], indicesToMove: number[]) => void,
|
||||
elems: T[],
|
||||
indices: number[],
|
||||
equal: T[],
|
||||
) {
|
||||
) => {
|
||||
fn(elems, indices);
|
||||
expect(elems).toEqual(equal);
|
||||
}
|
||||
};
|
||||
|
||||
it("should moveOneLeft", () => {
|
||||
expectMove(moveOneLeft, ["a", "b", "c", "d"], [1, 2], ["b", "c", "a", "d"]);
|
||||
|
@ -1,10 +1,10 @@
|
||||
function swap<T>(elements: T[], indexA: number, indexB: number) {
|
||||
const swap = <T>(elements: T[], indexA: number, indexB: number) => {
|
||||
const element = elements[indexA];
|
||||
elements[indexA] = elements[indexB];
|
||||
elements[indexB] = element;
|
||||
}
|
||||
};
|
||||
|
||||
export function moveOneLeft<T>(elements: T[], indicesToMove: number[]) {
|
||||
export const moveOneLeft = <T>(elements: T[], indicesToMove: number[]) => {
|
||||
indicesToMove.sort((a: number, b: number) => a - b);
|
||||
let isSorted = true;
|
||||
// We go from left to right to avoid overriding the wrong elements
|
||||
@ -19,9 +19,9 @@ export function moveOneLeft<T>(elements: T[], indicesToMove: number[]) {
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
};
|
||||
|
||||
export function moveOneRight<T>(elements: T[], indicesToMove: number[]) {
|
||||
export const moveOneRight = <T>(elements: T[], indicesToMove: number[]) => {
|
||||
const reversedIndicesToMove = indicesToMove.sort(
|
||||
(a: number, b: number) => b - a,
|
||||
);
|
||||
@ -38,7 +38,7 @@ export function moveOneRight<T>(elements: T[], indicesToMove: number[]) {
|
||||
swap(elements, index + 1, index);
|
||||
});
|
||||
return elements;
|
||||
}
|
||||
};
|
||||
|
||||
// Let's go through an example
|
||||
// | |
|
||||
@ -86,7 +86,7 @@ export function moveOneRight<T>(elements: T[], indicesToMove: number[]) {
|
||||
// [c, f, a, b, d, e, g]
|
||||
//
|
||||
// And we are done!
|
||||
export function moveAllLeft<T>(elements: T[], indicesToMove: number[]) {
|
||||
export const moveAllLeft = <T>(elements: T[], indicesToMove: number[]) => {
|
||||
indicesToMove.sort((a: number, b: number) => a - b);
|
||||
|
||||
// Copy the elements to move
|
||||
@ -117,7 +117,7 @@ export function moveAllLeft<T>(elements: T[], indicesToMove: number[]) {
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
};
|
||||
|
||||
// Let's go through an example
|
||||
// | |
|
||||
@ -164,7 +164,7 @@ export function moveAllLeft<T>(elements: T[], indicesToMove: number[]) {
|
||||
// [a, b, d, e, g, c, f]
|
||||
//
|
||||
// And we are done!
|
||||
export function moveAllRight<T>(elements: T[], indicesToMove: number[]) {
|
||||
export const moveAllRight = <T>(elements: T[], indicesToMove: number[]) => {
|
||||
const reversedIndicesToMove = indicesToMove.sort(
|
||||
(a: number, b: number) => b - a,
|
||||
);
|
||||
@ -199,4 +199,4 @@ export function moveAllRight<T>(elements: T[], indicesToMove: number[]) {
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user