1
0
mirror of https://github.com/excalidraw/excalidraw.git synced 2024-11-10 11:35:52 +01:00

Add Arrowheads to Arrows (#2452)

Co-authored-by: dwelle <luzar.david@gmail.com>
Co-authored-by: Lipis <lipiridis@gmail.com>
This commit is contained in:
Steve Ruiz 2020-12-08 15:02:55 +00:00 committed by GitHub
parent bd8e860d7f
commit c291edfc44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 711 additions and 101 deletions

23
.gitignore vendored

@ -1,10 +1,18 @@
*.log
.DS_Store .DS_Store
.env.development.local
.env.local
.env.production.local
.env.test.local
.envrc .envrc
.now .eslintcache
.idea
.vercel
.vscode .vscode
*.log
*.tgz
build build
firebase/ dist
firebase
logs logs
node_modules node_modules
npm-debug.log* npm-debug.log*
@ -12,12 +20,3 @@ static
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
yarn.lock yarn.lock
.idea
dist/
.eslintcache
*.tgz
.env.local
.env.development.local
.env.test.local
.env.production.local

@ -4,15 +4,19 @@ import {
ExcalidrawTextElement, ExcalidrawTextElement,
TextAlign, TextAlign,
FontFamily, FontFamily,
ExcalidrawLinearElement,
Arrowhead,
} from "../element/types"; } from "../element/types";
import { import {
getCommonAttributeOfSelectedElements, getCommonAttributeOfSelectedElements,
isSomeElementSelected, isSomeElementSelected,
getTargetElements, getTargetElements,
canChangeSharpness, canChangeSharpness,
canHaveArrowheads,
} from "../scene"; } from "../scene";
import { ButtonSelect } from "../components/ButtonSelect"; import { ButtonSelect } from "../components/ButtonSelect";
import { ButtonIconSelect } from "../components/ButtonIconSelect"; import { ButtonIconSelect } from "../components/ButtonIconSelect";
import { ButtonIconCycle } from "../components/ButtonIconCycle";
import { import {
isTextElement, isTextElement,
redrawTextBoundingBox, redrawTextBoundingBox,
@ -39,6 +43,7 @@ import {
SloppinessArchitectIcon, SloppinessArchitectIcon,
SloppinessArtistIcon, SloppinessArtistIcon,
SloppinessCartoonistIcon, SloppinessCartoonistIcon,
ArrowArrowheadIcon,
} from "../components/icons"; } from "../components/icons";
import { EVENT_CHANGE, trackEvent } from "../analytics"; import { EVENT_CHANGE, trackEvent } from "../analytics";
import colors from "../colors"; import colors from "../colors";
@ -622,3 +627,110 @@ export const actionChangeSharpness = register({
</fieldset> </fieldset>
), ),
}); });
export const actionChangeArrowhead = register({
name: "changeArrowhead",
perform: (
elements,
appState,
value: { position: "start" | "end"; type: Arrowhead },
) => {
return {
elements: changeProperty(elements, appState, (el) => {
if (isLinearElement(el)) {
trackEvent(
EVENT_CHANGE,
`arrowhead ${value.position}`,
value.type || "none",
);
const { position, type } = value;
if (position === "start") {
const element: ExcalidrawLinearElement = newElementWith(el, {
startArrowhead: type,
});
return element;
} else if (position === "end") {
const element: ExcalidrawLinearElement = newElementWith(el, {
endArrowhead: type,
});
return element;
}
}
return el;
}),
appState: {
...appState,
currentItemArrowheads: {
...appState.currentItemArrowheads,
[value.position]: value.type,
},
},
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>
),
});

@ -35,6 +35,7 @@ export type ActionName =
| "changeStrokeWidth" | "changeStrokeWidth"
| "changeSloppiness" | "changeSloppiness"
| "changeStrokeStyle" | "changeStrokeStyle"
| "changeArrowhead"
| "changeOpacity" | "changeOpacity"
| "changeFontSize" | "changeFontSize"
| "toggleCanvasMenu" | "toggleCanvasMenu"
@ -99,9 +100,7 @@ export interface Action {
} }
export interface ActionsManagerInterface { export interface ActionsManagerInterface {
actions: { actions: Record<ActionName, Action>;
[actionName in ActionName]: Action;
};
registerAction: (action: Action) => void; registerAction: (action: Action) => void;
handleKeyDown: (event: KeyboardEvent) => boolean; handleKeyDown: (event: KeyboardEvent) => boolean;
getContextMenuItems: ( getContextMenuItems: (

@ -39,6 +39,7 @@ export const getDefaultAppState = (): Omit<
currentItemTextAlign: DEFAULT_TEXT_ALIGN, currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentItemStrokeSharpness: "sharp", currentItemStrokeSharpness: "sharp",
currentItemLinearStrokeSharpness: "round", currentItemLinearStrokeSharpness: "round",
currentItemArrowheads: { start: null, end: "arrow" },
viewBackgroundColor: oc.white, viewBackgroundColor: oc.white,
scrollX: 0 as FlooredNumber, scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber, scrollY: 0 as FlooredNumber,
@ -103,6 +104,7 @@ const APP_STATE_STORAGE_CONF = (<
currentItemTextAlign: { browser: true, export: false }, currentItemTextAlign: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false }, currentItemStrokeSharpness: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false }, currentItemLinearStrokeSharpness: { browser: true, export: false },
currentItemArrowheads: { browser: true, export: false },
cursorButton: { browser: true, export: false }, cursorButton: { browser: true, export: false },
cursorX: { browser: true, export: false }, cursorX: { browser: true, export: false },
cursorY: { browser: true, export: false }, cursorY: { browser: true, export: false },

@ -7,6 +7,7 @@ import {
hasStroke, hasStroke,
canChangeSharpness, canChangeSharpness,
hasText, hasText,
canHaveArrowheads,
getTargetElements, getTargetElements,
} from "../scene"; } from "../scene";
import { t } from "../i18n"; import { t } from "../i18n";
@ -46,6 +47,7 @@ export const SelectedShapeActions = ({
const showChangeBackgroundIcons = const showChangeBackgroundIcons =
hasBackground(elementType) || hasBackground(elementType) ||
targetElements.some((element) => hasBackground(element.type)); targetElements.some((element) => hasBackground(element.type));
return ( return (
<div className="panelColumn"> <div className="panelColumn">
{renderAction("changeStrokeColor")} {renderAction("changeStrokeColor")}
@ -77,6 +79,11 @@ export const SelectedShapeActions = ({
</> </>
)} )}
{(canHaveArrowheads(elementType) ||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
<>{renderAction("changeArrowhead")}</>
)}
{renderAction("changeOpacity")} {renderAction("changeOpacity")}
<fieldset> <fieldset>

@ -2576,6 +2576,14 @@ class App extends React.Component<ExcalidrawProps, AppState> {
pointerDownState.origin.y, pointerDownState.origin.y,
elementType === "draw" ? null : this.state.gridSize, elementType === "draw" ? null : this.state.gridSize,
); );
// If arrow is pre-arrowheads, it will have undefined for both start and end arrowheads.
// If so, we want it to be null/"arrow". If the linear item is not an arrow, we want it
// to be null/null. Otherwise, we want it to use the currentItemArrowheads values.
const { start, end } = this.state.currentItemArrowheads;
const [startArrowhead, endArrowhead] =
elementType === "arrow" ? [start, end] : [null, null];
const element = newLinearElement({ const element = newLinearElement({
type: elementType, type: elementType,
x: gridX, x: gridX,
@ -2588,6 +2596,8 @@ class App extends React.Component<ExcalidrawProps, AppState> {
roughness: this.state.currentItemRoughness, roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity, opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemLinearStrokeSharpness, strokeSharpness: this.state.currentItemLinearStrokeSharpness,
startArrowhead,
endArrowhead,
}); });
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: { selectedElementIds: {

@ -0,0 +1,29 @@
import React from "react";
import clsx from "clsx";
export const ButtonIconCycle = <T extends any>({
options,
value,
onChange,
group,
}: {
options: { value: T; text: string; icon: JSX.Element }[];
value: T | null;
onChange: (value: T) => void;
group: string;
}) => {
const current = options.find((op) => op.value === value);
function cycle() {
const index = options.indexOf(current!);
const next = (index + 1) % options.length;
onChange(options[next].value);
}
return (
<label key={group} className={clsx({ active: current!.value !== null })}>
<input type="button" name={group} onClick={cycle} />
{current!.icon}
</label>
);
};

@ -568,13 +568,12 @@ export const UngroupIcon = React.memo(
export const FillHachureIcon = React.memo( export const FillHachureIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<g stroke={iconFillColor(appearance)} fill="none">
<path d="M0 0s0 0 0 0m0 0s0 0 0 0m.133 12.04L10.63-.033M.133 12.04L10.63-.034M2.234 21.818L21.26-.07M2.234 21.818L21.26-.07m-8.395 21.852L31.89-.103M12.865 21.783L31.89-.103m-8.395 21.852L41.208 1.37M23.495 21.75L41.208 1.37m-7.083 20.343l7.216-8.302m-7.216 8.302l7.216-8.302" />
<path <path
d="M0 0h40M0 0h40m0 0v20m0-20v20m0 0H0m40 0H0m0 0V0m0 20V0" fillRule="evenodd"
strokeWidth={2} clipRule="evenodd"
/> d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z"
</g>, fill={iconFillColor(appearance)}
/>,
{ width: 40, height: 20 }, { width: 40, height: 20 },
), ),
); );
@ -582,12 +581,9 @@ export const FillHachureIcon = React.memo(
export const FillCrossHatchIcon = React.memo( export const FillCrossHatchIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<g stroke={iconFillColor(appearance)} fill="none"> <g fill={iconFillColor(appearance)} fillRule="evenodd" clipRule="evenodd">
<path d="M0 0s0 0 0 0m0 0s0 0 0 0m.133 12.04L10.63-.033M.133 12.04L10.63-.034M2.234 21.818L21.26-.07M2.234 21.818L21.26-.07m-8.395 21.852L31.89-.103M12.865 21.783C17.87 16.025 22.875 10.266 31.89-.103m-8.395 21.852L41.208 1.37M23.495 21.75L41.208 1.37m-7.083 20.343l7.216-8.302m-7.216 8.302l7.216-8.302M-.09 19.92s0 0 0 0m0 0s0 0 0 0m12.04-.133L-.126 9.29m12.075 10.497L-.126 9.29m24.871 11.02C19.872 16.075 15 11.84.595-.684m24.15 20.994L.595-.684m36.19 20.861L12.636-.817m24.15 20.994L12.636-.817m30.909 16.269L24.676-.95m18.868 16.402L24.676-.95m18.833 5.771L37.472-.427m6.037 5.248L37.472-.427" /> <path d="M20.101 16H28.0934L36 8.95989V4H33.5779L20.101 16ZM30.5704 4L17.0935 16H9.10101L22.5779 4H30.5704ZM19.5704 4L6.09349 16H4V10.7475L11.5779 4H19.5704ZM8.57036 4H4V8.06952L8.57036 4ZM36 11.6378L31.101 16H36V11.6378ZM2 2V18H38V2H2Z" />
<path <path d="M14.0001 18L3.00006 4.00002L4.5727 2.76438L15.5727 16.7644L14.0001 18ZM25.0001 18L14.0001 4.00002L15.5727 2.76438L26.5727 16.7644L25.0001 18ZM36.0001 18L25.0001 4.00002L26.5727 2.76438L37.5727 16.7644L36.0001 18Z" />
d="M0 0h40M0 0h40m0 0v20m0-20v20m0 0H0m40 0H0m0 0V0m0 20V0"
strokeWidth={2}
/>
</g>, </g>,
{ width: 40, height: 20 }, { width: 40, height: 20 },
), ),
@ -595,18 +591,10 @@ export const FillCrossHatchIcon = React.memo(
export const FillSolidIcon = React.memo( export const FillSolidIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(<path d="M2 2H38V18H2V2Z" fill={iconFillColor(appearance)} />, {
<> width: 40,
<path d="M0 0h120v60H0" strokeWidth={0} /> height: 20,
<path }),
d="M0 0h40M0 0h40m0 0v20m0-20v20m0 0H0m40 0H0m0 0V0m0 20V0"
stroke={iconFillColor(appearance)}
strokeWidth={2}
fill="none"
/>
</>,
{ width: 40, height: 20 },
),
); );
export const StrokeWidthIcon = React.memo( export const StrokeWidthIcon = React.memo(
@ -619,7 +607,7 @@ export const StrokeWidthIcon = React.memo(
}) => }) =>
createIcon( createIcon(
<path <path
d="M0 10h40M0 10h40" d="M6 10H34"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
fill="none" fill="none"
@ -632,12 +620,15 @@ export const StrokeStyleSolidIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M0 10h40M0 10h40" d="M6 10H34"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
/>, />,
{ width: 40, height: 20 }, {
width: 40,
height: 20,
},
), ),
); );
@ -645,11 +636,11 @@ export const StrokeStyleDashedIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M3.286 9.998h32.759" d="M6 10H34"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2.5} strokeWidth={2.5}
strokeDasharray={"10, 8"}
fill="none" fill="none"
strokeDasharray="12 8"
/>, />,
{ width: 40, height: 20 }, { width: 40, height: 20 },
), ),
@ -659,11 +650,11 @@ export const StrokeStyleDottedIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M0 10h40M0 10h40" d="M6 10H34"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2.5}
strokeDasharray={"4, 4"}
fill="none" fill="none"
strokeDasharray="3 6"
/>, />,
{ width: 40, height: 20 }, { width: 40, height: 20 },
), ),
@ -673,7 +664,7 @@ export const SloppinessArchitectIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M.268 17.938C4.05 15.093 19.414.725 22.96.868c3.547.143-4.149 16.266-1.41 17.928 2.738 1.662 14.866-6.632 17.84-7.958m-39.123 7.1C4.05 15.093 19.414.725 22.96.868c3.547.143-4.149 16.266-1.41 17.928 2.738 1.662 14.866-6.632 17.84-7.958" d="M3.00098 16.1691C6.28774 13.9744 19.6399 2.8905 22.7215 3.00082C25.8041 3.11113 19.1158 15.5488 21.4962 16.8309C23.8757 18.1131 34.4155 11.7148 37.0001 10.6919"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
@ -686,7 +677,7 @@ export const SloppinessArtistIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M2.663 18.134c3.963-2.578 18.855-12.098 22.675-12.68 3.82-.58-1.966 8.367.242 9.196 2.209.828 10.649-3.14 13.01-4.224M7.037 15.474c4.013-2.198 14.19-14.648 17.18-14.32 2.99.329-1.749 14.286.759 16.292 2.507 2.006 12.284-2.68 14.286-4.256" d="M3 17C6.68158 14.8752 16.1296 9.09849 22.0648 6.54922C28 3.99995 22.2896 13.3209 25 14C27.7104 14.6791 36.3757 9.6471 36.3757 9.6471M6.40706 15C13 11.1918 20.0468 1.51045 23.0234 3.0052C26 4.49995 20.457 12.8659 22.7285 16.4329C25 20 36.3757 13 36.3757 13"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
@ -698,20 +689,12 @@ export const SloppinessArtistIcon = React.memo(
export const SloppinessCartoonistIcon = React.memo( export const SloppinessCartoonistIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<>
<path <path
d="M1.944 17.15C6.056 14.637 22.368 1.86 26.615 2.083c4.248.223-.992 14.695.815 16.406 1.807 1.71 8.355-5.117 10.026-6.14m-35.512 4.8C6.056 14.637 22.368 1.86 26.615 2.083c4.248.223-.992 14.695.815 16.406 1.807 1.71 8.355-5.117 10.026-6.14" d="M3 15.6468C6.93692 13.5378 22.5544 2.81528 26.6206 3.00242C30.6877 3.18956 25.6708 15.3346 27.4009 16.7705C29.1309 18.2055 35.4001 12.4762 37 11.6177M3.97143 10.4917C6.61158 9.24563 16.3706 2.61886 19.8104 3.01724C23.2522 3.41472 22.0773 12.2013 24.6181 12.8783C27.1598 13.5536 33.3179 8.04068 35.0571 7.07244"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
/> />,
<path
d="M3.114 10.534c2.737-1.395 12.854-8.814 16.42-8.368 3.568.445 2.35 10.282 4.984 11.04 2.635.756 9.019-5.416 10.822-6.5M3.114 10.535c2.737-1.395 12.854-8.814 16.42-8.368 3.568.445 2.35 10.282 4.984 11.04 2.635.756 9.019-5.416 10.822-6.5"
stroke={iconFillColor(appearance)}
strokeWidth={2}
fill="none"
/>
</>,
{ width: 40, height: 20, mirror: true }, { width: 40, height: 20, mirror: true },
), ),
); );
@ -720,7 +703,7 @@ export const EdgeSharpIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M9.18 19.68V6.346m0 13.336V6.345m0 0h29.599m-29.6 0h29.6" d="M10 17L10 5L35 5"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
@ -733,7 +716,7 @@ export const EdgeRoundIcon = React.memo(
({ appearance }: { appearance: "light" | "dark" }) => ({ appearance }: { appearance: "light" | "dark" }) =>
createIcon( createIcon(
<path <path
d="M9.444 19.537c.484-2.119-2.1-10.449 2.904-12.71 5.004-2.263 22.601-.72 27.121-.863M9.444 19.537c.484-2.119-2.1-10.449 2.904-12.71 5.004-2.263 22.601-.72 27.121-.863" d="M10 17V15C10 8 13 5 21 5L33.5 5"
stroke={iconFillColor(appearance)} stroke={iconFillColor(appearance)}
strokeWidth={2} strokeWidth={2}
fill="none" fill="none"
@ -741,3 +724,25 @@ export const EdgeRoundIcon = React.memo(
{ width: 40, height: 20, mirror: true }, { width: 40, height: 20, mirror: true },
), ),
); );
export const ArrowArrowheadIcon = React.memo(
({
appearance,
flip = false,
}: {
appearance: "light" | "dark";
flip?: boolean;
}) =>
createIcon(
<g
transform={flip ? "translate(40, 0) scale(-1, 1)" : ""}
stroke={iconFillColor(appearance)}
strokeWidth={2}
fill="none"
>
<path d="M34 10H6M34 10L27 5M34 10L27 15" />
<path d="M27.5 5L34.5 10L27.5 15" />
</g>,
{ width: 40, height: 20, mirror: true },
),
);

