mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
12 Commits
dwelle/fix
...
dwelle/dou
Author | SHA1 | Date | |
---|---|---|---|
a22927d4d1 | |||
ca9b7a505e | |||
36b387f973 | |||
2ac55067cd | |||
78ab12c7e6 | |||
f2f8219917 | |||
12c39d1034 | |||
d33e42e3a1 | |||
3b9ffd9586 | |||
b63689c230 | |||
c84babf574 | |||
36274f1f3e |
@ -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
|
||||
|
||||
|
@ -169,7 +169,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
],
|
||||
start_url: "/",
|
||||
id: "excalidraw",
|
||||
id:"excalidraw",
|
||||
display: "standalone",
|
||||
theme_color: "#121212",
|
||||
background_color: "#ffffff",
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
@ -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={
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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)">
|
||||
|
@ -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;
|
||||
|
@ -759,8 +759,3 @@ body.excalidraw-cursor-resize * {
|
||||
font-family: "Assistant";
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw-textEditorContainer {
|
||||
position: fixed;
|
||||
z-index: var(--zIndex-wysiwyg);
|
||||
}
|
||||
|
@ -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,
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -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),
|
||||
|
@ -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";
|
||||
|
||||
|
@ -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 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);
|
||||
|
@ -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[],
|
||||
|
@ -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,
|
||||
|
@ -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
@ -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"
|
||||
|
@ -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(() => {
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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));
|
||||
|
@ -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
@ -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, "<")
|
||||
);
|
||||
};
|
||||
|
@ -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