feat: introduce frames (#6123)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2023-06-15 00:42:01 +08:00 committed by GitHub
parent 4d7d96eb7b
commit 81ebf82979
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 4563 additions and 480 deletions

View file

@ -1,4 +1,4 @@
import React from "react";
import React, { useState } from "react";
import { ActionManager } from "../actions/manager";
import { getNonDeletedElements } from "../element";
import { ExcalidrawElement, PointerType } from "../element/types";
@ -35,6 +35,9 @@ import {
} from "../element/textElement";
import "./Actions.scss";
import DropdownMenu from "./dropdownMenu/DropdownMenu";
import { extraToolsIcon, frameToolIcon } from "./icons";
import { KEYS } from "../keys";
export const SelectedShapeActions = ({
appState,
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
<div>
{((hasStrokeColor(appState.activeTool.type) &&
appState.activeTool.type !== "image" &&
commonSelectedType !== "image") ||
commonSelectedType !== "image" &&
commonSelectedType !== "frame") ||
targetElements.some((element) => hasStrokeColor(element.type))) &&
renderAction("changeStrokeColor")}
</div>
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
setAppState: React.Component<any, UIAppState>["setState"];
onImageAction: (data: { pointerType: PointerType | null }) => void;
appState: UIAppState;
}) => (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
}) => {
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
const device = useDevice();
return (
<>
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
const label = t(`toolBar.${value}`);
const letter =
key && capitalizeString(typeof key === "string" ? key : key[0]);
const shortcut = letter
? `${letter} ${t("helpDialog.or")} ${numericKey}`
: `${numericKey}`;
return (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
type="radio"
icon={icon}
checked={activeTool.type === value}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
penDetected: true,
penMode: true,
});
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
const nextActiveTool = updateActiveTool(appState, {
type: value,
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
<div className="App-toolbar__divider" />
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
{device.isMobile ? (
<ToolButton
className={clsx("Shape", { fillable })}
key={value}
className={clsx("Shape", { fillable: false })}
type="radio"
icon={icon}
checked={activeTool.type === value}
icon={frameToolIcon}
checked={activeTool.type === "frame"}
name="editor-current-shape"
title={`${capitalizeString(label)}${shortcut}`}
keyBindingLabel={numericKey || letter}
aria-label={capitalizeString(label)}
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
title={`${capitalizeString(
t("toolBar.frame"),
)} ${KEYS.F.toLocaleUpperCase()}`}
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
aria-label={capitalizeString(t("toolBar.frame"))}
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
data-testid={`toolbar-frame`}
onPointerDown={({ pointerType }) => {
if (!appState.penDetected && pointerType === "pen") {
setAppState({
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
}
}}
onChange={({ pointerType }) => {
if (appState.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
trackEvent("toolbar", "frame", "ui");
const nextActiveTool = updateActiveTool(appState, {
type: value,
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
setCursorForShape(canvas, {
...appState,
activeTool: nextActiveTool,
});
if (value === "image") {
onImageAction({ pointerType });
}
}}
/>
);
})}
</>
);
) : (
<DropdownMenu open={isExtraToolsMenuOpen}>
<DropdownMenu.Trigger
className="App-toolbar__extra-tools-trigger"
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
title={t("toolBar.extraTools")}
>
{extraToolsIcon}
</DropdownMenu.Trigger>
<DropdownMenu.Content
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
onSelect={() => setIsExtraToolsMenuOpen(false)}
className="App-toolbar__extra-tools-dropdown"
>
<DropdownMenu.Item
onSelect={() => {
const nextActiveTool = updateActiveTool(appState, {
type: "frame",
});
setAppState({
activeTool: nextActiveTool,
multiElement: null,
selectedElementIds: {},
});
}}
icon={frameToolIcon}
shortcut={KEYS.F.toLocaleUpperCase()}
data-testid="toolbar-frame"
>
{t("toolBar.frame")}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu>
)}
</>
);
};
export const ZoomActions = ({
renderAction,

File diff suppressed because it is too large Load diff

View file

@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
label={t("toolBar.eraser")}
shortcuts={[KEYS.E, KEYS["0"]]}
/>
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
<Shortcut
label={t("labels.eyeDropper")}
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}

View file

@ -84,7 +84,10 @@ const ImageExportModal = ({
const [renderError, setRenderError] = useState<Error | null>(null);
const exportedElements = exportSelected
? getSelectedElements(elements, appState, true)
? getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
})
: elements;
useEffect(() => {

View file

@ -204,12 +204,7 @@ const LayerUI = ({
return (
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col
gap={6}
className={clsx("App-menu_top__left", {
"disable-pointerEvents": appState.zenModeEnabled,
})}
>
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
{renderCanvasActions()}
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</Stack.Col>
@ -254,7 +249,7 @@ const LayerUI = ({
title={t("toolBar.lock")}
/>
<div className="App-toolbar__divider"></div>
<div className="App-toolbar__divider" />
<HandButton
checked={isHandToolActive(appState)}

View file

@ -148,7 +148,11 @@ const usePendingElementsMemo = (
appState: UIAppState,
elements: readonly NonDeletedExcalidrawElement[],
) => {
const create = () => getSelectedElements(elements, appState, true);
const create = () =>
getSelectedElements(elements, appState, {
includeBoundTextElement: true,
includeElementsInFrames: true,
});
const val = useRef(create());
const prevAppState = useRef<UIAppState>(appState);
const prevElements = useRef(elements);

View file

@ -1,6 +1,6 @@
import "./ToolIcon.scss";
import React, { useEffect, useRef, useState } from "react";
import React, { CSSProperties, useEffect, useRef, useState } from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
import { AbortError } from "../errors";
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
visible?: boolean;
selected?: boolean;
className?: string;
style?: CSSProperties;
isLoading?: boolean;
};
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
"ToolIcon--plain": props.type === "icon",
},
)}
style={props.style}
data-testid={props["data-testid"]}
hidden={props.hidden}
title={props.title}

View file

@ -15,7 +15,24 @@
height: 1.5rem;
align-self: center;
background-color: var(--default-border-color);
margin: 0 0.5rem;
margin: 0 0.25rem;
}
}
.App-toolbar__extra-tools-trigger {
box-shadow: none;
border: 0;
&:active {
background-color: var(--button-hover-bg);
box-shadow: 0 0 0 1px
var(--button-active-border, var(--color-primary-darkest)) inset;
}
}
.App-toolbar__extra-tools-dropdown {
margin-top: 0.375rem;
right: 0;
min-width: 11.875rem;
}
}

View file

@ -1,23 +1,23 @@
import clsx from "clsx";
import { useUIAppState } from "../../context/ui-appState";
import { useDevice } from "../App";
const MenuTrigger = ({
className = "",
children,
onToggle,
title,
...rest
}: {
className?: string;
children: React.ReactNode;
onToggle: () => void;
}) => {
const appState = useUIAppState();
title?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
const device = useDevice();
const classNames = clsx(
`dropdown-menu-button ${className}`,
"zen-mode-transition",
{
"transition-left": appState.zenModeEnabled,
"dropdown-menu-button--mobile": device.isMobile,
},
).trim();
@ -28,6 +28,8 @@ const MenuTrigger = ({
onClick={onToggle}
type="button"
data-testid="dropdown-menu-button"
title={title}
{...rest}
>
{children}
</button>

View file

@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
</div>,
);
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
const excalContainers = container.querySelectorAll<HTMLDivElement>(
".excalidraw-container",
);
expect(
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
).toBe(1);
expect(
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
).toBe(1);
});
});

View file

@ -1616,3 +1616,24 @@ export const eyeDropperIcon = createIcon(
</g>,
tablerIconProps,
);
export const extraToolsIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3l-4 7h8z"></path>
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
</g>,
tablerIconProps,
);
export const frameToolIcon = createIcon(
<g strokeWidth={1.5}>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M4 7l16 0"></path>
<path d="M4 17l16 0"></path>
<path d="M7 4l0 16"></path>
<path d="M17 4l0 16"></path>
</g>,
tablerIconProps,
);

View file

@ -42,6 +42,7 @@ const MainMenu = Object.assign(
openMenu: appState.openMenu === "canvas" ? null : "canvas",
});
}}
data-testid="main-menu-trigger"
>
{HamburgerMenuIcon}
</DropdownMenu.Trigger>