Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46c6916e1d | |||
| bec662eaf6 | |||
| 068edfc1cd | |||
| 4672bb948c | |||
| 49fbad32ac | |||
| 2ac55067cd | |||
| 78ab12c7e6 | |||
| f2f8219917 | |||
| 12c39d1034 | |||
| d33e42e3a1 | |||
| 3b9ffd9586 | |||
| b63689c230 | |||
| c84babf574 | |||
| 36274f1f3e | |||
| 24858c9ace | |||
| fdbf316cc1 | |||
| eec3f67d72 | |||
| 0cbf244bd8 | |||
| 795511ee6b |
@@ -53,6 +53,9 @@ import {
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
ArrowheadCrowfootIcon,
|
||||
ArrowheadCrowfootOneIcon,
|
||||
ArrowheadCrowfootOneOrManyIcon,
|
||||
} from "../components/icons";
|
||||
import {
|
||||
ARROW_TYPE,
|
||||
@@ -1405,59 +1408,65 @@ const getArrowheadOptions = (flip: boolean) => {
|
||||
keyBinding: "w",
|
||||
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",
|
||||
text: t("labels.arrowhead_triangle"),
|
||||
icon: <ArrowheadTriangleIcon flip={flip} />,
|
||||
keyBinding: "t",
|
||||
keyBinding: "e",
|
||||
},
|
||||
{
|
||||
value: "triangle_outline",
|
||||
text: t("labels.arrowhead_triangle_outline"),
|
||||
icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
showInPicker: false,
|
||||
keyBinding: "r",
|
||||
},
|
||||
{
|
||||
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",
|
||||
text: t("labels.arrowhead_diamond"),
|
||||
icon: <ArrowheadDiamondIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
showInPicker: false,
|
||||
keyBinding: "d",
|
||||
},
|
||||
{
|
||||
value: "diamond_outline",
|
||||
text: t("labels.arrowhead_diamond_outline"),
|
||||
icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
|
||||
keyBinding: null,
|
||||
showInPicker: false,
|
||||
keyBinding: "f",
|
||||
},
|
||||
{
|
||||
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;
|
||||
};
|
||||
@@ -1521,6 +1530,7 @@ export const actionChangeArrowhead = register({
|
||||
appState.currentItemStartArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "start", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
<IconPicker
|
||||
label="arrowhead_end"
|
||||
@@ -1537,6 +1547,7 @@ export const actionChangeArrowhead = register({
|
||||
appState.currentItemEndArrowhead,
|
||||
)}
|
||||
onChange={(value) => updateData({ position: "end", type: value })}
|
||||
numberOfOptionsToAlwaysShow={4}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -233,7 +233,7 @@ import {
|
||||
findShapeByKey,
|
||||
getBoundTextShape,
|
||||
getCornerRadius,
|
||||
getElementShape,
|
||||
getElementShapes,
|
||||
isPathALoop,
|
||||
} from "../shapes";
|
||||
import { getSelectionBoxShape } from "../../utils/geometry/shape";
|
||||
@@ -5009,7 +5009,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x,
|
||||
y,
|
||||
element: elementWithHighestZIndex,
|
||||
shape: getElementShape(
|
||||
shapes: getElementShapes(
|
||||
elementWithHighestZIndex,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
@@ -5121,7 +5121,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape: getElementShape(element, this.scene.getNonDeletedElementsMap()),
|
||||
shapes: getElementShapes(element, this.scene.getNonDeletedElementsMap()),
|
||||
threshold: this.getElementHitThreshold(),
|
||||
frameNameBound: isFrameLikeElement(element)
|
||||
? this.frameNameBoundsCache.get(element)
|
||||
@@ -5153,7 +5153,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x,
|
||||
y,
|
||||
element: elements[index],
|
||||
shape: getElementShape(
|
||||
shapes: getElementShapes(
|
||||
elements[index],
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
@@ -5437,7 +5437,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: sceneX,
|
||||
y: sceneY,
|
||||
element: container,
|
||||
shape: getElementShape(
|
||||
shapes: getElementShapes(
|
||||
container,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
@@ -6211,7 +6211,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: scenePointerX,
|
||||
y: scenePointerY,
|
||||
element,
|
||||
shape: getElementShape(
|
||||
shapes: getElementShapes(
|
||||
element,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
@@ -9344,7 +9344,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
x: pointerDownState.origin.x,
|
||||
y: pointerDownState.origin.y,
|
||||
element: hitElement,
|
||||
shape: getElementShape(
|
||||
shapes: getElementShapes(
|
||||
hitElement,
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
top: var(--editor-container-padding);
|
||||
right: var(--editor-container-padding);
|
||||
bottom: var(--editor-container-padding);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top.zen-mode {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T> = {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
};
|
||||
|
||||
function Picker<T>({
|
||||
options,
|
||||
@@ -12,30 +25,16 @@ function Picker<T>({
|
||||
label,
|
||||
onChange,
|
||||
onClose,
|
||||
numberOfOptionsToAlwaysShow = options.length,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: {
|
||||
value: T;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
keyBinding: string | null;
|
||||
}[];
|
||||
options: readonly Option<T>[];
|
||||
onChange: (value: T) => void;
|
||||
onClose: () => void;
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
}) {
|
||||
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 device = useDevice();
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = options.find(
|
||||
@@ -44,28 +43,19 @@ function Picker<T>({
|
||||
|
||||
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<T>({
|
||||
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<T>({
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`picker`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={label}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div className="picker-content" ref={rGallery}>
|
||||
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 (
|
||||
<div className="picker-content">
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
type="button"
|
||||
@@ -113,7 +124,6 @@ function Picker<T>({
|
||||
active: value === option.value,
|
||||
})}
|
||||
onClick={(event) => {
|
||||
(event.currentTarget as HTMLButtonElement).focus();
|
||||
onChange(option.value);
|
||||
}}
|
||||
title={`${option.text} ${
|
||||
@@ -122,16 +132,13 @@ function Picker<T>({
|
||||
aria-label={option.text || "none"}
|
||||
aria-keyshortcuts={option.keyBinding || undefined}
|
||||
key={option.text}
|
||||
ref={(el) => {
|
||||
if (el && i === 0) {
|
||||
rFirstItem.current = el;
|
||||
ref={(ref) => {
|
||||
if (value === option.value) {
|
||||
// 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}
|
||||
@@ -141,7 +148,43 @@ function Picker<T>({
|
||||
</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>
|
||||
</Popover.Content>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,6 +194,7 @@ export function IconPicker<T>({
|
||||
options,
|
||||
onChange,
|
||||
group = "",
|
||||
numberOfOptionsToAlwaysShow,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
@@ -159,51 +203,40 @@ export function IconPicker<T>({
|
||||
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<any>(null);
|
||||
const isRTL = getLanguage().rtl;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
name={group}
|
||||
type="button"
|
||||
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.filter((opt) => opt.showInPicker !== false)}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
rPickerButton.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
<div className="picker-triangle" />
|
||||
</>
|
||||
) : null}
|
||||
</React.Suspense>
|
||||
<Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
|
||||
<Popover.Trigger
|
||||
name={group}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
ref={rPickerButton}
|
||||
className={isActive ? "active" : ""}
|
||||
>
|
||||
{options.find((option) => option.value === value)?.icon}
|
||||
</Popover.Trigger>
|
||||
{isActive && (
|
||||
<Picker
|
||||
options={options}
|
||||
value={value}
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
onClose={() => {
|
||||
setActive(false);
|
||||
}}
|
||||
numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<g clipPath="url(#a)">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
|
||||
import type { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError, ImageSceneDataError } from "../errors";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { decodeSvgBase64Payload } from "../scene/export";
|
||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||
import type { ValueOf } from "../utility-types";
|
||||
import { bytesToHexString, isPromiseLike } from "../utils";
|
||||
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||
}
|
||||
if (blob.type === MIME_TYPES.svg) {
|
||||
try {
|
||||
return (await import("./image")).decodeSvgMetadata({
|
||||
return decodeSvgBase64Payload({
|
||||
svg: contents,
|
||||
});
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -82,6 +82,7 @@ export const fileSave = (
|
||||
name: string;
|
||||
/** file extension */
|
||||
extension: FILE_EXTENSION;
|
||||
mimeTypes?: string[];
|
||||
description: string;
|
||||
/** existing FileSystemHandle */
|
||||
fileHandle?: FileSystemHandle | null;
|
||||
@@ -93,10 +94,11 @@ export const fileSave = (
|
||||
fileName: `${opts.name}.${opts.extension}`,
|
||||
description: opts.description,
|
||||
extensions: [`.${opts.extension}`],
|
||||
mimeTypes: opts.mimeTypes,
|
||||
},
|
||||
opts.fileHandle,
|
||||
);
|
||||
};
|
||||
|
||||
export type { FileSystemHandle };
|
||||
export { nativeFileSystemSupported };
|
||||
export type { FileSystemHandle };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import decodePng from "png-chunks-extract";
|
||||
import tEXt from "png-chunk-text";
|
||||
import encodePng from "png-chunks-encode";
|
||||
import { stringToBase64, encode, decode, base64ToString } from "./encode";
|
||||
import { encode, decode } from "./encode";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import { blobToArrayBuffer } from "./blob";
|
||||
|
||||
@@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
|
||||
}
|
||||
throw new Error("INVALID");
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// SVG
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const encodeSvgMetadata = ({ text }: { text: string }) => {
|
||||
const base64 = stringToBase64(
|
||||
JSON.stringify(encode({ text })),
|
||||
true /* is already byte string */,
|
||||
);
|
||||
|
||||
let metadata = "";
|
||||
metadata += `<!-- payload-type:${MIME_TYPES.excalidraw} -->`;
|
||||
metadata += `<!-- payload-version:2 -->`;
|
||||
metadata += "<!-- payload-start -->";
|
||||
metadata += base64;
|
||||
metadata += "<!-- payload-end -->";
|
||||
return metadata;
|
||||
};
|
||||
|
||||
export const decodeSvgMetadata = ({ svg }: { svg: string }) => {
|
||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||
const match = svg.match(
|
||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error("INVALID");
|
||||
}
|
||||
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
|
||||
const version = versionMatch?.[1] || "1";
|
||||
const isByteString = version !== "1";
|
||||
|
||||
try {
|
||||
const json = base64ToString(match[1], isByteString);
|
||||
const encodedData = JSON.parse(json);
|
||||
if (!("encoded" in encodedData)) {
|
||||
// legacy, un-encoded scene JSON
|
||||
if (
|
||||
"type" in encodedData &&
|
||||
encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
||||
) {
|
||||
return json;
|
||||
}
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
return decode(encodedData);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
}
|
||||
throw new Error("INVALID");
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
DEFAULT_FILENAME,
|
||||
IMAGE_MIME_TYPES,
|
||||
isFirefox,
|
||||
MIME_TYPES,
|
||||
} from "../constants";
|
||||
@@ -15,8 +16,9 @@ import type {
|
||||
ExcalidrawFrameLikeElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { getElementsOverlappingFrame } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected, getSelectedElements } from "../scene";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas, exportToSvg } from "../scene/export";
|
||||
import type { ExportType } from "../scene/types";
|
||||
import type { AppState, BinaryFiles } from "../types";
|
||||
@@ -25,7 +27,6 @@ import { canvasToBlob } from "./blob";
|
||||
import type { FileSystemHandle } from "./filesystem";
|
||||
import { fileSave } from "./filesystem";
|
||||
import { serializeAsJSON } from "./json";
|
||||
import { getElementsOverlappingFrame } from "../frame";
|
||||
|
||||
export { loadFromBlob } from "./blob";
|
||||
export { loadFromJSON, saveAsJSON } from "./json";
|
||||
@@ -130,6 +131,7 @@ export const exportCanvas = async (
|
||||
description: "Export to SVG",
|
||||
name,
|
||||
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
|
||||
mimeTypes: [IMAGE_MIME_TYPES.svg],
|
||||
fileHandle,
|
||||
},
|
||||
);
|
||||
@@ -168,9 +170,8 @@ export const exportCanvas = async (
|
||||
return fileSave(blob, {
|
||||
description: "Export to PNG",
|
||||
name,
|
||||
// FIXME reintroduce `excalidraw.png` when most people upgrade away
|
||||
// from 111.0.5563.64 (arm64), see #6349
|
||||
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
|
||||
extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
|
||||
mimeTypes: [IMAGE_MIME_TYPES.png],
|
||||
fileHandle,
|
||||
});
|
||||
} else if (type === "clipboard") {
|
||||
|
||||
@@ -35,6 +35,9 @@ import type { MaybePromise } from "../utility-types";
|
||||
import { Emitter } from "../emitter";
|
||||
import { Queue } from "../queue";
|
||||
import { hashElementsVersion, hashString } from "../element";
|
||||
import { toValidURL } from "./url";
|
||||
|
||||
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
|
||||
|
||||
type LibraryUpdate = {
|
||||
/** deleted library items since last onLibraryChange event */
|
||||
@@ -467,6 +470,28 @@ export const distributeLibraryItemsOnSquareGrid = (
|
||||
return resElements;
|
||||
};
|
||||
|
||||
const validateLibraryUrl = (
|
||||
libraryUrl: string,
|
||||
/**
|
||||
* If supplied, takes precedence over the default whitelist.
|
||||
* Return `true` if the URL is valid.
|
||||
*/
|
||||
validator?: (libraryUrl: string) => boolean,
|
||||
): boolean => {
|
||||
if (
|
||||
validator
|
||||
? validator(libraryUrl)
|
||||
: ALLOWED_LIBRARY_HOSTNAMES.includes(
|
||||
new URL(libraryUrl).hostname.split(".").slice(-2).join("."),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(`Invalid or disallowed library URL: "${libraryUrl}"`);
|
||||
throw new Error("Invalid or disallowed library URL");
|
||||
};
|
||||
|
||||
export const parseLibraryTokensFromUrl = () => {
|
||||
const libraryUrl =
|
||||
// current
|
||||
@@ -608,6 +633,11 @@ const persistLibraryUpdate = async (
|
||||
export const useHandleLibrary = (
|
||||
opts: {
|
||||
excalidrawAPI: ExcalidrawImperativeAPI | null;
|
||||
/**
|
||||
* Return `true` if the library install url should be allowed.
|
||||
* If not supplied, only the excalidraw.com base domain is allowed.
|
||||
*/
|
||||
validateLibraryUrl?: (libraryUrl: string) => boolean;
|
||||
} & (
|
||||
| {
|
||||
/** @deprecated we recommend using `opts.adapter` instead */
|
||||
@@ -650,7 +680,13 @@ export const useHandleLibrary = (
|
||||
}) => {
|
||||
const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
|
||||
try {
|
||||
const request = await fetch(decodeURIComponent(libraryUrl));
|
||||
libraryUrl = decodeURIComponent(libraryUrl);
|
||||
|
||||
libraryUrl = toValidURL(libraryUrl);
|
||||
|
||||
validateLibraryUrl(libraryUrl, optsRef.current.validateLibraryUrl);
|
||||
|
||||
const request = await fetch(libraryUrl);
|
||||
const blob = await request.blob();
|
||||
resolve(blob);
|
||||
} catch (error: any) {
|
||||
@@ -678,7 +714,12 @@ export const useHandleLibrary = (
|
||||
defaultStatus: "published",
|
||||
openLibraryMenu: true,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
excalidrawAPI.updateScene({
|
||||
appState: {
|
||||
errorMessage: error.message,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
|
||||
|
||||
@@ -25,6 +25,7 @@ describe("normalizeLink", () => {
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
export const sanitizeHTMLAttribute = (html: string) => {
|
||||
return html.replace(/"/g, """);
|
||||
};
|
||||
import { sanitizeHTMLAttribute } from "../utils";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isRectangularElement,
|
||||
isTextElement,
|
||||
@@ -51,7 +52,7 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { aabbForElement, getElementShape, pointInsideBounds } from "../shapes";
|
||||
import { aabbForElement, getElementShapes, pointInsideBounds } from "../shapes";
|
||||
import {
|
||||
compareHeading,
|
||||
HEADING_DOWN,
|
||||
@@ -575,7 +576,7 @@ export const getHoveredElementForBinding = (
|
||||
zoom,
|
||||
// disable fullshape snapping for frame elements so we
|
||||
// can bind to frame children
|
||||
fullShape,
|
||||
fullShape && !isFrameLikeElement(element),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1405,9 +1406,9 @@ export const bindingBorderTest = (
|
||||
): boolean => {
|
||||
const threshold = maxBindingGap(element, element.width, element.height, zoom);
|
||||
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
const shapes = getElementShapes(element, elementsMap);
|
||||
return (
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold) ||
|
||||
shapes.some((shape) => isPointOnShape(pointFrom(x, y), shape, threshold)) ||
|
||||
(fullShape === true &&
|
||||
pointInsideBounds(pointFrom(x, y), aabbForElement(element)))
|
||||
);
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ElementsMap,
|
||||
} from "./types";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import type { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import type { Drawable, Op } from "roughjs/bin/core";
|
||||
import type { AppState } from "../types";
|
||||
import { generateRoughOptions } from "../scene/Shape";
|
||||
@@ -24,13 +23,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { ShapeCache } from "../scene/ShapeCache";
|
||||
import { arrayToMap, invariant } from "../utils";
|
||||
import type {
|
||||
Degrees,
|
||||
GlobalPoint,
|
||||
LineSegment,
|
||||
LocalPoint,
|
||||
Radians,
|
||||
} from "../../math";
|
||||
import type { Degrees, GlobalPoint, LineSegment, Radians } from "../../math";
|
||||
import {
|
||||
degreesToRadians,
|
||||
lineSegment,
|
||||
@@ -39,7 +32,6 @@ import {
|
||||
pointFromArray,
|
||||
pointRotateRads,
|
||||
} from "../../math";
|
||||
import type { Mutable } from "../utility-types";
|
||||
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
@@ -556,6 +548,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 +665,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),
|
||||
@@ -713,36 +724,12 @@ export const getArrowheadPoints = (
|
||||
return [x2, y2, x3, y3, x4, y4];
|
||||
};
|
||||
|
||||
const generateLinearElementShape = (
|
||||
element: ExcalidrawLinearElement,
|
||||
): Drawable => {
|
||||
const generator = rough.generator();
|
||||
const options = generateRoughOptions(element);
|
||||
|
||||
const method = (() => {
|
||||
if (element.roundness) {
|
||||
return "curve";
|
||||
}
|
||||
if (options.fill) {
|
||||
return "polygon";
|
||||
}
|
||||
return "linearPath";
|
||||
})();
|
||||
|
||||
return generator[method](
|
||||
element.points as Mutable<LocalPoint>[] as RoughPoint[],
|
||||
options,
|
||||
);
|
||||
};
|
||||
|
||||
const getLinearElementRotatedBounds = (
|
||||
element: ExcalidrawLinearElement,
|
||||
cx: number,
|
||||
cy: number,
|
||||
elementsMap: ElementsMap,
|
||||
): Bounds => {
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (element.points.length < 2) {
|
||||
const [pointX, pointY] = element.points[0];
|
||||
const [x, y] = pointRotateRads(
|
||||
@@ -752,6 +739,7 @@ const getLinearElementRotatedBounds = (
|
||||
);
|
||||
|
||||
let coords: Bounds = [x, y, x, y];
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
element,
|
||||
@@ -769,18 +757,48 @@ const getLinearElementRotatedBounds = (
|
||||
return coords;
|
||||
}
|
||||
|
||||
// first element is always the curve
|
||||
const cachedShape = ShapeCache.get(element)?.[0];
|
||||
const shape = cachedShape ?? generateLinearElementShape(element);
|
||||
const ops = getCurvePathOps(shape);
|
||||
const cachedShape =
|
||||
ShapeCache.get(element) ?? ShapeCache.generateElementShape(element, null);
|
||||
|
||||
const [arrowCurve, ...arrowhead] = cachedShape;
|
||||
|
||||
const transformXY = ([x, y]: GlobalPoint) =>
|
||||
pointRotateRads<GlobalPoint>(
|
||||
pointFrom(element.x + x, element.y + y),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
const res = getMinMaxXYFromCurvePathOps(ops, transformXY);
|
||||
let coords: Bounds = [res[0], res[1], res[2], res[3]];
|
||||
|
||||
let coords = getMinMaxXYFromCurvePathOps(
|
||||
getCurvePathOps(arrowCurve),
|
||||
transformXY,
|
||||
);
|
||||
|
||||
for (const shape of arrowhead) {
|
||||
let [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(
|
||||
getCurvePathOps(shape),
|
||||
);
|
||||
|
||||
[minX, minY] = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(minX + element.x, minY + element.y),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
[maxX, maxY] = pointRotateRads<GlobalPoint>(
|
||||
pointFrom(maxX + element.x, maxY + element.y),
|
||||
pointFrom(cx, cy),
|
||||
element.angle,
|
||||
);
|
||||
|
||||
coords = [
|
||||
Math.min(minX, coords[0]),
|
||||
Math.min(minY, coords[1]),
|
||||
Math.max(maxX, coords[2]),
|
||||
Math.max(maxY, coords[3]),
|
||||
];
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText(
|
||||
element,
|
||||
@@ -795,6 +813,7 @@ const getLinearElementRotatedBounds = (
|
||||
coordsWithBoundText[3],
|
||||
];
|
||||
}
|
||||
|
||||
return coords;
|
||||
};
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ export type HitTestArgs<Point extends GlobalPoint | LocalPoint> = {
|
||||
x: number;
|
||||
y: number;
|
||||
element: ExcalidrawElement;
|
||||
shape: GeometricShape<Point>;
|
||||
shapes: GeometricShape<Point>[];
|
||||
threshold?: number;
|
||||
frameNameBound?: FrameNameBounds | null;
|
||||
};
|
||||
@@ -54,16 +54,18 @@ export const hitElementItself = <Point extends GlobalPoint | LocalPoint>({
|
||||
x,
|
||||
y,
|
||||
element,
|
||||
shape,
|
||||
shapes,
|
||||
threshold = 10,
|
||||
frameNameBound = null,
|
||||
}: HitTestArgs<Point>) => {
|
||||
let hit = shouldTestInside(element)
|
||||
? // Since `inShape` tests STRICTLY againt the insides of a shape
|
||||
// we would need `onShape` as well to include the "borders"
|
||||
isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold);
|
||||
const testInside = shouldTestInside(element);
|
||||
|
||||
let hit = shapes.some((shape) =>
|
||||
testInside || shape.isClosed
|
||||
? isPointInShape(pointFrom(x, y), shape) ||
|
||||
isPointOnShape(pointFrom(x, y), shape, threshold)
|
||||
: isPointOnShape(pointFrom(x, y), shape, threshold),
|
||||
);
|
||||
|
||||
// hit test against a frame's name
|
||||
if (!hit && frameNameBound) {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import type { ExcalidrawProps } from "../types";
|
||||
import { getFontString, updateActiveTool } from "../utils";
|
||||
import {
|
||||
getFontString,
|
||||
sanitizeHTMLAttribute,
|
||||
updateActiveTool,
|
||||
} from "../utils";
|
||||
import { setCursorForShape } from "../cursor";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { wrapText } from "./textWrapping";
|
||||
@@ -11,7 +15,6 @@ import type {
|
||||
ExcalidrawIframeLikeElement,
|
||||
IframeData,
|
||||
} from "./types";
|
||||
import { sanitizeHTMLAttribute } from "../data/url";
|
||||
import type { MarkRequired } from "../utility-types";
|
||||
import { StoreAction } from "../store";
|
||||
|
||||
|
||||
@@ -1720,10 +1720,10 @@ export class LinearElementEditor {
|
||||
includeBoundText: boolean = false,
|
||||
): [number, number, number, number, number, number] => {
|
||||
let coords: [number, number, number, number, number, number];
|
||||
let x1;
|
||||
let y1;
|
||||
let x2;
|
||||
let y2;
|
||||
let x1 = Infinity;
|
||||
let y1 = Infinity;
|
||||
let x2 = -Infinity;
|
||||
let y2 = -Infinity;
|
||||
if (element.points.length < 2 || !ShapeCache.get(element)) {
|
||||
// XXX this is just a poor estimate and not very useful
|
||||
const { minX, minY, maxX, maxY } = element.points.reduce(
|
||||
@@ -1745,14 +1745,15 @@ export class LinearElementEditor {
|
||||
} else {
|
||||
const shape = ShapeCache.generateElementShape(element, null);
|
||||
|
||||
// first element is always the curve
|
||||
const ops = getCurvePathOps(shape[0]);
|
||||
for (const s of shape) {
|
||||
const ops = getCurvePathOps(s);
|
||||
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
x1 = minX + element.x;
|
||||
y1 = minY + element.y;
|
||||
x2 = maxX + element.x;
|
||||
y2 = maxY + element.y;
|
||||
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
|
||||
x1 = Math.min(minX + element.x, x1);
|
||||
y1 = Math.min(minY + element.y, y1);
|
||||
x2 = Math.max(maxX + element.x, x2);
|
||||
y2 = Math.max(maxY + element.y, y2);
|
||||
}
|
||||
}
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -449,7 +449,7 @@ const renderElementToSvg = (
|
||||
|
||||
symbol.appendChild(image);
|
||||
|
||||
root.prepend(symbol);
|
||||
(root.querySelector("defs") || root).prepend(symbol);
|
||||
}
|
||||
|
||||
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
: []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
SVG_NS,
|
||||
THEME,
|
||||
THEME_FILTER,
|
||||
MIME_TYPES,
|
||||
EXPORT_DATA_TYPES,
|
||||
} from "../constants";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
@@ -39,8 +41,7 @@ import type { RenderableElementsMap } from "./types";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { renderStaticScene } from "../renderer/staticScene";
|
||||
import { Fonts } from "../fonts";
|
||||
|
||||
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
|
||||
|
||||
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
|
||||
if (element.width <= maxWidth) {
|
||||
@@ -254,6 +255,13 @@ export const exportToCanvas = async (
|
||||
return canvas;
|
||||
};
|
||||
|
||||
const createHTMLComment = (text: string) => {
|
||||
// surrounding with spaces to maintain prettified consistency with previous
|
||||
// iterations
|
||||
// <!-- comment -->
|
||||
return document.createComment(` ${text} `);
|
||||
};
|
||||
|
||||
export const exportToSvg = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: {
|
||||
@@ -302,31 +310,20 @@ export const exportToSvg = async (
|
||||
exportPadding = 0;
|
||||
}
|
||||
|
||||
let metadata = "";
|
||||
|
||||
// we need to serialize the "original" elements before we put them through
|
||||
// the tempScene hack which duplicates and regenerates ids
|
||||
if (exportEmbedScene) {
|
||||
try {
|
||||
metadata = (await import("../data/image")).encodeSvgMetadata({
|
||||
// when embedding scene, we want to embed the origionally supplied
|
||||
// elements which don't contain the temp frame labels.
|
||||
// But it also requires that the exportToSvg is being supplied with
|
||||
// only the elements that we're exporting, and no extra.
|
||||
text: serializeAsJSON(elements, appState, files || {}, "local"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const [minX, minY, width, height] = getCanvasSize(
|
||||
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
|
||||
exportPadding,
|
||||
);
|
||||
|
||||
// initialize SVG root
|
||||
const offsetX = -minX + exportPadding;
|
||||
const offsetY = -minY + exportPadding;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// initialize SVG root element
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const svgRoot = document.createElementNS(SVG_NS, "svg");
|
||||
|
||||
svgRoot.setAttribute("version", "1.1");
|
||||
svgRoot.setAttribute("xmlns", SVG_NS);
|
||||
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
|
||||
@@ -336,53 +333,105 @@ export const exportToSvg = async (
|
||||
svgRoot.setAttribute("filter", THEME_FILTER);
|
||||
}
|
||||
|
||||
const offsetX = -minX + exportPadding;
|
||||
const offsetY = -minY + exportPadding;
|
||||
const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
|
||||
|
||||
const metadataElement = svgRoot.ownerDocument.createElementNS(
|
||||
SVG_NS,
|
||||
"metadata",
|
||||
);
|
||||
|
||||
svgRoot.appendChild(createHTMLComment("svg-source:excalidraw"));
|
||||
svgRoot.appendChild(metadataElement);
|
||||
svgRoot.appendChild(defsElement);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scene embed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// we need to serialize the "original" elements before we put them through
|
||||
// the tempScene hack which duplicates and regenerates ids
|
||||
if (exportEmbedScene) {
|
||||
try {
|
||||
encodeSvgBase64Payload({
|
||||
metadataElement,
|
||||
// when embedding scene, we want to embed the origionally supplied
|
||||
// elements which don't contain the temp frame labels.
|
||||
// But it also requires that the exportToSvg is being supplied with
|
||||
// only the elements that we're exporting, and no extra.
|
||||
payload: serializeAsJSON(elements, appState, files || {}, "local"),
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// frame clip paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const frameElements = getFrameLikeElements(elements);
|
||||
|
||||
let exportingFrameClipPath = "";
|
||||
const elementsMap = arrayToMap(elements);
|
||||
for (const frame of frameElements) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||
if (frameElements.length) {
|
||||
const elementsMap = arrayToMap(elements);
|
||||
|
||||
exportingFrameClipPath += `<clipPath id=${frame.id}>
|
||||
<rect transform="translate(${frame.x + offsetX} ${
|
||||
frame.y + offsetY
|
||||
}) rotate(${frame.angle} ${cx} ${cy})"
|
||||
width="${frame.width}"
|
||||
height="${frame.height}"
|
||||
${
|
||||
exportingFrame
|
||||
? ""
|
||||
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
|
||||
}
|
||||
>
|
||||
</rect>
|
||||
</clipPath>`;
|
||||
for (const frame of frameElements) {
|
||||
const clipPath = svgRoot.ownerDocument.createElementNS(
|
||||
SVG_NS,
|
||||
"clipPath",
|
||||
);
|
||||
|
||||
clipPath.setAttribute("id", frame.id);
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
|
||||
const cx = (x2 - x1) / 2 - (frame.x - x1);
|
||||
const cy = (y2 - y1) / 2 - (frame.y - y1);
|
||||
|
||||
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute(
|
||||
"transform",
|
||||
`translate(${frame.x + offsetX} ${frame.y + offsetY}) rotate(${
|
||||
frame.angle
|
||||
} ${cx} ${cy})`,
|
||||
);
|
||||
rect.setAttribute("width", `${frame.width}`);
|
||||
rect.setAttribute("height", `${frame.height}`);
|
||||
|
||||
if (!exportingFrame) {
|
||||
rect.setAttribute("rx", `${FRAME_STYLE.radius}`);
|
||||
rect.setAttribute("ry", `${FRAME_STYLE.radius}`);
|
||||
}
|
||||
|
||||
clipPath.appendChild(rect);
|
||||
|
||||
defsElement.appendChild(clipPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// inline font faces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fontFaces = !opts?.skipInliningFonts
|
||||
? await Fonts.generateFontFaceDeclarations(elements)
|
||||
: [];
|
||||
|
||||
const delimiter = "\n "; // 6 spaces
|
||||
|
||||
svgRoot.innerHTML = `
|
||||
${SVG_EXPORT_TAG}
|
||||
${metadata}
|
||||
<defs>
|
||||
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)}
|
||||
</style>
|
||||
${exportingFrameClipPath}
|
||||
</defs>
|
||||
`;
|
||||
const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
|
||||
style.classList.add("style-fonts");
|
||||
style.appendChild(
|
||||
document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
|
||||
);
|
||||
|
||||
defsElement.appendChild(style);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// background
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// render background rect
|
||||
if (appState.exportBackground && viewBackgroundColor) {
|
||||
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
||||
const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("x", "0");
|
||||
rect.setAttribute("y", "0");
|
||||
rect.setAttribute("width", `${width}`);
|
||||
@@ -391,6 +440,10 @@ export const exportToSvg = async (
|
||||
svgRoot.appendChild(rect);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// render elements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const rsvg = rough.svg(svgRoot);
|
||||
|
||||
const renderEmbeddables = opts?.renderEmbeddables ?? false;
|
||||
@@ -420,9 +473,66 @@ export const exportToSvg = async (
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
return svgRoot;
|
||||
};
|
||||
|
||||
export const encodeSvgBase64Payload = ({
|
||||
payload,
|
||||
metadataElement,
|
||||
}: {
|
||||
payload: string;
|
||||
metadataElement: SVGMetadataElement;
|
||||
}) => {
|
||||
const base64 = stringToBase64(
|
||||
JSON.stringify(encode({ text: payload })),
|
||||
true /* is already byte string */,
|
||||
);
|
||||
|
||||
metadataElement.appendChild(
|
||||
createHTMLComment(`payload-type:${MIME_TYPES.excalidraw}`),
|
||||
);
|
||||
metadataElement.appendChild(createHTMLComment("payload-version:2"));
|
||||
metadataElement.appendChild(createHTMLComment("payload-start"));
|
||||
metadataElement.appendChild(document.createTextNode(base64));
|
||||
metadataElement.appendChild(createHTMLComment("payload-end"));
|
||||
};
|
||||
|
||||
export const decodeSvgBase64Payload = ({ svg }: { svg: string }) => {
|
||||
if (svg.includes(`payload-type:${MIME_TYPES.excalidraw}`)) {
|
||||
const match = svg.match(
|
||||
/<!-- payload-start -->\s*(.+?)\s*<!-- payload-end -->/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error("INVALID");
|
||||
}
|
||||
const versionMatch = svg.match(/<!-- payload-version:(\d+) -->/);
|
||||
const version = versionMatch?.[1] || "1";
|
||||
const isByteString = version !== "1";
|
||||
|
||||
try {
|
||||
const json = base64ToString(match[1], isByteString);
|
||||
const encodedData = JSON.parse(json);
|
||||
if (!("encoded" in encodedData)) {
|
||||
// legacy, un-encoded scene JSON
|
||||
if (
|
||||
"type" in encodedData &&
|
||||
encodedData.type === EXPORT_DATA_TYPES.excalidraw
|
||||
) {
|
||||
return json;
|
||||
}
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
return decode(encodedData);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
throw new Error("FAILED");
|
||||
}
|
||||
}
|
||||
throw new Error("INVALID");
|
||||
};
|
||||
|
||||
// calculate smallest area to fit the contents in
|
||||
const getCanvasSize = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
|
||||
+103
-36
@@ -1,3 +1,4 @@
|
||||
import { pointsOnBezierCurves } from "points-on-curve";
|
||||
import {
|
||||
isPoint,
|
||||
pointFrom,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
polygonFromPoints,
|
||||
} from "../math";
|
||||
import {
|
||||
getClosedCurveShape,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
getCurveShape,
|
||||
getEllipseShape,
|
||||
getFreedrawShape,
|
||||
getPointsOnRoughCurve,
|
||||
getPolygonShape,
|
||||
type GeometricShape,
|
||||
} from "../utils/geometry/shape";
|
||||
@@ -141,10 +144,10 @@ export const findShapeByKey = (key: string) => {
|
||||
* get the pure geometric shape of an excalidraw element
|
||||
* which is then used for hit detection
|
||||
*/
|
||||
export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
export const getElementShapes = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
): GeometricShape<Point> => {
|
||||
): GeometricShape<Point>[] => {
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
@@ -155,40 +158,102 @@ export const getElementShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
case "iframe":
|
||||
case "text":
|
||||
case "selection":
|
||||
return getPolygonShape(element);
|
||||
return [getPolygonShape(element)];
|
||||
case "arrow":
|
||||
case "line": {
|
||||
const roughShape =
|
||||
ShapeCache.get(element)?.[0] ??
|
||||
ShapeCache.generateElementShape(element, null)[0];
|
||||
const [curve, ...arrowheads] =
|
||||
ShapeCache.get(element) ??
|
||||
ShapeCache.generateElementShape(element, null);
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
const center = pointFrom<Point>(cx, cy);
|
||||
const startingPoint = pointFrom<Point>(element.x, element.y);
|
||||
|
||||
return shouldTestInside(element)
|
||||
? getClosedCurveShape<Point>(
|
||||
if (shouldTestInside(element)) {
|
||||
return [
|
||||
getClosedCurveShape<Point>(
|
||||
element,
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
curve,
|
||||
startingPoint,
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
)
|
||||
: getCurveShape<Point>(
|
||||
roughShape,
|
||||
pointFrom<Point>(element.x, element.y),
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
center,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// otherwise return the curve shape (and also the shape of its arrowheads)
|
||||
const arrowheadShapes: GeometricShape<Point>[] = [];
|
||||
const transform = (p: Point): Point =>
|
||||
pointRotateRads(
|
||||
pointFrom(p[0] + startingPoint[0], p[1] + startingPoint[1]),
|
||||
center,
|
||||
element.angle,
|
||||
);
|
||||
|
||||
for (const arrowhead of arrowheads) {
|
||||
if (arrowhead.shape === "polygon") {
|
||||
const ops = arrowhead.sets[0].ops;
|
||||
|
||||
const otherPoints = ops.slice(1);
|
||||
const arrowheadShape: GeometricShape<Point> = {
|
||||
type: "polygon",
|
||||
data: polygonFromPoints(
|
||||
otherPoints.map((otherPoint) =>
|
||||
transform(
|
||||
pointFrom<Point>(otherPoint.data[0], otherPoint.data[1]),
|
||||
),
|
||||
),
|
||||
),
|
||||
isClosed: true,
|
||||
};
|
||||
|
||||
arrowheadShapes.push(arrowheadShape);
|
||||
}
|
||||
|
||||
if (arrowhead.shape === "circle") {
|
||||
const polygonPoints = pointsOnBezierCurves(
|
||||
getPointsOnRoughCurve(arrowhead),
|
||||
15,
|
||||
2,
|
||||
).map((p) => transform(p as Point)) as Point[];
|
||||
|
||||
arrowheadShapes.push({
|
||||
type: "polygon",
|
||||
data: polygonFromPoints(polygonPoints),
|
||||
isClosed: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (arrowhead.shape === "line") {
|
||||
arrowheadShapes.push(
|
||||
getCurveShape<Point>(
|
||||
arrowhead,
|
||||
element.angle,
|
||||
center,
|
||||
startingPoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
getCurveShape<Point>(
|
||||
curve,
|
||||
element.angle,
|
||||
pointFrom(cx, cy),
|
||||
startingPoint,
|
||||
),
|
||||
...arrowheadShapes,
|
||||
];
|
||||
}
|
||||
|
||||
case "ellipse":
|
||||
return getEllipseShape(element);
|
||||
return [getEllipseShape(element)];
|
||||
|
||||
case "freedraw": {
|
||||
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
|
||||
return getFreedrawShape(
|
||||
element,
|
||||
pointFrom(cx, cy),
|
||||
shouldTestInside(element),
|
||||
);
|
||||
return [
|
||||
getFreedrawShape(element, pointFrom(cx, cy), shouldTestInside(element)),
|
||||
];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -201,21 +266,23 @@ export const getBoundTextShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
|
||||
if (boundTextElement) {
|
||||
if (element.type === "arrow") {
|
||||
return getElementShape(
|
||||
{
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
elementsMap,
|
||||
return (
|
||||
getElementShapes<Point>(
|
||||
{
|
||||
...boundTextElement,
|
||||
// arrow's bound text accurate position is not stored in the element's property
|
||||
// but rather calculated and returned from the following static method
|
||||
...LinearElementEditor.getBoundTextElementPosition(
|
||||
element,
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
),
|
||||
},
|
||||
elementsMap,
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
return getElementShape(boundTextElement, elementsMap);
|
||||
return getElementShapes<Point>(boundTextElement, elementsMap)[0] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1006,14 +1006,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"seed": 1,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"versionNonce": 1278240551,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -1042,14 +1042,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 449462985,
|
||||
"seed": 1,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 401146281,
|
||||
"versionNonce": 449462985,
|
||||
"width": 100,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -9792,14 +9792,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 1278240551,
|
||||
"seed": 1,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 453191,
|
||||
"versionNonce": 1278240551,
|
||||
"width": 200,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
@@ -9826,14 +9826,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
|
||||
"roundness": {
|
||||
"type": 3,
|
||||
},
|
||||
"seed": 449462985,
|
||||
"seed": 1,
|
||||
"strokeColor": "#1e1e1e",
|
||||
"strokeStyle": "solid",
|
||||
"strokeWidth": 2,
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 2,
|
||||
"versionNonce": 401146281,
|
||||
"versionNonce": 449462985,
|
||||
"width": 200,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -418,19 +418,12 @@ describe("element binding", () => {
|
||||
expect(arrow.startBinding?.elementId).toBe(rectLeft.id);
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
|
||||
// Drag arrow off of bound rectangle range
|
||||
const handles = getTransformHandles(
|
||||
arrow,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
).se!;
|
||||
|
||||
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
||||
const elX = handles[0] + handles[2] / 2;
|
||||
const elY = handles[1] + handles[3] / 2;
|
||||
mouse.downAt(elX, elY);
|
||||
mouse.moveTo(300, 400);
|
||||
mouse.downAt(
|
||||
arrow.x + arrow.points[arrow.points.length - 1][0],
|
||||
arrow.y + arrow.points[arrow.points.length - 1][1],
|
||||
);
|
||||
mouse.moveTo(300, 300);
|
||||
mouse.up();
|
||||
|
||||
expect(arrow.startBinding).not.toBe(null);
|
||||
|
||||
@@ -2,16 +2,17 @@ import React from "react";
|
||||
import { render, waitFor } from "./test-utils";
|
||||
import { Excalidraw } from "../index";
|
||||
import { API } from "./helpers/api";
|
||||
import {
|
||||
encodePngMetadata,
|
||||
encodeSvgMetadata,
|
||||
decodeSvgMetadata,
|
||||
} from "../data/image";
|
||||
import { encodePngMetadata } from "../data/image";
|
||||
import { serializeAsJSON } from "../data/json";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import {
|
||||
decodeSvgBase64Payload,
|
||||
encodeSvgBase64Payload,
|
||||
exportToSvg,
|
||||
} from "../scene/export";
|
||||
import type { FileId } from "../element/types";
|
||||
import { getDataURL } from "../data/blob";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { SVG_NS } from "../constants";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -62,15 +63,32 @@ describe("export", () => {
|
||||
});
|
||||
|
||||
it("test encoding/decoding scene for SVG export", async () => {
|
||||
const encoded = encodeSvgMetadata({
|
||||
text: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||
const metadataElement = document.createElementNS(SVG_NS, "metadata");
|
||||
|
||||
encodeSvgBase64Payload({
|
||||
metadataElement,
|
||||
payload: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||
});
|
||||
const decoded = JSON.parse(decodeSvgMetadata({ svg: encoded }));
|
||||
|
||||
const decoded = JSON.parse(
|
||||
decodeSvgBase64Payload({ svg: metadataElement.innerHTML }),
|
||||
);
|
||||
expect(decoded.elements).toEqual([
|
||||
expect.objectContaining({ type: "text", text: "😀" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("export svg-embedded scene", async () => {
|
||||
const svg = await exportToSvg(
|
||||
testElements,
|
||||
{ ...getDefaultAppState(), exportEmbedScene: true },
|
||||
{},
|
||||
);
|
||||
const svgText = svg.outerHTML;
|
||||
|
||||
expect(svgText).toMatchSnapshot(`svg-embdedded scene export output`);
|
||||
});
|
||||
|
||||
it("import embedded png (legacy v1)", async () => {
|
||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -198,8 +198,8 @@ const checkElementsBoundingBox = async (
|
||||
|
||||
await waitFor(() => {
|
||||
// Check if width and height did not change
|
||||
expect(x2 - x1).toBeCloseTo(x22 - x12, -1);
|
||||
expect(y2 - y1).toBeCloseTo(y22 - y12, -1);
|
||||
expect(Math.abs(x2 - x1 - (x22 - x12))).toBeLessThanOrEqual(toleranceInPx);
|
||||
expect(Math.abs(y2 - y1 - (y22 - y12))).toBeLessThanOrEqual(toleranceInPx);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -220,7 +220,6 @@ export class API {
|
||||
| "width"
|
||||
| "height"
|
||||
| "type"
|
||||
| "seed"
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "isDeleted"
|
||||
@@ -228,6 +227,7 @@ export class API {
|
||||
| "link"
|
||||
| "updated"
|
||||
> = {
|
||||
seed: 1,
|
||||
x,
|
||||
y,
|
||||
frameId: rest.frameId ?? null,
|
||||
|
||||
@@ -535,7 +535,7 @@ describe("arrow element", () => {
|
||||
|
||||
UI.resize([rectangle, arrow], "nw", [300, 350]);
|
||||
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
|
||||
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.168, 2);
|
||||
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +1,19 @@
|
||||
import * as utils from "../utils";
|
||||
import { isTransparent, sanitizeHTMLAttribute } from "../utils";
|
||||
|
||||
describe("Test isTransparent", () => {
|
||||
it("should return true when color is rgb transparent", () => {
|
||||
expect(utils.isTransparent("#ff00")).toEqual(true);
|
||||
expect(utils.isTransparent("#fff00000")).toEqual(true);
|
||||
expect(utils.isTransparent("transparent")).toEqual(true);
|
||||
expect(isTransparent("#ff00")).toEqual(true);
|
||||
expect(isTransparent("#fff00000")).toEqual(true);
|
||||
expect(isTransparent("transparent")).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false when color is not transparent", () => {
|
||||
expect(utils.isTransparent("#ced4da")).toEqual(false);
|
||||
expect(isTransparent("#ced4da")).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeHTMLAttribute()", () => {
|
||||
it("should escape HTML attribute special characters & not double escape", () => {
|
||||
expect(sanitizeHTMLAttribute(`&"'><`)).toBe("&"'><");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1225,3 +1225,16 @@ export class PromisePool<T> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sanitizeHTMLAttribute = (html: string) => {
|
||||
return (
|
||||
html
|
||||
// note, if we're not doing stupid things, escaping " is enough,
|
||||
// but we might end up doing stupid things
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/</g, "<")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export type Ellipse<Point extends GlobalPoint | LocalPoint> = {
|
||||
halfHeight: number;
|
||||
};
|
||||
|
||||
export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
|
||||
export type GeometricShape<Point extends GlobalPoint | LocalPoint> = (
|
||||
| {
|
||||
type: "line";
|
||||
data: LineSegment<Point>;
|
||||
@@ -97,7 +97,10 @@ export type GeometricShape<Point extends GlobalPoint | LocalPoint> =
|
||||
| {
|
||||
type: "polycurve";
|
||||
data: Polycurve<Point>;
|
||||
};
|
||||
}
|
||||
) & {
|
||||
isClosed?: boolean;
|
||||
};
|
||||
|
||||
type RectangularElement =
|
||||
| ExcalidrawRectangleElement
|
||||
@@ -203,9 +206,9 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
|
||||
// linear
|
||||
export const getCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
roughShape: Drawable,
|
||||
startingPoint: Point = pointFrom(0, 0),
|
||||
angleInRadian: Radians,
|
||||
center: Point,
|
||||
startingPoint: Point = pointFrom(0, 0),
|
||||
): GeometricShape<Point> => {
|
||||
const transform = (p: Point): Point =>
|
||||
pointRotateRads(
|
||||
@@ -285,6 +288,35 @@ export const getFreedrawShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
) as GeometricShape<Point>;
|
||||
};
|
||||
|
||||
export const getPointsOnRoughCurve = <Point extends GlobalPoint | LocalPoint>(
|
||||
roughCurve: Drawable,
|
||||
) => {
|
||||
const ops = getCurvePathOps(roughCurve);
|
||||
|
||||
const points: Point[] = [];
|
||||
let odd = false;
|
||||
for (const operation of ops) {
|
||||
if (operation.op === "move") {
|
||||
odd = !odd;
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
}
|
||||
} else if (operation.op === "bcurveTo") {
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
points.push(pointFrom(operation.data[2], operation.data[3]));
|
||||
points.push(pointFrom(operation.data[4], operation.data[5]));
|
||||
}
|
||||
} else if (operation.op === "lineTo") {
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
element: ExcalidrawLinearElement,
|
||||
roughShape: Drawable,
|
||||
@@ -308,31 +340,10 @@ export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
|
||||
};
|
||||
}
|
||||
|
||||
const ops = getCurvePathOps(roughShape);
|
||||
|
||||
const points: Point[] = [];
|
||||
let odd = false;
|
||||
for (const operation of ops) {
|
||||
if (operation.op === "move") {
|
||||
odd = !odd;
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
}
|
||||
} else if (operation.op === "bcurveTo") {
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
points.push(pointFrom(operation.data[2], operation.data[3]));
|
||||
points.push(pointFrom(operation.data[4], operation.data[5]));
|
||||
}
|
||||
} else if (operation.op === "lineTo") {
|
||||
if (odd) {
|
||||
points.push(pointFrom(operation.data[0], operation.data[1]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const polygonPoints = pointsOnBezierCurves(points, 10, 5).map((p) =>
|
||||
transform(p as Point),
|
||||
const polygonPoints = pointsOnBezierCurves(
|
||||
getPointsOnRoughCurve(roughShape),
|
||||
10,
|
||||
5,
|
||||
) as Point[];
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
|
||||
import type { ImportedDataState } from "../excalidraw/data/types";
|
||||
import * as utils from "../utils";
|
||||
import { API } from "../excalidraw/tests/helpers/api";
|
||||
import { decodeSvgBase64Payload } from "../excalidraw/scene/export";
|
||||
import { decodePngMetadata } from "../excalidraw/data/image";
|
||||
|
||||
// NOTE this test file is using the actual API, unmocked. Hence splitting it
|
||||
// from the other test file, because I couldn't figure out how to test
|
||||
@@ -27,7 +28,7 @@ describe("embedding scene data", () => {
|
||||
|
||||
const svg = svgNode.outerHTML;
|
||||
|
||||
const parsedString = decodeSvgMetadata({ svg });
|
||||
const parsedString = decodeSvgBase64Payload({ svg });
|
||||
const importedData: ImportedDataState = JSON.parse(parsedString);
|
||||
|
||||
expect(sourceElements.map((x) => x.id)).toEqual(
|
||||
|
||||
@@ -158,5 +158,8 @@ const createESMRawBuild = async () => {
|
||||
await buildProd(rawConfigChunks);
|
||||
};
|
||||
|
||||
createESMRawBuild();
|
||||
createESMBrowserBuild();
|
||||
// otherwise throws "ERROR: Could not resolve "./subset-worker.chunk"
|
||||
(async () => {
|
||||
await createESMRawBuild();
|
||||
await createESMBrowserBuild();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user