@ -93,7 +93,8 @@
display: inline-block; display: inline-block;
} }
input[type="radio"] { input[type="radio"],
input[type="button"] {
opacity: 0; opacity: 0;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;

@ -90,6 +90,11 @@ const restoreElement = (
case "draw": case "draw":
case "line": case "line":
case "arrow": { case "arrow": {
const {
startArrowhead = null,
endArrowhead = element.type === "arrow" ? "arrow" : null,
} = element;
return restoreElementWithProperties(element, { return restoreElementWithProperties(element, {
startBinding: element.startBinding, startBinding: element.startBinding,
endBinding: element.endBinding, endBinding: element.endBinding,
@ -102,6 +107,8 @@ const restoreElement = (
] ]
: element.points, : element.points,
lastCommittedPoint: null, lastCommittedPoint: null,
startArrowhead,
endArrowhead,
}); });
} }
// generic elements // generic elements

@ -1,4 +1,4 @@
import { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; import { ExcalidrawElement, ExcalidrawLinearElement, Arrowhead } from "./types";
import { distance2d, rotate } from "../math"; import { distance2d, rotate } from "../math";
import rough from "roughjs/bin/rough"; import rough from "roughjs/bin/rough";
import { Drawable, Op } from "roughjs/bin/core"; import { Drawable, Op } from "roughjs/bin/core";
@ -160,24 +160,29 @@ const getLinearElementAbsoluteCoords = (
]; ];
}; };
export const getArrowPoints = ( export const getArrowheadPoints = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
shape: Drawable[], shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
) => { ) => {
const ops = getCurvePathOps(shape[0]); const ops = getCurvePathOps(shape[0]);
if (ops.length < 1) { if (ops.length < 1) {
return null; return null;
} }
const data = ops[ops.length - 1].data; // The index of the bCurve operation to examine.
const index = position === "start" ? 1 : ops.length - 1;
const data = ops[index].data;
const p3 = [data[4], data[5]] as Point; const p3 = [data[4], data[5]] as Point;
const p2 = [data[2], data[3]] as Point; const p2 = [data[2], data[3]] as Point;
const p1 = [data[0], data[1]] as Point; const p1 = [data[0], data[1]] as Point;
// we need to find p0 of the bezier curve // We need to find p0 of the bezier curve.
// it is typically the last point of the previous // It is typically the last point of the previous
// curve; it can also be the position of moveTo operation // curve; it can also be the position of moveTo operation.
const prevOp = ops[ops.length - 2]; const prevOp = ops[index - 1];
let p0: Point = [0, 0]; let p0: Point = [0, 0];
if (prevOp.op === "move") { if (prevOp.op === "move") {
p0 = (prevOp.data as unknown) as Point; p0 = (prevOp.data as unknown) as Point;
@ -192,38 +197,40 @@ export const getArrowPoints = (
3 * Math.pow(t, 2) * (1 - t) * p1[idx] + 3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3); p0[idx] * Math.pow(t, 3);
// we know the last point of the arrow // Ee know the last point of the arrow (or the first, if start arrowhead).
const [x2, y2] = p3; const [x2, y2] = position === "start" ? p0 : p3;
// by using cubic bezier equation (B(t)) and the given parameters, // By using cubic bezier equation (B(t)) and the given parameters,
// we calculate a point that is closer to the last point // we calculate a point that is closer to the last point.
// The value 0.3 is chosen arbitrarily and it works best for all // The value 0.3 is chosen arbitrarily and it works best for all
// the tested cases // the tested cases.
const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)]; const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)];
// find the normalized direction vector based on the // Find the normalized direction vector based on the
// previously calculated points // previously calculated points.
const distance = Math.hypot(x2 - x1, y2 - y1); const distance = Math.hypot(x2 - x1, y2 - y1);
const nx = (x2 - x1) / distance; const nx = (x2 - x1) / distance;
const ny = (y2 - y1) / distance; const ny = (y2 - y1) / distance;
const size = 30; // pixels const size = 30; // pixels (will differ for each arrowhead)
const arrowLength = element.points.reduce((total, [cx, cy], idx, points) => {
const length = element.points.reduce((total, [cx, cy], idx, points) => {
const [px, py] = idx > 0 ? points[idx - 1] : [0, 0]; const [px, py] = idx > 0 ? points[idx - 1] : [0, 0];
return total + Math.hypot(cx - px, cy - py); return total + Math.hypot(cx - px, cy - py);
}, 0); }, 0);
// Scale down the arrow until we hit a certain size so that it doesn't look weird // Scale down the arrowhead until we hit a certain size so that it doesn't look weird.
// This value is selected by minizing a minmum size with the whole length of the arrow // This value is selected by minimizing a minimum size with the whole length of the
// intead of last segment of the arrow // arrowhead instead of last segment of the arrowhead.
const minSize = Math.min(size, arrowLength / 2); const minSize = Math.min(size, length / 2);
const xs = x2 - nx * minSize; const xs = x2 - nx * minSize;
const ys = y2 - ny * minSize; const ys = y2 - ny * minSize;
const angle = 20; // degrees const angle = 20; // degrees
// Return points
const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180);
const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180);
return [x2, y2, x3, y3, x4, y4]; return [x2, y2, x3, y3, x4, y4];
}; };

