mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: add crowfoot to arrowheads (#8942)
* 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>
This commit is contained in:
parent
3b9ffd9586
commit
d33e42e3a1
9 changed files with 288 additions and 178 deletions
|
@ -53,6 +53,9 @@ import {
|
||||||
sharpArrowIcon,
|
sharpArrowIcon,
|
||||||
roundArrowIcon,
|
roundArrowIcon,
|
||||||
elbowArrowIcon,
|
elbowArrowIcon,
|
||||||
|
ArrowheadCrowfootIcon,
|
||||||
|
ArrowheadCrowfootOneIcon,
|
||||||
|
ArrowheadCrowfootOneOrManyIcon,
|
||||||
} from "../components/icons";
|
} from "../components/icons";
|
||||||
import {
|
import {
|
||||||
ARROW_TYPE,
|
ARROW_TYPE,
|
||||||
|
@ -1405,59 +1408,65 @@ const getArrowheadOptions = (flip: boolean) => {
|
||||||
keyBinding: "w",
|
keyBinding: "w",
|
||||||
icon: <ArrowheadArrowIcon flip={flip} />,
|
icon: <ArrowheadArrowIcon flip={flip} />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: "bar",
|
|
||||||
text: t("labels.arrowhead_bar"),
|
|
||||||
keyBinding: "e",
|
|
||||||
icon: <ArrowheadBarIcon flip={flip} />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "dot",
|
|
||||||
text: t("labels.arrowhead_circle"),
|
|
||||||
keyBinding: null,
|
|
||||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
|
||||||
showInPicker: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "circle",
|
|
||||||
text: t("labels.arrowhead_circle"),
|
|
||||||
keyBinding: "r",
|
|
||||||
icon: <ArrowheadCircleIcon flip={flip} />,
|
|
||||||
showInPicker: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "circle_outline",
|
|
||||||
text: t("labels.arrowhead_circle_outline"),
|
|
||||||
keyBinding: null,
|
|
||||||
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
|
||||||
showInPicker: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
value: "triangle",
|
value: "triangle",
|
||||||
text: t("labels.arrowhead_triangle"),
|
text: t("labels.arrowhead_triangle"),
|
||||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||||
keyBinding: "t",
|
keyBinding: "e",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "triangle_outline",
|
value: "triangle_outline",
|
||||||
text: t("labels.arrowhead_triangle_outline"),
|
text: t("labels.arrowhead_triangle_outline"),
|
||||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||||
keyBinding: null,
|
keyBinding: "r",
|
||||||
showInPicker: false,
|
},
|
||||||
|
{
|
||||||
|
value: "circle",
|
||||||
|
text: t("labels.arrowhead_circle"),
|
||||||
|
keyBinding: "a",
|
||||||
|
icon: <ArrowheadCircleIcon flip={flip} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "circle_outline",
|
||||||
|
text: t("labels.arrowhead_circle_outline"),
|
||||||
|
keyBinding: "s",
|
||||||
|
icon: <ArrowheadCircleOutlineIcon flip={flip} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "diamond",
|
value: "diamond",
|
||||||
text: t("labels.arrowhead_diamond"),
|
text: t("labels.arrowhead_diamond"),
|
||||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||||
keyBinding: null,
|
keyBinding: "d",
|
||||||
showInPicker: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "diamond_outline",
|
value: "diamond_outline",
|
||||||
text: t("labels.arrowhead_diamond_outline"),
|
text: t("labels.arrowhead_diamond_outline"),
|
||||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||||
keyBinding: null,
|
keyBinding: "f",
|
||||||
showInPicker: false,
|
},
|
||||||
|
{
|
||||||
|
value: "bar",
|
||||||
|
text: t("labels.arrowhead_bar"),
|
||||||
|
keyBinding: "z",
|
||||||
|
icon: <ArrowheadBarIcon flip={flip} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "crowfoot_one",
|
||||||
|
text: t("labels.arrowhead_crowfoot_one"),
|
||||||
|
icon: <ArrowheadCrowfootOneIcon flip={flip} />,
|
||||||
|
keyBinding: "c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "crowfoot_many",
|
||||||
|
text: t("labels.arrowhead_crowfoot_many"),
|
||||||
|
icon: <ArrowheadCrowfootIcon flip={flip} />,
|
||||||
|
keyBinding: "x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "crowfoot_one_or_many",
|
||||||
|
text: t("labels.arrowhead_crowfoot_one_or_many"),
|
||||||
|
icon: <ArrowheadCrowfootOneOrManyIcon flip={flip} />,
|
||||||
|
keyBinding: "v",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
};
|
};
|
||||||
|
@ -1521,6 +1530,7 @@ export const actionChangeArrowhead = register({
|
||||||
appState.currentItemStartArrowhead,
|
appState.currentItemStartArrowhead,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData({ position: "start", type: value })}
|
onChange={(value) => updateData({ position: "start", type: value })}
|
||||||
|
numberOfOptionsToAlwaysShow={4}
|
||||||
/>
|
/>
|
||||||
<IconPicker
|
<IconPicker
|
||||||
label="arrowhead_end"
|
label="arrowhead_end"
|
||||||
|
@ -1537,6 +1547,7 @@ export const actionChangeArrowhead = register({
|
||||||
appState.currentItemEndArrowhead,
|
appState.currentItemEndArrowhead,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData({ position: "end", type: value })}
|
onChange={(value) => updateData({ position: "end", type: value })}
|
||||||
|
numberOfOptionsToAlwaysShow={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
|
@ -1,19 +1,16 @@
|
||||||
@import "../css/variables.module.scss";
|
@import "../css/variables.module.scss";
|
||||||
|
|
||||||
.excalidraw {
|
.excalidraw {
|
||||||
.picker-container {
|
|
||||||
display: inline-block;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker {
|
.picker {
|
||||||
|
padding: 0.5rem;
|
||||||
background: var(--popup-bg-color);
|
background: var(--popup-bg-color);
|
||||||
border: 0 solid transparentize($oc-white, 0.75);
|
border: 0 solid transparentize($oc-white, 0.75);
|
||||||
// ˇˇ yeah, i dunno, open to suggestions here :D
|
box-shadow: var(--shadow-island);
|
||||||
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
:root[dir="rtl"] & {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-container button,
|
.picker-container button,
|
||||||
|
@ -55,47 +52,16 @@
|
||||||
padding: 0.25rem 0.28rem 0.35rem 0.25rem;
|
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 {
|
.picker-content {
|
||||||
padding: 0.5rem;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, auto);
|
grid-template-columns: repeat(4, auto);
|
||||||
grid-gap: 0.5rem;
|
grid-gap: 0.5rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
:root[dir="rtl"] & {
|
|
||||||
padding: 0.4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.picker-collapsible {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-keybinding {
|
.picker-keybinding {
|
||||||
|
|
|
@ -1,10 +1,23 @@
|
||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Popover } from "./Popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import "./IconPicker.scss";
|
import "./IconPicker.scss";
|
||||||
import { isArrowKey, KEYS } from "../keys";
|
import { isArrowKey, KEYS } from "../keys";
|
||||||
import { getLanguage } from "../i18n";
|
import { getLanguage, t } from "../i18n";
|
||||||
import clsx from "clsx";
|
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<T> = {
|
||||||
|
value: T;
|
||||||
|
text: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
keyBinding: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
function Picker<T>({
|
function Picker<T>({
|
||||||
options,
|
options,
|
||||||
|
@ -12,30 +25,16 @@ function Picker<T>({
|
||||||
label,
|
label,
|
||||||
onChange,
|
onChange,
|
||||||
onClose,
|
onClose,
|
||||||
|
numberOfOptionsToAlwaysShow = options.length,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
options: {
|
options: readonly Option<T>[];
|
||||||
value: T;
|
|
||||||
text: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
keyBinding: string | null;
|
|
||||||
}[];
|
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
numberOfOptionsToAlwaysShow?: number;
|
||||||
}) {
|
}) {
|
||||||
const rFirstItem = React.useRef<HTMLButtonElement>();
|
const device = useDevice();
|
||||||
const rActiveItem = React.useRef<HTMLButtonElement>();
|
|
||||||
const rGallery = React.useRef<HTMLDivElement>(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 handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
const pressedOption = options.find(
|
const pressedOption = options.find(
|
||||||
|
@ -44,28 +43,19 @@ function Picker<T>({
|
||||||
|
|
||||||
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
|
||||||
// Keybinding navigation
|
// Keybinding navigation
|
||||||
const index = options.indexOf(pressedOption);
|
onChange(pressedOption.value);
|
||||||
(rGallery!.current!.children![index] as any).focus();
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.TAB) {
|
} else if (event.key === KEYS.TAB) {
|
||||||
// Tab navigation cycle through options. If the user tabs
|
const index = options.findIndex((option) => option.value === value);
|
||||||
// away from the picker, close the picker. We need to use
|
const nextIndex = event.shiftKey
|
||||||
// a timeout here to let the stack clear before checking.
|
? (options.length + index - 1) % options.length
|
||||||
setTimeout(() => {
|
: (index + 1) % options.length;
|
||||||
const active = rActiveItem.current;
|
onChange(options[nextIndex].value);
|
||||||
const docActive = document.activeElement;
|
|
||||||
if (active !== docActive) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
} else if (isArrowKey(event.key)) {
|
} else if (isArrowKey(event.key)) {
|
||||||
// Arrow navigation
|
// Arrow navigation
|
||||||
const { activeElement } = document;
|
|
||||||
const isRTL = getLanguage().rtl;
|
const isRTL = getLanguage().rtl;
|
||||||
const index = Array.prototype.indexOf.call(
|
const index = options.findIndex((option) => option.value === value);
|
||||||
rGallery!.current!.children,
|
|
||||||
activeElement,
|
|
||||||
);
|
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
const length = options.length;
|
const length = options.length;
|
||||||
let nextIndex = index;
|
let nextIndex = index;
|
||||||
|
@ -73,19 +63,26 @@ function Picker<T>({
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
// Select the next option
|
// Select the next option
|
||||||
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
|
||||||
case KEYS.ARROW_DOWN: {
|
|
||||||
nextIndex = (index + 1) % length;
|
nextIndex = (index + 1) % length;
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
// Select the previous option
|
// Select the previous option
|
||||||
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
|
||||||
case KEYS.ARROW_UP: {
|
|
||||||
nextIndex = (length + index - 1) % length;
|
nextIndex = (length + index - 1) % length;
|
||||||
break;
|
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();
|
event.preventDefault();
|
||||||
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
|
||||||
|
@ -97,15 +94,29 @@ function Picker<T>({
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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<T>[]) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="picker-content">
|
||||||
className={`picker`}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={label}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
<div className="picker-content" ref={rGallery}>
|
|
||||||
{options.map((option, i) => (
|
{options.map((option, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -113,7 +124,6 @@ function Picker<T>({
|
||||||
active: value === option.value,
|
active: value === option.value,
|
||||||
})}
|
})}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
(event.currentTarget as HTMLButtonElement).focus();
|
|
||||||
onChange(option.value);
|
onChange(option.value);
|
||||||
}}
|
}}
|
||||||
title={`${option.text} ${
|
title={`${option.text} ${
|
||||||
|
@ -122,16 +132,13 @@ function Picker<T>({
|
||||||
aria-label={option.text || "none"}
|
aria-label={option.text || "none"}
|
||||||
aria-keyshortcuts={option.keyBinding || undefined}
|
aria-keyshortcuts={option.keyBinding || undefined}
|
||||||
key={option.text}
|
key={option.text}
|
||||||
ref={(el) => {
|
ref={(ref) => {
|
||||||
if (el && i === 0) {
|
if (value === option.value) {
|
||||||
rFirstItem.current = el;
|
// Use a timeout here to render focus properly
|
||||||
|
setTimeout(() => {
|
||||||
|
ref?.focus();
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
if (el && option.value === value) {
|
|
||||||
rActiveItem.current = el;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
onChange(option.value);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.icon}
|
{option.icon}
|
||||||
|
@ -141,7 +148,43 @@ function Picker<T>({
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Content
|
||||||
|
side={
|
||||||
|
device.editor.isMobile && !device.viewport.isLandscape
|
||||||
|
? "top"
|
||||||
|
: "bottom"
|
||||||
|
}
|
||||||
|
align="start"
|
||||||
|
sideOffset={12}
|
||||||
|
style={{ zIndex: "var(--zIndex-popup)" }}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`picker`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{renderOptions(alwaysVisibleOptions)}
|
||||||
|
|
||||||
|
{moreOptions.length > 0 && (
|
||||||
|
<Collapsible
|
||||||
|
label={t("labels.more_options")}
|
||||||
|
open={showMoreOptions}
|
||||||
|
openTrigger={() => {
|
||||||
|
setShowMoreOptions((value) => !value);
|
||||||
|
}}
|
||||||
|
className="picker-collapsible"
|
||||||
|
>
|
||||||
|
{renderOptions(moreOptions)}
|
||||||
|
</Collapsible>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</Popover.Content>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -151,6 +194,7 @@ export function IconPicker<T>({
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
group = "",
|
group = "",
|
||||||
|
numberOfOptionsToAlwaysShow,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: T;
|
value: T;
|
||||||
|
@ -159,51 +203,40 @@ export function IconPicker<T>({
|
||||||
text: string;
|
text: string;
|
||||||
icon: JSX.Element;
|
icon: JSX.Element;
|
||||||
keyBinding: string | null;
|
keyBinding: string | null;
|
||||||
showInPicker?: boolean;
|
|
||||||
}[];
|
}[];
|
||||||
onChange: (value: T) => void;
|
onChange: (value: T) => void;
|
||||||
|
numberOfOptionsToAlwaysShow?: number;
|
||||||
group?: string;
|
group?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isActive, setActive] = React.useState(false);
|
const [isActive, setActive] = React.useState(false);
|
||||||
const rPickerButton = React.useRef<any>(null);
|
const rPickerButton = React.useRef<any>(null);
|
||||||
const isRTL = getLanguage().rtl;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||||
|
<Popover.Trigger
|
||||||
name={group}
|
name={group}
|
||||||
type="button"
|
type="button"
|
||||||
className={isActive ? "active" : ""}
|
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
onClick={() => setActive(!isActive)}
|
onClick={() => setActive(!isActive)}
|
||||||
ref={rPickerButton}
|
ref={rPickerButton}
|
||||||
|
className={isActive ? "active" : ""}
|
||||||
>
|
>
|
||||||
{options.find((option) => option.value === value)?.icon}
|
{options.find((option) => option.value === value)?.icon}
|
||||||
</button>
|
</Popover.Trigger>
|
||||||
<React.Suspense fallback="">
|
{isActive && (
|
||||||
{isActive ? (
|
|
||||||
<>
|
|
||||||
<Popover
|
|
||||||
onCloseRequest={(event) =>
|
|
||||||
event.target !== rPickerButton.current && setActive(false)
|
|
||||||
}
|
|
||||||
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
|
|
||||||
>
|
|
||||||
<Picker
|
<Picker
|
||||||
options={options.filter((opt) => opt.showInPicker !== false)}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
label={label}
|
label={label}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setActive(false);
|
setActive(false);
|
||||||
rPickerButton.current?.focus();
|
|
||||||
}}
|
}}
|
||||||
|
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||||
/>
|
/>
|
||||||
</Popover>
|
)}
|
||||||
<div className="picker-triangle" />
|
</Popover.Root>
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</React.Suspense>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ interface CollapsibleProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
openTrigger: () => void;
|
openTrigger: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Collapsible = ({
|
const Collapsible = ({
|
||||||
|
@ -16,6 +17,7 @@ const Collapsible = ({
|
||||||
open,
|
open,
|
||||||
openTrigger,
|
openTrigger,
|
||||||
children,
|
children,
|
||||||
|
className,
|
||||||
}: CollapsibleProps) => {
|
}: CollapsibleProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
@ -26,6 +28,7 @@ const Collapsible = ({
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
|
className={className}
|
||||||
onClick={openTrigger}
|
onClick={openTrigger}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
|
|
|
@ -1352,6 +1352,54 @@ export const ArrowheadDiamondOutlineIcon = React.memo(
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L7,5 M15,10 L7,15" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootOneIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L15,15 L15,5" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ArrowheadCrowfootOneOrManyIcon = React.memo(
|
||||||
|
({ flip = false }: { flip?: boolean }) =>
|
||||||
|
createIcon(
|
||||||
|
<g
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
transform={flip ? "" : "translate(40, 0) scale(-1, 1)"}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M34,10 H6 M15,10 L15,16 L15,4 M15,10 L7,5 M15,10 L7,15" />
|
||||||
|
</g>,
|
||||||
|
{ width: 40, height: 20 },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
export const FontSizeSmallIcon = createIcon(
|
export const FontSizeSmallIcon = createIcon(
|
||||||
<>
|
<>
|
||||||
<g clipPath="url(#a)">
|
<g clipPath="url(#a)">
|
||||||
|
|
|
@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
|
||||||
case "diamond":
|
case "diamond":
|
||||||
case "diamond_outline":
|
case "diamond_outline":
|
||||||
return 12;
|
return 12;
|
||||||
|
case "crowfoot_many":
|
||||||
|
case "crowfoot_one":
|
||||||
|
case "crowfoot_one_or_many":
|
||||||
|
return 20;
|
||||||
default:
|
default:
|
||||||
return 15;
|
return 15;
|
||||||
}
|
}
|
||||||
|
@ -669,6 +673,21 @@ export const getArrowheadPoints = (
|
||||||
|
|
||||||
const angle = getArrowheadAngle(arrowhead);
|
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
|
// Return points
|
||||||
const [x3, y3] = pointRotateRads(
|
const [x3, y3] = pointRotateRads(
|
||||||
pointFrom(xs, ys),
|
pointFrom(xs, ys),
|
||||||
|
|
|
@ -303,7 +303,10 @@ export type Arrowhead =
|
||||||
| "triangle"
|
| "triangle"
|
||||||
| "triangle_outline"
|
| "triangle_outline"
|
||||||
| "diamond"
|
| "diamond"
|
||||||
| "diamond_outline";
|
| "diamond_outline"
|
||||||
|
| "crowfoot_one"
|
||||||
|
| "crowfoot_many"
|
||||||
|
| "crowfoot_one_or_many";
|
||||||
|
|
||||||
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
|
|
|
@ -46,6 +46,10 @@
|
||||||
"arrowhead_triangle_outline": "Triangle (outline)",
|
"arrowhead_triangle_outline": "Triangle (outline)",
|
||||||
"arrowhead_diamond": "Diamond",
|
"arrowhead_diamond": "Diamond",
|
||||||
"arrowhead_diamond_outline": "Diamond (outline)",
|
"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",
|
"arrowtypes": "Arrow type",
|
||||||
"arrowtype_sharp": "Sharp arrow",
|
"arrowtype_sharp": "Sharp arrow",
|
||||||
"arrowtype_round": "Curved arrow",
|
"arrowtype_round": "Curved arrow",
|
||||||
|
|
|
@ -177,6 +177,19 @@ const getArrowheadShapes = (
|
||||||
return [];
|
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) {
|
switch (arrowhead) {
|
||||||
case "dot":
|
case "dot":
|
||||||
case "circle":
|
case "circle":
|
||||||
|
@ -255,8 +268,12 @@ const getArrowheadShapes = (
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
case "crowfoot_one":
|
||||||
|
return generateCrowfootOne(arrowheadPoints, options);
|
||||||
case "bar":
|
case "bar":
|
||||||
case "arrow":
|
case "arrow":
|
||||||
|
case "crowfoot_many":
|
||||||
|
case "crowfoot_one_or_many":
|
||||||
default: {
|
default: {
|
||||||
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
|
||||||
|
|
||||||
|
@ -272,6 +289,12 @@ const getArrowheadShapes = (
|
||||||
return [
|
return [
|
||||||
generator.line(x3, y3, x2, y2, options),
|
generator.line(x3, y3, x2, y2, options),
|
||||||
generator.line(x4, y4, x2, y2, options),
|
generator.line(x4, y4, x2, y2, options),
|
||||||
|
...(arrowhead === "crowfoot_one_or_many"
|
||||||
|
? generateCrowfootOne(
|
||||||
|
getArrowheadPoints(element, shape, position, "crowfoot_one"),
|
||||||
|
options,
|
||||||
|
)
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue