From d33e42e3a1a955fa95c61310128dfe4ca2e5fdae Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 6 Jan 2025 04:50:24 +0800 Subject: [PATCH] feat: add crowfoot to arrowheads (#8942) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * crowfoot many * crowfoot one * one or many * add icons for crowfoot * add crowfoot icons * adjust arrowhead selection popover * make options collapsible * swap triangle and bar * switch to radix popover * put triangle outline in the first row * align shadow with new design spec * remove unused flag * swap order * tweak labels * handle shift+tab --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> Co-authored-by: Jakub Królak <108676707+j-krolak@users.noreply.github.com> --- .../excalidraw/actions/actionProperties.tsx | 69 +++--- .../excalidraw/components/IconPicker.scss | 56 +---- packages/excalidraw/components/IconPicker.tsx | 229 ++++++++++-------- .../components/Stats/Collapsible.tsx | 3 + packages/excalidraw/components/icons.tsx | 48 ++++ packages/excalidraw/element/bounds.ts | 19 ++ packages/excalidraw/element/types.ts | 5 +- packages/excalidraw/locales/en.json | 4 + packages/excalidraw/scene/Shape.ts | 23 ++ 9 files changed, 283 insertions(+), 173 deletions(-) diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 7ff078673..7e870eec1 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -53,6 +53,9 @@ import { sharpArrowIcon, roundArrowIcon, elbowArrowIcon, + ArrowheadCrowfootIcon, + ArrowheadCrowfootOneIcon, + ArrowheadCrowfootOneOrManyIcon, } from "../components/icons"; import { ARROW_TYPE, @@ -1406,58 +1409,64 @@ const getArrowheadOptions = (flip: boolean) => { icon: , }, { - value: "bar", - text: t("labels.arrowhead_bar"), + value: "triangle", + text: t("labels.arrowhead_triangle"), + icon: , keyBinding: "e", - icon: , }, { - value: "dot", - text: t("labels.arrowhead_circle"), - keyBinding: null, - icon: , - showInPicker: false, + value: "triangle_outline", + text: t("labels.arrowhead_triangle_outline"), + icon: , + keyBinding: "r", }, { value: "circle", text: t("labels.arrowhead_circle"), - keyBinding: "r", + keyBinding: "a", icon: , - showInPicker: false, }, { value: "circle_outline", text: t("labels.arrowhead_circle_outline"), - keyBinding: null, + keyBinding: "s", icon: , - showInPicker: false, - }, - { - value: "triangle", - text: t("labels.arrowhead_triangle"), - icon: , - keyBinding: "t", - }, - { - value: "triangle_outline", - text: t("labels.arrowhead_triangle_outline"), - icon: , - keyBinding: null, - showInPicker: false, }, { value: "diamond", text: t("labels.arrowhead_diamond"), icon: , - keyBinding: null, - showInPicker: false, + keyBinding: "d", }, { value: "diamond_outline", text: t("labels.arrowhead_diamond_outline"), icon: , - keyBinding: null, - showInPicker: false, + keyBinding: "f", + }, + { + value: "bar", + text: t("labels.arrowhead_bar"), + keyBinding: "z", + icon: , + }, + { + value: "crowfoot_one", + text: t("labels.arrowhead_crowfoot_one"), + icon: , + keyBinding: "c", + }, + { + value: "crowfoot_many", + text: t("labels.arrowhead_crowfoot_many"), + icon: , + keyBinding: "x", + }, + { + value: "crowfoot_one_or_many", + text: t("labels.arrowhead_crowfoot_one_or_many"), + icon: , + keyBinding: "v", }, ] as const; }; @@ -1521,6 +1530,7 @@ export const actionChangeArrowhead = register({ appState.currentItemStartArrowhead, )} onChange={(value) => updateData({ position: "start", type: value })} + numberOfOptionsToAlwaysShow={4} /> updateData({ position: "end", type: value })} + numberOfOptionsToAlwaysShow={4} /> diff --git a/packages/excalidraw/components/IconPicker.scss b/packages/excalidraw/components/IconPicker.scss index 2cf87c474..b9b47b39e 100644 --- a/packages/excalidraw/components/IconPicker.scss +++ b/packages/excalidraw/components/IconPicker.scss @@ -1,19 +1,16 @@ @import "../css/variables.module.scss"; .excalidraw { - .picker-container { - display: inline-block; - box-sizing: border-box; - margin-right: 0.25rem; - } - .picker { + padding: 0.5rem; background: var(--popup-bg-color); border: 0 solid transparentize($oc-white, 0.75); - // ˇˇ yeah, i dunno, open to suggestions here :D - box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px; + box-shadow: var(--shadow-island); border-radius: 4px; position: absolute; + :root[dir="rtl"] & { + padding: 0.4rem; + } } .picker-container button, @@ -55,47 +52,16 @@ padding: 0.25rem 0.28rem 0.35rem 0.25rem; } - .picker-triangle { - width: 0; - height: 0; - 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: 0 9px 10px; - border-color: transparent transparent transparentize($oc-black, 0.9); - top: -1px; - } - - &:after { - content: ""; - position: absolute; - border-style: solid; - border-width: 0 9px 10px; - border-color: transparent transparent var(--popup-bg-color); - } - } - .picker-content { - padding: 0.5rem; display: grid; - grid-template-columns: repeat(3, auto); + grid-template-columns: repeat(4, auto); grid-gap: 0.5rem; border-radius: 4px; - :root[dir="rtl"] & { - padding: 0.4rem; - } + } + + .picker-collapsible { + font-size: 0.75rem; + padding: 0.5rem 0; } .picker-keybinding { diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 4d6e95af5..f72433106 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -1,10 +1,23 @@ -import React from "react"; -import { Popover } from "./Popover"; +import React, { useEffect } from "react"; +import * as Popover from "@radix-ui/react-popover"; import "./IconPicker.scss"; import { isArrowKey, KEYS } from "../keys"; -import { getLanguage } from "../i18n"; +import { getLanguage, t } from "../i18n"; import clsx from "clsx"; +import Collapsible from "./Stats/Collapsible"; +import { atom, useAtom } from "jotai"; +import { jotaiScope } from "../jotai"; +import { useDevice } from ".."; + +const moreOptionsAtom = atom(false); + +type Option = { + value: T; + text: string; + icon: JSX.Element; + keyBinding: string | null; +}; function Picker({ options, @@ -12,30 +25,16 @@ function Picker({ label, onChange, onClose, + numberOfOptionsToAlwaysShow = options.length, }: { label: string; value: T; - options: { - value: T; - text: string; - icon: JSX.Element; - keyBinding: string | null; - }[]; + options: readonly Option[]; onChange: (value: T) => void; onClose: () => void; + numberOfOptionsToAlwaysShow?: number; }) { - 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 device = useDevice(); const handleKeyDown = (event: React.KeyboardEvent) => { const pressedOption = options.find( @@ -44,28 +43,19 @@ function Picker({ if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { // Keybinding navigation - const index = options.indexOf(pressedOption); - (rGallery!.current!.children![index] as any).focus(); + onChange(pressedOption.value); + 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); + const index = options.findIndex((option) => option.value === value); + const nextIndex = event.shiftKey + ? (options.length + index - 1) % options.length + : (index + 1) % options.length; + onChange(options[nextIndex].value); } else if (isArrowKey(event.key)) { // Arrow navigation - const { activeElement } = document; const isRTL = getLanguage().rtl; - const index = Array.prototype.indexOf.call( - rGallery!.current!.children, - activeElement, - ); + const index = options.findIndex((option) => option.value === value); if (index !== -1) { const length = options.length; let nextIndex = index; @@ -73,19 +63,26 @@ function Picker({ 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; + // Go the next row + case KEYS.ARROW_DOWN: { + nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length; + break; + } + // Go the previous row + case KEYS.ARROW_UP: { + nextIndex = + (length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length; + break; } } - (rGallery.current!.children![nextIndex] as any).focus(); + onChange(options[nextIndex].value); } event.preventDefault(); } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { @@ -97,15 +94,29 @@ function Picker({ event.stopPropagation(); }; - return ( -
-
+ const [showMoreOptions, setShowMoreOptions] = useAtom( + moreOptionsAtom, + jotaiScope, + ); + + const alwaysVisibleOptions = React.useMemo( + () => options.slice(0, numberOfOptionsToAlwaysShow), + [options, numberOfOptionsToAlwaysShow], + ); + const moreOptions = React.useMemo( + () => options.slice(numberOfOptionsToAlwaysShow), + [options, numberOfOptionsToAlwaysShow], + ); + + useEffect(() => { + if (!alwaysVisibleOptions.some((option) => option.value === value)) { + setShowMoreOptions(true); + } + }, [value, alwaysVisibleOptions, setShowMoreOptions]); + + const renderOptions = (options: Option[]) => { + return ( +
{options.map((option, i) => ( ))}
-
+ ); + }; + + return ( + +
+ {renderOptions(alwaysVisibleOptions)} + + {moreOptions.length > 0 && ( + { + setShowMoreOptions((value) => !value); + }} + className="picker-collapsible" + > + {renderOptions(moreOptions)} + + )} +
+
); } @@ -151,6 +194,7 @@ export function IconPicker({ options, onChange, group = "", + numberOfOptionsToAlwaysShow, }: { label: string; value: T; @@ -159,51 +203,40 @@ export function IconPicker({ text: string; icon: JSX.Element; keyBinding: string | null; - showInPicker?: boolean; }[]; onChange: (value: T) => void; + numberOfOptionsToAlwaysShow?: number; group?: string; }) { const [isActive, setActive] = React.useState(false); const rPickerButton = React.useRef(null); - const isRTL = getLanguage().rtl; return (
- - - {isActive ? ( - <> - - event.target !== rPickerButton.current && setActive(false) - } - {...(isRTL ? { right: 5.5 } : { left: -5.5 })} - > - opt.showInPicker !== false)} - value={value} - label={label} - onChange={onChange} - onClose={() => { - setActive(false); - rPickerButton.current?.focus(); - }} - /> - -
- - ) : null} - + setActive(open)}> + setActive(!isActive)} + ref={rPickerButton} + className={isActive ? "active" : ""} + > + {options.find((option) => option.value === value)?.icon} + + {isActive && ( + { + setActive(false); + }} + numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow} + /> + )} +
); } diff --git a/packages/excalidraw/components/Stats/Collapsible.tsx b/packages/excalidraw/components/Stats/Collapsible.tsx index f655860f7..13d476d2a 100644 --- a/packages/excalidraw/components/Stats/Collapsible.tsx +++ b/packages/excalidraw/components/Stats/Collapsible.tsx @@ -9,6 +9,7 @@ interface CollapsibleProps { open: boolean; openTrigger: () => void; children: React.ReactNode; + className?: string; } const Collapsible = ({ @@ -16,6 +17,7 @@ const Collapsible = ({ open, openTrigger, children, + className, }: CollapsibleProps) => { return ( <> @@ -26,6 +28,7 @@ const Collapsible = ({ justifyContent: "space-between", alignItems: "center", }} + className={className} onClick={openTrigger} > {label} diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 7ba3f618b..ddea3d8ed 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo( ), ); +export const ArrowheadCrowfootIcon = React.memo( + ({ flip = false }: { flip?: boolean }) => + createIcon( + + + , + { width: 40, height: 20 }, + ), +); + +export const ArrowheadCrowfootOneIcon = React.memo( + ({ flip = false }: { flip?: boolean }) => + createIcon( + + + , + { width: 40, height: 20 }, + ), +); + +export const ArrowheadCrowfootOneOrManyIcon = React.memo( + ({ flip = false }: { flip?: boolean }) => + createIcon( + + + , + { width: 40, height: 20 }, + ), +); + export const FontSizeSmallIcon = createIcon( <> diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 6fedd4113..19cde12d5 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => { case "diamond": case "diamond_outline": return 12; + case "crowfoot_many": + case "crowfoot_one": + case "crowfoot_one_or_many": + return 20; default: return 15; } @@ -669,6 +673,21 @@ export const getArrowheadPoints = ( const angle = getArrowheadAngle(arrowhead); + if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") { + // swap (xs, ys) with (x2, y2) + const [x3, y3] = pointRotateRads( + pointFrom(x2, y2), + pointFrom(xs, ys), + degreesToRadians(-angle as Degrees), + ); + const [x4, y4] = pointRotateRads( + pointFrom(x2, y2), + pointFrom(xs, ys), + degreesToRadians(angle), + ); + return [xs, ys, x3, y3, x4, y4]; + } + // Return points const [x3, y3] = pointRotateRads( pointFrom(xs, ys), diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index c2cce533a..9f6d8e0b8 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -303,7 +303,10 @@ export type Arrowhead = | "triangle" | "triangle_outline" | "diamond" - | "diamond_outline"; + | "diamond_outline" + | "crowfoot_one" + | "crowfoot_many" + | "crowfoot_one_or_many"; export type ExcalidrawLinearElement = _ExcalidrawElementBase & Readonly<{ diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index d03d68307..bf99dd58d 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -46,6 +46,10 @@ "arrowhead_triangle_outline": "Triangle (outline)", "arrowhead_diamond": "Diamond", "arrowhead_diamond_outline": "Diamond (outline)", + "arrowhead_crowfoot_many": "Crow's foot (many)", + "arrowhead_crowfoot_one": "Crow's foot (one)", + "arrowhead_crowfoot_one_or_many": "Crow's foot (one or many)", + "more_options": "More options", "arrowtypes": "Arrow type", "arrowtype_sharp": "Sharp arrow", "arrowtype_round": "Curved arrow", diff --git a/packages/excalidraw/scene/Shape.ts b/packages/excalidraw/scene/Shape.ts index 0426b3f70..00adfb1eb 100644 --- a/packages/excalidraw/scene/Shape.ts +++ b/packages/excalidraw/scene/Shape.ts @@ -177,6 +177,19 @@ const getArrowheadShapes = ( return []; } + const generateCrowfootOne = ( + arrowheadPoints: number[] | null, + options: Options, + ) => { + if (arrowheadPoints === null) { + return []; + } + + const [, , x3, y3, x4, y4] = arrowheadPoints; + + return [generator.line(x3, y3, x4, y4, options)]; + }; + switch (arrowhead) { case "dot": case "circle": @@ -255,8 +268,12 @@ const getArrowheadShapes = ( ), ]; } + case "crowfoot_one": + return generateCrowfootOne(arrowheadPoints, options); case "bar": case "arrow": + case "crowfoot_many": + case "crowfoot_one_or_many": default: { const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; @@ -272,6 +289,12 @@ const getArrowheadShapes = ( return [ generator.line(x3, y3, x2, y2, options), generator.line(x4, y4, x2, y2, options), + ...(arrowhead === "crowfoot_one_or_many" + ? generateCrowfootOne( + getArrowheadPoints(element, shape, position, "crowfoot_one"), + options, + ) + : []), ]; } }