@ -18,7 +18,7 @@ export {
getElementBounds, getElementBounds,
getCommonBounds, getCommonBounds,
getDiamondPoints, getDiamondPoints,
getArrowPoints, getArrowheadPoints,
getClosestElementBounds, getClosestElementBounds,
} from "./bounds"; } from "./bounds";

@ -8,6 +8,7 @@ import {
FontFamily, FontFamily,
GroupId, GroupId,
VerticalAlign, VerticalAlign,
Arrowhead,
} from "../element/types"; } from "../element/types";
import { measureText, getFontString } from "../utils"; import { measureText, getFontString } from "../utils";
import { randomInteger, randomId } from "../random"; import { randomInteger, randomId } from "../random";
@ -214,6 +215,8 @@ export const updateTextElement = (
export const newLinearElement = ( export const newLinearElement = (
opts: { opts: {
type: ExcalidrawLinearElement["type"]; type: ExcalidrawLinearElement["type"];
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
} & ElementConstructorOpts, } & ElementConstructorOpts,
): NonDeleted<ExcalidrawLinearElement> => { ): NonDeleted<ExcalidrawLinearElement> => {
return { return {
@ -222,6 +225,8 @@ export const newLinearElement = (
lastCommittedPoint: null, lastCommittedPoint: null,
startBinding: null, startBinding: null,
endBinding: null, endBinding: null,
startArrowhead: opts.startArrowhead,
endArrowhead: opts.endArrowhead,
}; };
}; };

@ -90,13 +90,17 @@ export type PointBinding = {
gap: number; gap: number;
}; };
export type Arrowhead = "arrow";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
type: "arrow" | "line" | "draw"; type: "line" | "draw" | "arrow";
points: readonly Point[]; points: readonly Point[];
lastCommittedPoint: Point | null; lastCommittedPoint: Point | null;
startBinding: PointBinding | null; startBinding: PointBinding | null;
endBinding: PointBinding | null; endBinding: PointBinding | null;
startArrowhead: Arrowhead | null;
endArrowhead: Arrowhead | null;
}>; }>;
export type PointerType = "mouse" | "pen" | "touch"; export type PointerType = "mouse" | "pen" | "touch";

