More Arrowheads: dot, bar (#2486)

Co-authored-by: Jed Fox <git@jedfox.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
This commit is contained in:
Steve Ruiz 2020-12-11 17:17:28 +00:00 committed by GitHub
parent 7c7fb4903b
commit c742225f43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 550 additions and 70 deletions

View File

@ -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 }) => (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="buttonList buttonListIcon">
<ButtonIconCycle
group="arrowhead_start"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
icon: (
<ArrowArrowheadIcon
appearance={appState.appearance}
flip={true}
/>
),
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemArrowheads.start,
appState.currentItemArrowheads.start,
)}
onChange={(value) => updateData({ position: "start", type: value })}
/>
<ButtonIconCycle
group="arrowhead_end"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
icon: <StrokeStyleSolidIcon appearance={appState.appearance} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
icon: <ArrowArrowheadIcon appearance={appState.appearance} />,
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemArrowheads.end,
appState.currentItemArrowheads.end,
)}
onChange={(value) => updateData({ position: "end", type: value })}
/>
</div>
</fieldset>
),
PanelComponent: ({ elements, appState, updateData }) => {
const isRTL = getLanguage().rtl;
return (
<fieldset>
<legend>{t("labels.arrowheads")}</legend>
<div className="iconSelectList">
<IconPicker
label="arrowhead_start"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
keyBinding: "q",
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
icon: (
<ArrowheadArrowIcon
appearance={appState.appearance}
flip={!isRTL}
/>
),
keyBinding: "w",
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
icon: (
<ArrowheadBarIcon
appearance={appState.appearance}
flip={!isRTL}
/>
),
keyBinding: "e",
},
{
value: "dot",
text: t("labels.arrowhead_dot"),
icon: (
<ArrowheadDotIcon
appearance={appState.appearance}
flip={!isRTL}
/>
),
keyBinding: "r",
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.startArrowhead
: appState.currentItemArrowheads.start,
appState.currentItemArrowheads.start,
)}
onChange={(value) => updateData({ position: "start", type: value })}
/>
<IconPicker
label="arrowhead_end"
group="arrowheads"
options={[
{
value: null,
text: t("labels.arrowhead_none"),
keyBinding: "q",
icon: <ArrowheadNoneIcon appearance={appState.appearance} />,
},
{
value: "arrow",
text: t("labels.arrowhead_arrow"),
keyBinding: "w",
icon: (
<ArrowheadArrowIcon
appearance={appState.appearance}
flip={isRTL}
/>
),
},
{
value: "bar",
text: t("labels.arrowhead_bar"),
keyBinding: "e",
icon: (
<ArrowheadBarIcon
appearance={appState.appearance}
flip={isRTL}
/>
),
},
{
value: "dot",
text: t("labels.arrowhead_dot"),
keyBinding: "r",
icon: (
<ArrowheadDotIcon
appearance={appState.appearance}
flip={isRTL}
/>
),
},
]}
value={getFormValue<Arrowhead | null>(
elements,
appState,
(element) =>
isLinearElement(element) && canHaveArrowheads(element.type)
? element.endArrowhead
: appState.currentItemArrowheads.end,
appState.currentItemArrowheads.end,
)}
onChange={(value) => updateData({ position: "end", type: value })}
/>
</div>
</fieldset>
);
},
});

View File

@ -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;
}
}
}

View File

