feat: add undo/redo buttons & tweak footer (#3832)

Co-authored-by: Aakansha Doshi <aakansha1216@gmail.com>
This commit is contained in:
David Luzar 2021-07-15 18:48:03 +02:00 committed by GitHub
parent 685abac81a
commit 99623334d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 152 additions and 125 deletions

View File

@ -1,7 +1,7 @@
import React from "react";
import { getDefaultAppState } from "../appState";
import { ColorPicker } from "../components/ColorPicker";
import { resetZoom, trash, zoomIn, zoomOut } from "../components/icons";
import { trash, zoomIn, zoomOut } from "../components/icons";
import { ToolButton } from "../components/ToolButton";
import { DarkModeToggle } from "../components/DarkModeToggle";
import { ZOOM_STEP } from "../constants";
@ -17,6 +17,7 @@ import { getNewZoom } from "../scene/zoom";
import { AppState, NormalizedZoomValue } from "../types";
import { getShortcutKey } from "../utils";
import { register } from "./register";
import { Tooltip } from "../components/Tooltip";
export const actionChangeViewBackgroundColor = register({
name: "changeViewBackgroundColor",
@ -108,6 +109,7 @@ export const actionZoomIn = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@ -142,6 +144,7 @@ export const actionZoomOut = register({
onClick={() => {
updateData(null);
}}
size="small"
/>
),
keyTest: (event) =>
@ -168,16 +171,21 @@ export const actionResetZoom = register({
commitToHistory: false,
};
},
PanelComponent: ({ updateData }) => (
<ToolButton
type="button"
icon={resetZoom}
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
/>
PanelComponent: ({ updateData, appState }) => (
<Tooltip label={t("buttons.resetZoom")}>
<ToolButton
type="button"
className="reset-zoom-button"
title={t("buttons.resetZoom")}
aria-label={t("buttons.resetZoom")}
onClick={() => {
updateData(null);
}}
size="small"
>
{(appState.zoom.value * 100).toFixed(0)}%
</ToolButton>
</Tooltip>
),
keyTest: (event) =>
(event.code === CODES.ZERO || event.code === CODES.NUM_ZERO) &&

View File

@ -70,7 +70,7 @@ export const actionChangeExportScale = register({
return (
<ToolButton
key={s}
size="s"
size="small"
type="radio"
icon={`${s}x`}
name="export-canvas-scale"
@ -120,7 +120,7 @@ export const actionChangeExportEmbedScene = register({
>
{t("labels.exportEmbedScene")}
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
<div className="Tooltip-icon">{questionCircle}</div>
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
</Tooltip>
</CheckboxItem>
),

View File

@ -69,12 +69,13 @@ export const createUndoAction: ActionCreator = (history) => ({
event[KEYS.CTRL_OR_CMD] &&
event.key.toLowerCase() === KEYS.Z &&
!event.shiftKey,
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={undo}
aria-label={t("buttons.undo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,
@ -89,12 +90,13 @@ export const createRedoAction: ActionCreator = (history) => ({
event.shiftKey &&
event.key.toLowerCase() === KEYS.Z) ||
(isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y),
PanelComponent: ({ updateData }) => (
PanelComponent: ({ updateData, data }) => (
<ToolButton
type="button"
icon={redo}
aria-label={t("buttons.redo")}
onClick={updateData}
size={data?.size || "medium"}
/>
),
commitToHistory: () => false,

View File

@ -30,8 +30,8 @@ export const actionGoToCollaborator = register({
commitToHistory: false,
};
},
PanelComponent: ({ appState, updateData, id }) => {
const clientId = id;
PanelComponent: ({ appState, updateData, data }) => {
const clientId: string | undefined = data?.id;
if (!clientId) {
return null;
}

View File

@ -5,6 +5,7 @@ import {
UpdaterFn,
ActionName,
ActionResult,
PanelComponentProps,
} from "./types";
import { ExcalidrawElement } from "../element/types";
import { AppProps, AppState } from "../types";
@ -107,11 +108,10 @@ export class ActionManager implements ActionsManagerInterface {
);
}
// Id is an attribute that we can use to pass in data like keys.
// This is needed for dynamically generated action components
// like the user list. We can use this key to extract more
// data from app state. This is an alternative to generic prop hell!
renderAction = (name: ActionName, id?: string) => {
/**
* @param data additional data sent to the PanelComponent
*/
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
const canvasActions = this.app.props.UIOptions.canvasActions;
if (
@ -139,8 +139,8 @@ export class ActionManager implements ActionsManagerInterface {
elements={this.getElementsIncludingDeleted()}
appState={this.getAppState()}
updateData={updateData}
id={id}
appProps={this.app.props}
data={data}
/>
);
}

View File

@ -2,6 +2,7 @@ import React from "react";
import { ExcalidrawElement } from "../element/types";
import { AppState, ExcalidrawProps } from "../types";
import Library from "../data/library";
import { ToolButtonSize } from "../components/ToolButton";
/** if false, the action should be prevented */
export type ActionResult =
@ -102,15 +103,17 @@ export type ActionName =
| "exportWithDarkMode"
| "toggleTheme";
export type PanelComponentProps = {
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
data?: Partial<{ id: string; size: ToolButtonSize }>;
};
export interface Action {
name: ActionName;
PanelComponent?: React.FC<{
elements: readonly ExcalidrawElement[];
appState: AppState;
updateData: (formData?: any) => void;
appProps: ExcalidrawProps;
id?: string;
}>;
PanelComponent?: React.FC<PanelComponentProps>;
perform: ActionFn;
keyPriority?: number;
keyTest?: (

View File

@ -207,9 +207,6 @@ export const ZoomActions = ({
{renderAction("zoomIn")}
{renderAction("zoomOut")}
{renderAction("resetZoom")}
<div style={{ marginInlineStart: 4 }}>
{(zoom.value * 100).toFixed(0)}%
</div>
</Stack.Row>
</Stack.Col>
);

View File

@ -81,7 +81,7 @@
align-items: center;
}
.Tooltip-icon {
.excalidraw-tooltip-icon {
width: 1em;
height: 1em;
}

View File

@ -73,10 +73,10 @@
}
:root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(-92px, 0);
transform: translate(-76px, 0);
}
:root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left {
transform: translate(92px, 0);
transform: translate(76px, 0);
}
&.layer-ui__wrapper__footer-left--transition-bottom {
@ -120,5 +120,15 @@
.disable-zen-mode--visible {
pointer-events: all;
}
.layer-ui__wrapper__footer-left {
margin-bottom: 0.2em;
}
.layer-ui__wrapper__footer-right {
margin-top: auto;
margin-bottom: auto;
margin-inline-end: 1em;
}
}
}

View File

@ -632,7 +632,9 @@ const LayerUI = ({
label={client.username || "Unknown user"}
key={clientId}
>
{actionManager.renderAction("goToCollaborator", clientId)}
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</Tooltip>
))}
</UserList>
@ -665,6 +667,16 @@ const LayerUI = ({
zoom={appState.zoom}
/>
</Island>
{!viewModeEnabled && (
<div
className={clsx("undo-redo-buttons zen-mode-transition", {
"layer-ui__wrapper__footer-left--transition-bottom": zenModeEnabled,
})}
>
{actionManager.renderAction("undo", { size: "small" })}
{actionManager.renderAction("redo", { size: "small" })}
</div>
)}
</Section>
</Stack.Col>
</div>

View File

@ -21,7 +21,7 @@ export const LibraryButton: React.FC<{
<label
className={clsx(
"ToolIcon ToolIcon_type_floating ToolIcon__library zen-mode-visibility",
`ToolIcon_size_m`,
`ToolIcon_size_medium`,
{
"zen-mode-visibility--hidden": appState.zenModeEnabled,
},

View File

@ -2,8 +2,7 @@ import "./ToolIcon.scss";
import React from "react";
import clsx from "clsx";
type LockIconSize = "s" | "m";
import { ToolButtonSize } from "./ToolButton";
type LockIconProps = {
title?: string;
@ -13,7 +12,7 @@ type LockIconProps = {
zenModeEnabled?: boolean;
};
const DEFAULT_SIZE: LockIconSize = "m";
const DEFAULT_SIZE: ToolButtonSize = "medium";
const ICONS = {
CHECKED: (

View File

@ -168,10 +168,9 @@ export const MobileMenu = ({
)
.map(([clientId, client]) => (
<React.Fragment key={clientId}>
{actionManager.renderAction(
"goToCollaborator",
clientId,
)}
{actionManager.renderAction("goToCollaborator", {
id: clientId,
})}
</React.Fragment>
))}
</UserList>

View File

@ -4,7 +4,7 @@ import React from "react";
import clsx from "clsx";
import { useExcalidrawContainer } from "./App";
type ToolIconSize = "s" | "m";
export type ToolButtonSize = "small" | "medium";
type ToolButtonBaseProps = {
icon?: React.ReactNode;
@ -15,7 +15,7 @@ type ToolButtonBaseProps = {
title?: string;
name?: string;
id?: string;
size?: ToolIconSize;
size?: ToolButtonSize;
keyBindingLabel?: string;
showAriaLabel?: boolean;
hidden?: boolean;
@ -41,13 +41,11 @@ type ToolButtonProps =
onChange?(): void;
});
const DEFAULT_SIZE: ToolIconSize = "m";
export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
const { id: excalId } = useExcalidrawContainer();
const innerRef = React.useRef(null);
React.useImperativeHandle(ref, () => innerRef.current);
const sizeCn = `ToolIcon_size_${props.size || DEFAULT_SIZE}`;
const sizeCn = `ToolIcon_size_${props.size}`;
if (props.type === "button" || props.type === "icon") {
return (
@ -118,4 +116,5 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
ToolButton.defaultProps = {
visible: true,
className: "",
size: "medium",
};

View File

@ -60,9 +60,9 @@
text-overflow: ellipsis;
}
.ToolIcon_size_s .ToolIcon__icon {
width: 1.4rem;
height: 1.4rem;
.ToolIcon_size_small .ToolIcon__icon {
width: 2rem;
height: 2rem;
font-size: 0.8em;
}

View File

@ -1,4 +1,6 @@
@import "../css/variables.module";
// container in body where the actual tooltip is appended to
.excalidraw-tooltip {
position: absolute;
z-index: 1000;
@ -24,16 +26,20 @@
}
}
.excalidraw {
.Tooltip-icon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
display: flex;
// wraps the element we want to apply the tooltip to
.excalidraw-tooltip-wrapper {
display: flex;
height: 100%;
}
@include isMobile {
display: none;
}
.excalidraw-tooltip-icon {
width: 0.9em;
height: 0.9em;
margin-left: 5px;
margin-top: 1px;
display: flex;
@include isMobile {
display: none;
}
}

View File

@ -74,6 +74,7 @@ export const Tooltip = ({ children, label, long = false }: TooltipProps) => {
return (
<div
className="excalidraw-tooltip-wrapper"
onPointerEnter={(event) =>
updateTooltip(
event.currentTarget as HTMLDivElement,

View File

@ -414,22 +414,6 @@
&:active {
background-color: var(--button-gray-2);
}
&.dropdown-select--floating {
margin: 0.5em;
}
}
.dropdown-select__language.dropdown-select--floating {
bottom: 10px;
:root[dir="ltr"] & {
right: 44px;
}
:root[dir="rtl"] & {
left: 44px;
}
}
.zIndexButton {
@ -456,26 +440,32 @@
}
.help-icon {
display: flex;
cursor: pointer;
fill: $oc-gray-6;
width: 1.5rem;
padding: 0;
margin: 0;
margin-top: 5px;
background: none;
color: var(--icon-fill-color);
&:hover {
background: none;
}
}
:root[dir="ltr"] & {
margin-right: 14px;
}
.reset-zoom-button {
padding: 0.2em;
background: transparent;
color: var(--text-primary-color);
font-family: var(--ui-font);
}
:root[dir="rtl"] & {
margin-left: 14px;
}
.undo-redo-buttons {
display: flex;
gap: 0.4em;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: 0.6em;
}
@include isMobile {

View File

@ -1,23 +1,18 @@
import React from "react";
import clsx from "clsx";
import * as i18n from "../../i18n";
export const LanguageList = ({
onChange,
languages = i18n.languages,
currentLangCode = i18n.getLanguage().code,
floating,
}: {
languages?: { code: string; label: string }[];
onChange: (langCode: i18n.Language["code"]) => void;
currentLangCode?: i18n.Language["code"];
floating?: boolean;
}) => (
<React.Fragment>
<select
className={clsx("dropdown-select dropdown-select__language", {
"dropdown-select--floating": floating,
})}
className="dropdown-select dropdown-select__language"
onChange={({ target }) => onChange(target.value)}
value={currentLangCode}
aria-label={i18n.t("buttons.selectLanguage")}

View File

@ -2,12 +2,18 @@
.layer-ui__wrapper__footer-center {
display: flex;
justify-content: space-between;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: auto;
}
.encrypted-icon {
border-radius: var(--space-factor);
color: var(--icon-green-fill-color);
margin-top: 13px;
margin-top: auto;
margin-bottom: auto;
margin-inline-start: auto;
margin-inline-end: 0.6em;
svg {
width: 1.2rem;

View File

@ -348,11 +348,8 @@ const ExcalidrawWrapper = () => {
const renderLanguageList = () => (
<LanguageList
onChange={(langCode) => {
setLangCode(langCode);
}}
onChange={(langCode) => setLangCode(langCode)}
languages={languages}
floating={!isMobile}
currentLangCode={langCode}
/>
);

View File

@ -17,7 +17,7 @@ beforeEach(async () => {
mouse.reset();
await setLanguage(defaultLang);
render(<App />);
await render(<App />);
});
const createAndSelectOneRectangle = (angle: number = 0) => {

View File

@ -25,7 +25,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
>
<button
aria-label="Reset the canvas"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
@ -53,7 +53,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
/>
<button
aria-label="Load"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="load-button"
title="Load"
type="button"
@ -78,7 +78,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
</button>
<button
aria-label="Export"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="json-export-button"
title="Export"
type="button"
@ -103,7 +103,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
</button>
<button
aria-label="Save as image"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="image-export-button"
title="Save as image"
type="button"
@ -170,7 +170,7 @@ exports[`<Excalidraw/> Test UIOptions prop Test canvasActions should not hide an
>
<button
aria-label="Dark mode"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon ToolIcon--plain"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon ToolIcon--plain"
data-testid="toggle-dark-mode"
title="Dark mode"
type="button"
@ -224,7 +224,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
>
<button
aria-label="Reset the canvas"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="clear-canvas-button"
title="Reset the canvas"
type="button"
@ -252,7 +252,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
/>
<button
aria-label="Load"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="load-button"
title="Load"
type="button"
@ -277,7 +277,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
</button>
<button
aria-label="Export"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="json-export-button"
title="Export"
type="button"
@ -302,7 +302,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
</button>
<button
aria-label="Save as image"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon"
data-testid="image-export-button"
title="Save as image"
type="button"
@ -369,7 +369,7 @@ exports[`<Excalidraw/> Test UIOptions prop should not hide any UI element when t
>
<button
aria-label="Dark mode"
class="ToolIcon_type_button ToolIcon_size_m ToolIcon_type_button--show ToolIcon ToolIcon--plain"
class="ToolIcon_type_button ToolIcon_size_medium ToolIcon_type_button--show ToolIcon ToolIcon--plain"
data-testid="toggle-dark-mode"
title="Dark mode"
type="button"

View File

@ -45,14 +45,17 @@ describe("resize rectangle ellipses and diamond elements", () => {
${"se"} | ${[-30, -10]} | ${[70, 90]} | ${[elemData.x, elemData.y]}
${"nw"} | ${[-300, -200]} | ${[400, 300]} | ${[-300, -200]}
${"sw"} | ${[40, -20]} | ${[60, 80]} | ${[40, 0]}
`("resizes with handle $handle", ({ handle, move, dimensions, topLeft }) => {
render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move);
const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
expect([element.x, element.y]).toEqual(topLeft);
});
`(
"resizes with handle $handle",
async ({ handle, move, dimensions, topLeft }) => {
await render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move);
const element = h.elements[0];
expect([element.width, element.height]).toEqual(dimensions);
expect([element.x, element.y]).toEqual(topLeft);
},
);
it.each`
handle | move | dimensions | topLeft
@ -61,8 +64,8 @@ describe("resize rectangle ellipses and diamond elements", () => {
${"sw"} | ${[40, -20]} | ${[80, 80]} | ${[20, 0]}
`(
"resizes with fixed side ratios on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
async ({ handle, move, dimensions, topLeft }) => {
await render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { shift: true });
const element = h.elements[0];
@ -79,8 +82,8 @@ describe("resize rectangle ellipses and diamond elements", () => {
${"n"} | ${[_, 150]} | ${[50, 50]} | ${[25, 100]}
`(
"Flips while resizing and keeping side ratios on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
async ({ handle, move, dimensions, topLeft }) => {
await render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { shift: true });
const element = h.elements[0];
@ -95,8 +98,8 @@ describe("resize rectangle ellipses and diamond elements", () => {
${"s"} | ${[_, -20]} | ${[100, 60]} | ${[0, 20]}
`(
"Resizes from center on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
async ({ handle, move, dimensions, topLeft }) => {
await render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { alt: true });
const element = h.elements[0];
@ -111,8 +114,8 @@ describe("resize rectangle ellipses and diamond elements", () => {
${"e"} | ${[-130, _]} | ${[160, 160]} | ${[-30, -30]}
`(
"Resizes from center, flips and keeps side rations on handle $handle",
({ handle, move, dimensions, topLeft }) => {
render(<App />);
async ({ handle, move, dimensions, topLeft }) => {
await render(<App />);
const rectangle = UI.createElement("rectangle", elemData);
resize(rectangle, handle, move, { alt: true, shift: true });
const element = h.elements[0];