Compare commits

..

12 Commits

Author SHA1 Message Date
a22927d4d1 DEBUG 2025-01-07 18:28:01 +01:00
ca9b7a505e flake 2025-01-07 18:04:43 +01:00
36b387f973 feat: add timeout on doublick pointerup 2025-01-07 18:00:22 +01:00
2ac55067cd fix: package build fails on worker chunks (#8990) 2025-01-07 11:22:36 +00:00
78ab12c7e6 fix: z-index clash in mobile UI (#8985) 2025-01-06 21:21:11 +01:00
f2f8219917 feat: reintroduce .excalidraw.png default when embedding scene (#8979) 2025-01-05 22:21:39 +01:00
12c39d1034 feat: add mimeTypes on file save (#8946) 2025-01-05 21:12:07 +00:00
d33e42e3a1 feat: add crowfoot to arrowheads (#8942)
* crowfoot many

* crowfoot one

* one or many

* add icons for crowfoot

* add crowfoot icons

* adjust arrowhead selection popover

* make options collapsible

* swap triangle and bar

* switch to radix popover

* put triangle outline in the first row

* align shadow with new design spec

* remove unused flag

* swap order

* tweak labels

* handle shift+tab

---------

Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
Co-authored-by: Jakub Królak <108676707+j-krolak@users.noreply.github.com>
2025-01-05 21:50:24 +01:00
3b9ffd9586 fix: elbow arrows do not work within frames (issue: #8964) (#8969)
check for !isFrameLikeElement
2025-01-05 21:47:20 +01:00
b63689c230 feat: make HTML attribute sanitization stricter (#8977)
* feat: make HTML attribute sanitization stricter

* fix double escape
2025-01-05 21:45:04 +01:00
c84babf574 feat: validate library install urls (#8976) 2025-01-05 17:10:55 +01:00
36274f1f3e feat: cleanup svg export and move payload to <metadata> (#8975) 2025-01-05 16:53:05 +01:00
49 changed files with 683 additions and 575 deletions

View File

@ -25,7 +25,7 @@ VITE_APP_ENABLE_TRACKING=true
FAST_REFRESH=false
# The port the run the dev server
VITE_APP_PORT=3001
VITE_APP_PORT=3000
#Debug flags

View File

@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
},
],
start_url: "/",
id: "excalidraw",
id:"excalidraw",
display: "standalone",
theme_color: "#121212",
background_color: "#ffffff",

View File

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

View File

@ -91,6 +91,7 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA,
isSafari,
type EXPORT_IMAGE_TYPES,
DOUBLE_CLICK_POINTERUP_TIMEOUT,
} from "../constants";
import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data";
@ -2583,21 +2584,6 @@ class App extends React.Component<AppProps, AppState> {
this.handleWheel,
{ passive: false },
),
addEventListener(window, "focusin", (event) => {
console.log("%c@@@@@@ focusin:", "color:lime", event.target);
const target = event.target;
if (
event.target instanceof HTMLElement &&
this.state.editingTextElement
) {
if (event.target.tagName !== "TEXTAREA") {
this.focusContainer();
}
}
}),
addEventListener(window, "focusout", (event) => {
console.log("%c@@@@@@ focusout:", "color:red", event.target);
}),
addEventListener(window, EVENT.MESSAGE, this.onWindowMessage, false),
addEventListener(document, EVENT.POINTER_UP, this.removePointer, {
passive: false,
@ -4860,12 +4846,6 @@ class App extends React.Component<AppProps, AppState> {
) {
const elementsMap = this.scene.getElementsMapIncludingDeleted();
// flushSync(() => {
// this.setState({
// editingTextElement: element,
// });
// });
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
this.scene.replaceAllElements([
// Not sure why we include deleted elements as well hence using deleted elements map
@ -5370,6 +5350,14 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>,
) => {
if (
this.lastPointerDownEvent &&
event.timeStamp - this.lastPointerDownEvent.timeStamp >
DOUBLE_CLICK_POINTERUP_TIMEOUT
) {
return;
}
// case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode
if (this.state.multiElement) {
@ -6299,12 +6287,8 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>,
) => {
console.log("(1)", document.activeElement);
console.time();
this.focusContainer();
console.timeEnd();
console.log("(2)", document.activeElement);
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) {
@ -6748,16 +6732,17 @@ class App extends React.Component<AppProps, AppState> {
}
isPanning = true;
// due to event.preventDefault below, container wouldn't get focus
// automatically
this.focusContainer();
// preventing defualt while text editing messes with cursor/focus
if (!this.state.editingTextElement) {
// necessary to prevent browser from scrolling the page if excalidraw
// not full-page #4489
//
// note, this fix won't work when panning canvas while in wysiwyg since
// we don't execute it while in wysiwyg
// as such, the above is broken when panning canvas while in wysiwyg
event.preventDefault();
// focus explicitly due to the event.preventDefault above
this.focusContainer();
}
let nextPastePrevented = false;

View File

@ -19,7 +19,6 @@ import { jotaiScope } from "../../jotai";
import { ColorInput } from "./ColorInput";
import { activeEyeDropperAtom } from "../EyeDropper";
import { PropertiesPopover } from "../PropertiesPopover";
import { CLASSES } from "../../constants";
import "./ColorPicker.scss";
@ -187,13 +186,9 @@ const ColorPickerTrigger = ({
return (
<Popover.Trigger
type="button"
className={clsx(
"color-picker__button active-color",
CLASSES.PROPERTIES_POPOVER_TRIGGER,
{
"is-transparent": color === "transparent" || !color,
},
)}
className={clsx("color-picker__button active-color properties-trigger", {
"is-transparent": color === "transparent" || !color,
})}
aria-label={label}
style={color ? { "--swatch-color": color } : undefined}
title={

View File

@ -1,7 +1,6 @@
import clsx from "clsx";
import { useAtom } from "jotai";
import { useEffect, useRef } from "react";
import { useUIAppState } from "../../context/ui-appState";
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
import HotkeyLabel from "./HotkeyLabel";
@ -18,7 +17,6 @@ export const CustomColorList = ({
onChange,
label,
}: CustomColorListProps) => {
const appState = useUIAppState();
const [activeColorPickerSection, setActiveColorPickerSection] = useAtom(
activeColorPickerSectionAtom,
);
@ -56,9 +54,7 @@ export const CustomColorList = ({
key={i}
>
<div className="color-picker__button-outline" />
{!appState.editingTextElement && (
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
)}
<HotkeyLabel color={c} keyLabel={i + 1} isCustomColor />
</button>
);
})}

View File

@ -10,7 +10,6 @@ import HotkeyLabel from "./HotkeyLabel";
import type { ColorPaletteCustom } from "../../colors";
import type { TranslationKeys } from "../../i18n";
import { t } from "../../i18n";
import { useUIAppState } from "../../context/ui-appState";
interface PickerColorListProps {
palette: ColorPaletteCustom;
@ -27,8 +26,6 @@ const PickerColorList = ({
label,
activeShade,
}: PickerColorListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: color || "transparent",
palette,
@ -83,9 +80,7 @@ const PickerColorList = ({
key={key}
>
<div className="color-picker__button-outline" />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={keybinding} />
)}
<HotkeyLabel color={color} keyLabel={keybinding} />
</button>
);
})}

View File

@ -8,7 +8,6 @@ import {
import HotkeyLabel from "./HotkeyLabel";
import { t } from "../../i18n";
import type { ColorPaletteCustom } from "../../colors";
import { useUIAppState } from "../../context/ui-appState";
interface ShadeListProps {
hex: string;
@ -17,8 +16,6 @@ interface ShadeListProps {
}
export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
const appState = useUIAppState();
const colorObj = getColorNameAndShadeFromColor({
color: hex || "transparent",
palette,
@ -34,7 +31,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
if (btnRef.current && activeColorPickerSection === "shades") {
btnRef.current.focus();
}
}, [colorObj?.colorName, colorObj?.shade, activeColorPickerSection]);
}, [colorObj, activeColorPickerSection]);
if (colorObj) {
const { colorName, shade } = colorObj;
@ -67,9 +64,7 @@ export const ShadeList = ({ hex, onChange, palette }: ShadeListProps) => {
}}
>
<div className="color-picker__button-outline" />
{!appState.editingTextElement && (
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
)}
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
</button>
))}
</div>

View File

@ -56,7 +56,6 @@ export const TopPicks = ({
title={color}
onClick={() => onChange(color)}
data-testid={`color-top-pick-${color}`}
tabIndex={-1}
>
<div className="color-picker__button-outline" />
</button>

View File

@ -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 {

View File

@ -250,10 +250,6 @@ export const FontPickerList = React.memo(
onClose={onClose}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
onFocusOutside={(event) => {
// so we don't close when refocusing wysiwyg while editing
event.preventDefault();
}}
>
<QuickSearch
ref={inputRef}

View File

@ -5,7 +5,6 @@ import { TextIcon } from "../icons";
import type { FontFamilyValues } from "../../element/types";
import { t } from "../../i18n";
import { isDefaultFont } from "./FontPicker";
import { CLASSES } from "../../constants";
interface FontPickerTriggerProps {
selectedFontFamily: FontFamilyValues | null;
@ -27,7 +26,7 @@ export const FontPickerTrigger = ({
standalone
icon={TextIcon}
title={t("labels.showFonts")}
className={CLASSES.PROPERTIES_POPOVER_TRIGGER}
className="properties-trigger"
testId={"font-family-show-fonts"}
active={isTriggerActive}
// no-op

View File

@ -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 {

View File

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

View File

@ -5,7 +5,6 @@ import * as Popover from "@radix-ui/react-popover";
import { useDevice } from "./App";
import { Island } from "./Island";
import { isInteractive } from "../utils";
import { CLASSES } from "../constants";
interface PropertiesPopoverProps {
className?: string;
@ -43,11 +42,7 @@ export const PropertiesPopover = React.forwardRef<
<Popover.Portal container={container}>
<Popover.Content
ref={ref}
className={clsx(
"focus-visible-none",
CLASSES.PROPERTIES_POPOVER,
className,
)}
className={clsx("focus-visible-none", className)}
data-prevent-outside-click
side={
device.editor.isMobile && !device.viewport.isLandscape

View File

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

View File

@ -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)">

View File

@ -115,8 +115,6 @@ export const CLASSES = {
SHAPE_ACTIONS_MENU: "App-menu__left",
ZOOM_ACTIONS: "zoom-actions",
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
PROPERTIES_POPOVER: "properties-popover",
PROPERTIES_POPOVER_TRIGGER: "properties-popover-trigger",
};
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
@ -257,6 +255,14 @@ export const EXPORT_SOURCE =
// time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300;
/**
* The time the user has from 2nd pointerdown to following pointerup
* before it's not considered a double click.
*
* Helps prevent cases where you double-click by mistake but then drag/keep
* the pointer down for to cancel the double click or do another action.
*/
export const DOUBLE_CLICK_POINTERUP_TIMEOUT = 300;
export const TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000;

View File

@ -759,8 +759,3 @@ body.excalidraw-cursor-resize * {
font-family: "Assistant";
}
}
.excalidraw-textEditorContainer {
position: fixed;
z-index: var(--zIndex-wysiwyg);
}

View File

@ -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) {

View File

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

View File

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

View File

@ -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") {

View File

@ -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)) {

View File

@ -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("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&amp;");
});
});

View File

@ -1,8 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
import { sanitizeHTMLAttribute } from "../utils";
export const normalizeLink = (link: string) => {
link = link.trim();

View File

@ -40,6 +40,7 @@ import {
isBoundToContainer,
isElbowArrow,
isFixedPointBinding,
isFrameLikeElement,
isLinearElement,
isRectangularElement,
isTextElement,
@ -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),
),
);

View File

@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond":
case "diamond_outline":
return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default:
return 15;
}
@ -669,6 +673,21 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead);
if (arrowhead === "crowfoot_many" || arrowhead === "crowfoot_one_or_many") {
// swap (xs, ys) with (x2, y2)
const [x3, y3] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(-angle as Degrees),
);
const [x4, y4] = pointRotateRads(
pointFrom(x2, y2),
pointFrom(xs, ys),
degreesToRadians(angle),
);
return [xs, ys, x3, y3, x4, y4];
}
// Return points
const [x3, y3] = pointRotateRads(
pointFrom(xs, ys),

View File

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

View File

@ -11,7 +11,7 @@ import {
isBoundToContainer,
isTextElement,
} from "./typeChecks";
import { CLASSES, EVENT, isSafari, POINTER_BUTTON } from "../constants";
import { CLASSES, POINTER_BUTTON } from "../constants";
import type {
ExcalidrawElement,
ExcalidrawLinearElement,
@ -50,8 +50,6 @@ import {
originalContainerCache,
updateOriginalContainerCache,
} from "./containerCache";
import { activeEyeDropperAtom } from "../components/EyeDropper";
import { jotaiStore } from "../jotai";
const getTransform = (
width: number,
@ -291,8 +289,6 @@ export const textWysiwyg = ({
editable.dataset.type = "wysiwyg";
// prevent line wrapping on Safari
editable.wrap = "off";
// set &nbsp; placeholder fix for Safari not showing caret for empty textarea
editable.placeholder = "\u00A0";
editable.classList.add("excalidraw-wysiwyg");
let whiteSpace = "pre";
@ -526,7 +522,6 @@ export const textWysiwyg = ({
// so that we don't need to create separate a callback for event handlers
let submittedViaKeyboard = false;
const handleSubmit = () => {
console.warn("handleSubmit");
// prevent double submit
if (isDestroyed) {
return;
@ -584,96 +579,62 @@ export const textWysiwyg = ({
});
};
const onBlur = () => {
console.warn("onBlur", document.activeElement);
const isColorPicking = jotaiStore.get(activeEyeDropperAtom);
if (isColorPicking) {
focusEditable(null);
} else if (document.activeElement !== editable) {
handleSubmit();
}
};
const cleanup = () => {
// remove events to ensure they don't late-fire
editable.onblur = null;
editable.oninput = null;
editable.onkeydown = null;
editable.onpointerdown = null;
if (observer) {
observer.disconnect();
}
window.removeEventListener(EVENT.RESIZE, updateWysiwygStyle);
window.removeEventListener(EVENT.WHEEL, stopEvent, true);
window.removeEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.BLUR, onBlur);
window.removeEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
window.removeEventListener("resize", updateWysiwygStyle);
window.removeEventListener("wheel", stopEvent, true);
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointerup", bindBlurEvent);
window.removeEventListener("blur", handleSubmit);
window.removeEventListener("beforeunload", handleSubmit);
unbindUpdate();
unbindOnScroll();
editable.remove();
};
const focusEditable = (event: MouseEvent | FocusEvent | null) => {
const bindBlurEvent = (event?: MouseEvent) => {
window.removeEventListener("pointerup", bindBlurEvent);
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
const target = event?.target;
const shouldSkipRefocus =
target &&
// don't steal focus if user is focusing an input such as HEX input
((isWritableElement(target) && document.activeElement !== editable) ||
// refocusing while clicking on popver breaks safari
(isSafari &&
target instanceof HTMLElement &&
target.classList.contains(CLASSES.PROPERTIES_POPOVER_TRIGGER)));
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if (!shouldSkipRefocus) {
// Deferred so that the pointerdown that initiates the wysiwyg doesn't
// trigger the blur on ensuing pointerup.
// Also to handle cases such as picking a color which would trigger a blur
// in that same tick.
setTimeout(() => {
// double deferred because on onUpdate/color picker shennanings
setTimeout(() => {
editable.focus();
});
});
}
};
const onPointerUp = (event: PointerEvent | FocusEvent) => {
window.removeEventListener(EVENT.POINTER_UP, onPointerUp);
window.removeEventListener(EVENT.FOCUS, onPointerUp);
// needs to be deferred due to Safari
setTimeout(() => {
editable.onblur = onBlur;
editable.onblur = handleSubmit;
// case: clicking on the same property → no change → no update → no focus
if (!isPropertiesTrigger) {
editable.focus();
}
});
focusEditable(event);
};
const disableBlurUntilNextPointerUp = () => {
const temporarilyDisableSubmit = () => {
editable.onblur = null;
window.addEventListener(EVENT.POINTER_UP, onPointerUp);
window.addEventListener("pointerup", bindBlurEvent);
// handle edge-case where pointerup doesn't fire e.g. due to user
// alt-tabbing away
window.addEventListener(EVENT.FOCUS, onPointerUp);
window.addEventListener("blur", handleSubmit);
};
// prevent blur when changing properties from the menu
const onPointerDown = (event: MouseEvent) => {
const target = event?.target;
// ugly hack to close popups such as color picker when clicking back
// into the wysiwyg editor (it won't autoclose as blur won't trigger
// since we perpetually keep focus inside the wysiwyg)
if (target === editable && app.state.openPopup) {
app.setState({ openPopup: null });
}
// panning canvas
if (event.button === POINTER_BUTTON.WHEEL) {
// trying to pan by clicking inside text area itself -> handle here
@ -681,18 +642,24 @@ export const textWysiwyg = ({
event.preventDefault();
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
}
disableBlurUntilNextPointerUp();
temporarilyDisableSubmit();
return;
}
const isPropertiesTrigger =
target instanceof HTMLElement &&
target.classList.contains("properties-trigger");
if (
(event.target instanceof HTMLElement ||
((event.target instanceof HTMLElement ||
event.target instanceof SVGElement) &&
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}, .${CLASSES.PROPERTIES_POPOVER}`,
)
event.target.closest(
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
) &&
!isWritableElement(event.target)) ||
isPropertiesTrigger
) {
disableBlurUntilNextPointerUp();
temporarilyDisableSubmit();
} else if (
event.target instanceof HTMLCanvasElement &&
// Vitest simply ignores stopPropagation, capture-mode, or rAF
@ -715,11 +682,9 @@ export const textWysiwyg = ({
const unbindUpdate = app.scene.onUpdate(() => {
updateWysiwygStyle();
const isPopupOpened = !!document.activeElement?.closest(
CLASSES.PROPERTIES_POPOVER,
".properties-content",
);
if (!isPopupOpened) {
// we need to keep this code path for safari (iPadOS) bs reasons
// (also Vitest)
editable.focus();
}
});
@ -737,11 +702,8 @@ export const textWysiwyg = ({
// because we need it to happen *after* the blur event from `pointerdown`)
editable.select();
}
focusEditable(null);
setTimeout(() => {
editable.onblur = onBlur;
});
console.log(">>>>>>>>", app.state.editingTextElement);
bindBlurEvent();
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
// is preferred so we catch changes from host, where window may not resize.
let observer: ResizeObserver | null = null;
@ -751,7 +713,7 @@ export const textWysiwyg = ({
});
observer.observe(canvas);
} else {
window.addEventListener(EVENT.RESIZE, updateWysiwygStyle);
window.addEventListener("resize", updateWysiwygStyle);
}
editable.onpointerdown = (event) => event.stopPropagation();
@ -759,11 +721,9 @@ export const textWysiwyg = ({
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
// triggered the wysiwyg
requestAnimationFrame(() => {
window.addEventListener(EVENT.POINTER_DOWN, onPointerDown, {
capture: true,
});
window.addEventListener("pointerdown", onPointerDown, { capture: true });
});
window.addEventListener(EVENT.BEFORE_UNLOAD, handleSubmit);
window.addEventListener("beforeunload", handleSubmit);
excalidrawContainer
?.querySelector(".excalidraw-textEditorContainer")!
.appendChild(editable);

View File

@ -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<{

View File

@ -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",

View File

@ -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");

View File

@ -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,
)
: []),
];
}
}

View File

@ -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[],

View File

@ -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,

View File

@ -635,7 +635,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
aria-expanded="false"
aria-haspopup="dialog"
aria-label="Canvas background"
class="color-picker__button active-color properties-popover-trigger"
class="color-picker__button active-color properties-trigger"
data-state="closed"
style="--swatch-color: #ffffff;"
title="Show background color picker"

File diff suppressed because one or more lines are too long

View File

@ -17,7 +17,6 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
class="excalidraw-wysiwyg"
data-type="wysiwyg"
dir="auto"
placeholder=" "
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 26.25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Excalifont, Xiaolai, Segoe UI Emoji;"
tabindex="0"
wrap="off"

View File

@ -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(() => {

View File

@ -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,

View File

@ -38,8 +38,6 @@ import { pointFrom, pointRotateRads } from "../../../math";
import { cropElement } from "../../element/cropElement";
import type { ToolType } from "../../types";
const TEXT_EDITOR_SELECTOR = ".excalidraw-textEditorContainer > textarea";
// so that window.h is available when App.tsx is not imported as well.
createTestHook();
@ -479,20 +477,12 @@ export class UI {
pointFrom(0, 0),
pointFrom(width, height),
];
UI.clickTool(type);
if (type === "text") {
mouse.reset();
mouse.click(x, y);
const openedEditor =
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
openedEditor?.focus();
} else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => {
mouse.reset();
@ -528,25 +518,20 @@ export class UI {
static async editText<
T extends ExcalidrawTextElement | ExcalidrawTextContainer,
>(element: T, text: string) {
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
const openedEditor =
document.querySelector<HTMLTextAreaElement>(TEXT_EDITOR_SELECTOR);
document.querySelector<HTMLTextAreaElement>(textEditorSelector);
if (!openedEditor) {
mouse.select(element);
Keyboard.keyPress(KEYS.ENTER);
}
const editor = await getTextEditor();
const editor = await getTextEditor(textEditorSelector);
if (!editor) {
throw new Error("Can't find wysiwyg text editor in the dom");
}
// NOTE this is a hack to make sure the editor is focused on edit
// which for some reason doesn't work in tests after latest changes.
// This means that a regression in wysiwyg editor might not be caught
// tests.
editor.focus();
fireEvent.input(editor, { target: { value: text } });
act(() => {
editor.blur();

View File

@ -1,10 +1,7 @@
import { waitFor } from "@testing-library/dom";
import { fireEvent } from "@testing-library/react";
export const getTextEditor = async (
selector = ".excalidraw-textEditorContainer > textarea",
waitForEditor = true,
) => {
export const getTextEditor = async (selector: string, waitForEditor = true) => {
const query = () => document.querySelector(selector) as HTMLTextAreaElement;
if (waitForEditor) {
await waitFor(() => expect(query()).not.toBe(null));

View File

@ -1126,7 +1126,7 @@ describe("multiple selection", () => {
expect(bottomArrowLabel.fontSize).toBeCloseTo(28 * scale);
});
it.only("resizes with text elements", async () => {
it("resizes with text elements", async () => {
const topText = UI.createElement("text", { position: 0 });
await UI.editText(topText, "lorem ipsum");

File diff suppressed because one or more lines are too long

View File

@ -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("&amp;&quot;&#39;&gt;&lt;");
});
});

View File

@ -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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/>/g, "&gt;")
.replace(/</g, "&lt;")
);
};

View File

@ -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(

View File

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