@ -28,6 +28,9 @@
"edges": "Edges", "edges": "Edges",
"sharp": "Sharp", "sharp": "Sharp",
"round": "Round", "round": "Round",
"arrowheads": "Arrowheads",
"arrowhead_none": "None",
"arrowhead_arrow": "Arrow",
"fontSize": "Font size", "fontSize": "Font size",
"fontFamily": "Font family", "fontFamily": "Font family",
"onlySelected": "Only selected", "onlySelected": "Only selected",

@ -1,13 +1,15 @@
import { import {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
Arrowhead,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { isTextElement, isLinearElement } from "../element/typeChecks"; import { isTextElement, isLinearElement } from "../element/typeChecks";
import { import {
getDiamondPoints, getDiamondPoints,
getArrowPoints,
getElementAbsoluteCoords, getElementAbsoluteCoords,
getArrowheadPoints,
} from "../element/bounds"; } from "../element/bounds";
import { RoughCanvas } from "roughjs/bin/canvas"; import { RoughCanvas } from "roughjs/bin/canvas";
import { Drawable, Options } from "roughjs/bin/core"; import { Drawable, Options } from "roughjs/bin/core";
@ -334,22 +336,64 @@ const generateElementShape = (
// add lines only in arrow // add lines only in arrow
if (element.type === "arrow") { if (element.type === "arrow") {
const arrowPoints = getArrowPoints(element, shape); const { startArrowhead = null, endArrowhead = "arrow" } = element;
if (arrowPoints) {
const [x2, y2, x3, y3, x4, y4] = arrowPoints; function getArrowheadShapes(
// for dotted arrows caps, reduce gap to make it more legible element: ExcalidrawLinearElement,
shape: Drawable[],
position: "start" | "end",
arrowhead: Arrowhead,
) {
const arrowheadPoints = getArrowheadPoints(
element,
shape,
position,
arrowhead,
);
if (arrowheadPoints === null) {
return [];
}
// Other arrowheads here...
// Arrow arrowheads
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
if (element.strokeStyle === "dotted") { if (element.strokeStyle === "dotted") {
// for dotted arrows caps, reduce gap to make it more legible
options.strokeLineDash = [3, 4]; options.strokeLineDash = [3, 4];
// for solid/dashed, keep solid arrow cap
} else { } else {
// for solid/dashed, keep solid arrow cap
delete options.strokeLineDash; delete options.strokeLineDash;
} }
shape.push( 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),
], ];
}
if (startArrowhead !== null) {
const shapes = getArrowheadShapes(
element,
shape,
"start",
startArrowhead,
); );
shape.push(...shapes);
}
if (endArrowhead !== null) {
if (endArrowhead === undefined) {
// Hey, we have an old arrow here!
}
const shapes = getArrowheadShapes(
element,
shape,
"end",
endArrowhead,
);
shape.push(...shapes);
} }
} }
break; break;