@ -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<T>({
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<HTMLButtonElement>();
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 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 (
<div
className={`picker`}
role="dialog"
aria-modal="true"
aria-label={label}
onKeyDown={handleKeyDown}
>
<div className="picker-content" ref={rGallery}>
{options.map((option, i) => (
<button
className="picker-option"
onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value);
}}
title={`${option.text}${option.keyBinding.toUpperCase()}`}
aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding}
key={option.text}
ref={(el) => {
if (el && i === 0) {
rFirstItem.current = el;
}
if (el && option.value === value) {
rActiveItem.current = el;
}
}}
onFocus={() => {
onChange(option.value);
}}
>
{option.icon}
<span className="picker-keybinding">{option.keyBinding}</span>
</button>
))}
</div>
</div>
);
}
export function IconPicker<T>({
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<any>(null);
const isRTL = getLanguage().rtl;
return (
<label className={"picker-container"}>
<button
name={group}
className={isActive ? "active" : ""}
aria-label={label}
onClick={() => setActive(!isActive)}
ref={rPickerButton}
>
{options.find((option) => option.value === value)?.icon}
</button>
<React.Suspense fallback="">
{isActive ? (
<>
<Popover
onCloseRequest={(event) =>
event.target !== rPickerButton.current && setActive(false)
}
{...(isRTL ? { right: 5.5 } : { left: -5.5 })}
>
<Picker
options={options}
value={value}
label={label}
onChange={onChange}
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
</label>
);
}

View File

@ -82,7 +82,7 @@ export const MobileMenu = ({
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
}}
>
<Island padding={3}>
<Island padding={0}>
{appState.openMenu === "canvas" ? (
<Section className="App-mobile-menu" heading="canvasActions">
<div className="panelColumn">

View File

@ -725,7 +725,23 @@ export const EdgeRoundIcon = React.memo(
),
);
export const ArrowArrowheadIcon = React.memo(
export const ArrowheadNoneIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) =>
createIcon(
<path
d="M6 10H34"
stroke={iconFillColor(appearance)}
strokeWidth={2}
fill="none"
/>,
{
width: 40,
height: 20,
},
),
);
export const ArrowheadArrowIcon = React.memo(
({
appearance,
flip = false,
@ -743,6 +759,48 @@ export const ArrowArrowheadIcon = React.memo(
<path d="M34 10H6M34 10L27 5M34 10L27 15" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>,
{ width: 40, height: 20, mirror: true },
{ width: 40, height: 20 },
),
);
export const ArrowheadDotIcon = React.memo(
({
appearance,
flip = false,
}: {
appearance: "light" | "dark";
flip?: boolean;
}) =>
createIcon(
<g
stroke={iconFillColor(appearance)}
fill={iconFillColor(appearance)}
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
>
<path d="M32 10L6 10" strokeWidth={2} />
<circle r="4" transform="matrix(-1 0 0 1 30 10)" />
</g>,
{ width: 40, height: 20 },
),
);
export const ArrowheadBarIcon = React.memo(
({
appearance,
flip = false,
}: {
appearance: "light" | "dark";
flip?: boolean;
}) =>
createIcon(
<g transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}>
<path
d="M34 10H5.99996M34 10L34 5M34 10L34 15"
stroke={iconFillColor(appearance)}
strokeWidth={2}
fill="none"
/>
</g>,
{ width: 40, height: 20 },
),
);

View File

@ -84,6 +84,11 @@
padding: 0;
}
.iconSelectList {
flex-wrap: wrap;
position: relative;
}
.buttonList {
flex-wrap: wrap;
@ -236,6 +241,10 @@
display: flex;
flex-direction: column;
pointer-events: initial;
.panelColumn {
padding: 8px 8px 0px 8px;
}
}
}
@ -249,6 +258,7 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
}
.App-mobile-menu {

View File

@ -212,7 +212,11 @@ export const getArrowheadPoints = (
const nx = (x2 - x1) / distance;
const ny = (y2 - y1) / distance;
const size = 30; // pixels (will differ for each arrowhead)
const size = {
arrow: 30,
bar: 15,
dot: 15,
}[arrowhead]; // pixels (will differ for each arrowhead)
const length = element.points.reduce((total, [cx, cy], idx, points) => {
const [px, py] = idx > 0 ? points[idx - 1] : [0, 0];
@ -226,7 +230,15 @@ export const getArrowheadPoints = (
const xs = x2 - nx * minSize;
const ys = y2 - ny * minSize;
const angle = 20; // degrees
if (arrowhead === "dot") {
const r = Math.hypot(ys - y2, xs - x2);
return [x2, y2, r];
}
const angle = {
arrow: 20,
bar: 90,
}[arrowhead]; // degrees
// Return points
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);

View File

@ -98,7 +98,7 @@ export type PointBinding = {
gap: number;
};
export type Arrowhead = "arrow";
export type Arrowhead = "arrow" | "bar" | "dot";
export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{

View File

@ -31,6 +31,8 @@
"arrowheads": "Arrowheads",
"arrowhead_none": "None",
"arrowhead_arrow": "Arrow",
"arrowhead_bar": "Bar",
"arrowhead_dot": "Dot",
"fontSize": "Font size",
"fontFamily": "Font family",
"onlySelected": "Only selected",

View File

@ -356,6 +356,17 @@ const generateElementShape = (
}
// Other arrowheads here...
if (arrowhead === "dot") {
const [x, y, r] = arrowheadPoints;
return [
generator.circle(x, y, r, {
...options,
fill: element.strokeColor,
fillStyle: "solid",
}),
];
}
// Arrow arrowheads
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;