From c742225f43680898ec3a1e1fac8efb71b09ddd3a Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Fri, 11 Dec 2020 17:17:28 +0000 Subject: [PATCH] More Arrowheads: dot, bar (#2486) Co-authored-by: Jed Fox Co-authored-by: Lipis --- src/actions/actionProperties.tsx | 190 ++++++++++++++++++++----------- src/components/IconPicker.scss | 137 ++++++++++++++++++++++ src/components/IconPicker.tsx | 188 ++++++++++++++++++++++++++++++ src/components/MobileMenu.tsx | 2 +- src/components/icons.tsx | 62 +++++++++- src/css/styles.scss | 10 ++ src/element/bounds.ts | 16 ++- src/element/types.ts | 2 +- src/locales/en.json | 2 + src/renderer/renderElement.ts | 11 ++ 10 files changed, 550 insertions(+), 70 deletions(-) create mode 100644 src/components/IconPicker.scss create mode 100644 src/components/IconPicker.tsx diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 5d025546..2589fc77 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { getLanguage } from "../i18n"; import { ExcalidrawElement, ExcalidrawTextElement, @@ -16,7 +17,7 @@ import { } from "../scene"; import { ButtonSelect } from "../components/ButtonSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect"; -import { ButtonIconCycle } from "../components/ButtonIconCycle"; +import { IconPicker } from "../components/IconPicker"; import { isTextElement, redrawTextBoundingBox, @@ -43,7 +44,10 @@ import { SloppinessArchitectIcon, SloppinessArtistIcon, SloppinessCartoonistIcon, - ArrowArrowheadIcon, + ArrowheadArrowIcon, + ArrowheadBarIcon, + ArrowheadDotIcon, + ArrowheadNoneIcon, } from "../components/icons"; import { EVENT_CHANGE, trackEvent } from "../analytics"; import colors from "../colors"; @@ -671,66 +675,124 @@ export const actionChangeArrowhead = register({ commitToHistory: true, }; }, - PanelComponent: ({ elements, appState, updateData }) => ( -
- {t("labels.arrowheads")} -
- , - }, - { - value: "arrow", - text: t("labels.arrowhead_arrow"), - icon: ( - - ), - }, - ]} - value={getFormValue( - elements, - appState, - (element) => - isLinearElement(element) && canHaveArrowheads(element.type) - ? element.startArrowhead - : appState.currentItemArrowheads.start, - appState.currentItemArrowheads.start, - )} - onChange={(value) => updateData({ position: "start", type: value })} - /> - , - }, - { - value: "arrow", - text: t("labels.arrowhead_arrow"), - icon: , - }, - ]} - value={getFormValue( - elements, - appState, - (element) => - isLinearElement(element) && canHaveArrowheads(element.type) - ? element.endArrowhead - : appState.currentItemArrowheads.end, - appState.currentItemArrowheads.end, - )} - onChange={(value) => updateData({ position: "end", type: value })} - /> -
-
- ), + PanelComponent: ({ elements, appState, updateData }) => { + const isRTL = getLanguage().rtl; + + return ( +
+ {t("labels.arrowheads")} +
+ , + keyBinding: "q", + }, + { + value: "arrow", + text: t("labels.arrowhead_arrow"), + icon: ( + + ), + keyBinding: "w", + }, + { + value: "bar", + text: t("labels.arrowhead_bar"), + icon: ( + + ), + keyBinding: "e", + }, + { + value: "dot", + text: t("labels.arrowhead_dot"), + icon: ( + + ), + keyBinding: "r", + }, + ]} + value={getFormValue( + elements, + appState, + (element) => + isLinearElement(element) && canHaveArrowheads(element.type) + ? element.startArrowhead + : appState.currentItemArrowheads.start, + appState.currentItemArrowheads.start, + )} + onChange={(value) => updateData({ position: "start", type: value })} + /> + , + }, + { + value: "arrow", + text: t("labels.arrowhead_arrow"), + keyBinding: "w", + icon: ( + + ), + }, + { + value: "bar", + text: t("labels.arrowhead_bar"), + keyBinding: "e", + icon: ( + + ), + }, + { + value: "dot", + text: t("labels.arrowhead_dot"), + keyBinding: "r", + icon: ( + + ), + }, + ]} + value={getFormValue( + elements, + appState, + (element) => + isLinearElement(element) && canHaveArrowheads(element.type) + ? element.endArrowhead + : appState.currentItemArrowheads.end, + appState.currentItemArrowheads.end, + )} + onChange={(value) => updateData({ position: "end", type: value })} + /> +
+
+ ); + }, }); diff --git a/src/components/IconPicker.scss b/src/components/IconPicker.scss new file mode 100644 index 00000000..49be1ca0 --- /dev/null +++ b/src/components/IconPicker.scss @@ -0,0 +1,137 @@ +@import "open-color/open-color.scss"; + +.excalidraw { + .picker-container { + display: inline-block; + box-sizing: border-box; + margin-right: 0.25rem; + } + + .picker { + background: var(--popup-background-color); + border: 0px solid transparentize($oc-white, 0.75); + box-shadow: transparentize($oc-black, 0.75) 0px 1px 4px; + border-radius: 4px; + position: absolute; + } + + .picker-container button, + .picker button { + position: relative; + display: flex; + align-items: center; + justify-content: center; + + &:focus { + outline: transparent; + background-color: var(--button-gray-2); + & svg { + opacity: 1; + } + } + + &:hover { + background-color: var(--button-gray-2); + } + + &:active { + background-color: var(--button-gray-3); + } + + &:disabled { + cursor: not-allowed; + } + + svg { + margin: 0; + width: 36px; + height: 18px; + opacity: 0.6; + pointer-events: none; + } + } + + .picker button { + padding: 0.25rem 0.28rem 0.35rem 0.25rem; + } + + .picker-triangle { + width: 0px; + height: 0px; + position: relative; + top: -10px; + :root[dir="ltr"] & { + left: 12px; + } + + :root[dir="rtl"] & { + right: 12px; + } + z-index: 10; + + &:before { + content: ""; + position: absolute; + border-style: solid; + border-width: 0px 9px 10px; + border-color: transparent transparent transparentize($oc-black, 0.9); + top: -1px; + } + + &:after { + content: ""; + position: absolute; + border-style: solid; + border-width: 0px 9px 10px; + border-color: transparent transparent var(--popup-background-color); + } + } + + .picker-content { + padding: 0.5rem; + display: grid; + grid-auto-flow: column; + grid-gap: 0.5rem; + border-radius: 4px; + } + + .picker-keybinding { + position: absolute; + bottom: 2px; + + :root[dir="ltr"] & { + right: 2px; + } + + :root[dir="rtl"] & { + left: 2px; + } + + font-size: 0.7em; + } + + .picker-type-canvasBackground .picker-keybinding { + color: #aaa; + } + + .picker-type-elementBackground .picker-keybinding { + color: #fff; + } + + .picker-swatch[aria-label="transparent"] .picker-keybinding { + color: #aaa; + } + + .picker-type-elementStroke .picker-keybinding { + color: #d4d4d4; + } + + &.Appearance_dark { + .picker-type-elementBackground .picker-keybinding { + color: #000; + } + .picker-swatch[aria-label="transparent"] .picker-keybinding { + color: #000; + } + } +} diff --git a/src/components/IconPicker.tsx b/src/components/IconPicker.tsx new file mode 100644 index 00000000..77e9d5f8 --- /dev/null +++ b/src/components/IconPicker.tsx @@ -0,0 +1,188 @@ +import React from "react"; +import { Popover } from "./Popover"; + +import "./IconPicker.scss"; +import { isArrowKey, KEYS } from "../keys"; +import { getLanguage } from "../i18n"; + +function Picker({ + options, + value, + label, + onChange, + onClose, +}: { + label: string; + value: T; + options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[]; + onChange: (value: T) => void; + onClose: () => void; +}) { + const rFirstItem = React.useRef(); + const rActiveItem = React.useRef(); + const rGallery = React.useRef(null); + + React.useEffect(() => { + // After the component is first mounted focus on first input + if (rActiveItem.current) { + rActiveItem.current.focus(); + } else if (rGallery.current) { + rGallery.current.focus(); + } + }, []); + + const handleKeyDown = (event: React.KeyboardEvent) => { + const pressedOption = options.find( + (option) => option.keyBinding === event.key.toLowerCase(), + )!; + + if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { + // Keybinding navigation + const index = options.indexOf(pressedOption); + (rGallery!.current!.children![index] as any).focus(); + event.preventDefault(); + } else if (event.key === KEYS.TAB) { + // Tab navigation cycle through options. If the user tabs + // away from the picker, close the picker. We need to use + // a timeout here to let the stack clear before checking. + setTimeout(() => { + const active = rActiveItem.current; + const docActive = document.activeElement; + if (active !== docActive) { + onClose(); + } + }, 0); + } else if (isArrowKey(event.key)) { + // Arrow navigation + const { activeElement } = document; + const isRTL = getLanguage().rtl; + const index = Array.prototype.indexOf.call( + rGallery!.current!.children, + activeElement, + ); + if (index !== -1) { + const length = options.length; + let nextIndex = index; + + switch (event.key) { + // Select the next option + case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT: + case KEYS.ARROW_DOWN: { + nextIndex = (index + 1) % length; + break; + } + // Select the previous option + case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT: + case KEYS.ARROW_UP: { + nextIndex = (length + index - 1) % length; + break; + } + } + + (rGallery.current!.children![nextIndex] as any).focus(); + } + event.preventDefault(); + } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { + // Close on escape or enter + event.preventDefault(); + onClose(); + } + event.nativeEvent.stopImmediatePropagation(); + }; + + return ( +
+
+ {options.map((option, i) => ( + + ))} +
+
+ ); +} + +export function IconPicker({ + value, + label, + options, + onChange, + group = "", +}: { + label: string; + value: T; + options: { value: T; text: string; icon: JSX.Element; keyBinding: string }[]; + onChange: (value: T) => void; + group?: string; +}) { + const [isActive, setActive] = React.useState(false); + const rPickerButton = React.useRef(null); + const isRTL = getLanguage().rtl; + + return ( +