@ -28,6 +28,8 @@ export const canChangeSharpness = (type: string) =>
export const hasText = (type: string) => type === "text"; export const hasText = (type: string) => type === "text";
export const canHaveArrowheads = (type: string) => type === "arrow";
export const getElementAtPosition = ( export const getElementAtPosition = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean, isAtPositionFn: (element: NonDeletedExcalidrawElement) => boolean,

@ -10,6 +10,7 @@ export { normalizeScroll, calculateScrollCenter } from "./scroll";
export { export {
hasBackground, hasBackground,
hasStroke, hasStroke,
canHaveArrowheads,
canChangeSharpness, canChangeSharpness,
getElementAtPosition, getElementAtPosition,
getElementContainingPosition, getElementContainingPosition,

@ -7,6 +7,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
@ -27,6 +28,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
@ -102,6 +104,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
@ -122,6 +125,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",

@ -139,6 +139,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": Object { "endBinding": Object {
"elementId": "id1", "elementId": "id1",
"focus": -0.46666666666666673, "focus": -0.46666666666666673,
@ -163,6 +164,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 401146281, "seed": 401146281,
"startArrowhead": null,
"startBinding": Object { "startBinding": Object {
"elementId": "id0", "elementId": "id0",
"focus": -0.6000000000000001, "focus": -0.6000000000000001,

@ -5,6 +5,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
@ -32,6 +33,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
@ -51,6 +53,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
@ -78,6 +81,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",

File diff suppressed because it is too large Load Diff

@ -5,6 +5,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": "arrow",
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
@ -25,6 +26,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",
@ -44,6 +46,7 @@ Object {
"angle": 0, "angle": 0,
"backgroundColor": "transparent", "backgroundColor": "transparent",
"boundElementIds": null, "boundElementIds": null,
"endArrowhead": null,
"endBinding": null, "endBinding": null,
"fillStyle": "hachure", "fillStyle": "hachure",
"groupIds": Array [], "groupIds": Array [],
@ -64,6 +67,7 @@ Object {
], ],
"roughness": 1, "roughness": 1,
"seed": 337897, "seed": 337897,
"startArrowhead": null,
"startBinding": null, "startBinding": null,
"strokeColor": "#000000", "strokeColor": "#000000",
"strokeSharpness": "round", "strokeSharpness": "round",

@ -130,6 +130,8 @@ export class API {
case "draw": case "draw":
element = newLinearElement({ element = newLinearElement({
type: type as "arrow" | "line" | "draw", type: type as "arrow" | "line" | "draw",
startArrowhead: null,
endArrowhead: null,
...base, ...base,
}); });
break; break;

@ -8,6 +8,7 @@ import {
FontFamily, FontFamily,
GroupId, GroupId,
ExcalidrawBindableElement, ExcalidrawBindableElement,
Arrowhead,
} from "./element/types"; } from "./element/types";
import { SHAPES } from "./shapes"; import { SHAPES } from "./shapes";
import { Point as RoughPoint } from "roughjs/bin/geometry"; import { Point as RoughPoint } from "roughjs/bin/geometry";
@ -60,6 +61,10 @@ export type AppState = {
currentItemFontSize: number; currentItemFontSize: number;
currentItemTextAlign: TextAlign; currentItemTextAlign: TextAlign;
currentItemStrokeSharpness: ExcalidrawElement["strokeSharpness"]; currentItemStrokeSharpness: ExcalidrawElement["strokeSharpness"];
currentItemArrowheads: {
start: Arrowhead | null;
end: Arrowhead | null;
};
currentItemLinearStrokeSharpness: ExcalidrawElement["strokeSharpness"]; currentItemLinearStrokeSharpness: ExcalidrawElement["strokeSharpness"];
viewBackgroundColor: string; viewBackgroundColor: string;
scrollX: FlooredNumber; scrollX: FlooredNumber;