Compare commits

...

12 Commits

Author SHA1 Message Date
dwelle a22927d4d1 DEBUG 2025-01-07 18:28:01 +01:00
dwelle ca9b7a505e flake 2025-01-07 18:04:43 +01:00
dwelle 36b387f973 feat: add timeout on doublick pointerup 2025-01-07 18:00:22 +01:00
Marcel Mraz 2ac55067cd fix: package build fails on worker chunks (#8990) 2025-01-07 11:22:36 +00:00
David Luzar 78ab12c7e6 fix: z-index clash in mobile UI (#8985) 2025-01-06 21:21:11 +01:00
David Luzar 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
Ryan Di 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
zsviczian 3b9ffd9586 fix: elbow arrows do not work within frames (issue: #8964) (#8969)
check for !isFrameLikeElement
2025-01-05 21:47:20 +01:00
David Luzar 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
David Luzar c84babf574 feat: validate library install urls (#8976) 2025-01-05 17:10:55 +01:00
David Luzar 36274f1f3e feat: cleanup svg export and move payload to <metadata> (#8975) 2025-01-05 16:53:05 +01:00
32 changed files with 617 additions and 388 deletions
@@ -53,6 +53,9 @@ import {
sharpArrowIcon, sharpArrowIcon,
roundArrowIcon, roundArrowIcon,
elbowArrowIcon, elbowArrowIcon,
ArrowheadCrowfootIcon,
ArrowheadCrowfootOneIcon,
ArrowheadCrowfootOneOrManyIcon,
} from "../components/icons"; } from "../components/icons";
import { import {
ARROW_TYPE, ARROW_TYPE,
@@ -1405,59 +1408,65 @@ const getArrowheadOptions = (flip: boolean) => {
keyBinding: "w", keyBinding: "w",
icon: <ArrowheadArrowIcon flip={flip} />, 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", value: "triangle",
text: t("labels.arrowhead_triangle"), text: t("labels.arrowhead_triangle"),
icon: <ArrowheadTriangleIcon flip={flip} />, icon: <ArrowheadTriangleIcon flip={flip} />,
keyBinding: "t", keyBinding: "e",
}, },
{ {
value: "triangle_outline", value: "triangle_outline",
text: t("labels.arrowhead_triangle_outline"), text: t("labels.arrowhead_triangle_outline"),
icon: <ArrowheadTriangleOutlineIcon flip={flip} />, icon: <ArrowheadTriangleOutlineIcon flip={flip} />,
keyBinding: null, keyBinding: "r",
showInPicker: false, },
{
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", value: "diamond",
text: t("labels.arrowhead_diamond"), text: t("labels.arrowhead_diamond"),
icon: <ArrowheadDiamondIcon flip={flip} />, icon: <ArrowheadDiamondIcon flip={flip} />,
keyBinding: null, keyBinding: "d",
showInPicker: false,
}, },
{ {
value: "diamond_outline", value: "diamond_outline",
text: t("labels.arrowhead_diamond_outline"), text: t("labels.arrowhead_diamond_outline"),
icon: <ArrowheadDiamondOutlineIcon flip={flip} />, icon: <ArrowheadDiamondOutlineIcon flip={flip} />,
keyBinding: null, keyBinding: "f",
showInPicker: false, },
{
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; ] as const;
}; };
@@ -1521,6 +1530,7 @@ export const actionChangeArrowhead = register({
appState.currentItemStartArrowhead, appState.currentItemStartArrowhead,
)} )}
onChange={(value) => updateData({ position: "start", type: value })} onChange={(value) => updateData({ position: "start", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
<IconPicker <IconPicker
label="arrowhead_end" label="arrowhead_end"
@@ -1537,6 +1547,7 @@ export const actionChangeArrowhead = register({
appState.currentItemEndArrowhead, appState.currentItemEndArrowhead,
)} )}
onChange={(value) => updateData({ position: "end", type: value })} onChange={(value) => updateData({ position: "end", type: value })}
numberOfOptionsToAlwaysShow={4}
/> />
</div> </div>
</fieldset> </fieldset>
+10
View File
@@ -91,6 +91,7 @@ import {
DEFAULT_REDUCED_GLOBAL_ALPHA, DEFAULT_REDUCED_GLOBAL_ALPHA,
isSafari, isSafari,
type EXPORT_IMAGE_TYPES, type EXPORT_IMAGE_TYPES,
DOUBLE_CLICK_POINTERUP_TIMEOUT,
} from "../constants"; } from "../constants";
import type { ExportedElements } from "../data"; import type { ExportedElements } from "../data";
import { exportCanvas, loadFromBlob } from "../data"; import { exportCanvas, loadFromBlob } from "../data";
@@ -5349,6 +5350,14 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>, 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 // case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode // text and enter multiElement mode
if (this.state.multiElement) { if (this.state.multiElement) {
@@ -6279,6 +6288,7 @@ class App extends React.Component<AppProps, AppState> {
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
this.maybeCleanupAfterMissingPointerUp(event.nativeEvent); this.maybeCleanupAfterMissingPointerUp(event.nativeEvent);
this.maybeUnfollowRemoteUser(); this.maybeUnfollowRemoteUser();
if (this.state.searchMatches) { if (this.state.searchMatches) {
@@ -15,7 +15,6 @@
top: var(--editor-container-padding); top: var(--editor-container-padding);
right: var(--editor-container-padding); right: var(--editor-container-padding);
bottom: var(--editor-container-padding); bottom: var(--editor-container-padding);
z-index: 2;
} }
.FixedSideContainer_side_top.zen-mode { .FixedSideContainer_side_top.zen-mode {
+11 -45
View File
@@ -1,19 +1,16 @@
@import "../css/variables.module.scss"; @import "../css/variables.module.scss";
.excalidraw { .excalidraw {
.picker-container {
display: inline-block;
box-sizing: border-box;
margin-right: 0.25rem;
}
.picker { .picker {
padding: 0.5rem;
background: var(--popup-bg-color); background: var(--popup-bg-color);
border: 0 solid transparentize($oc-white, 0.75); border: 0 solid transparentize($oc-white, 0.75);
// ˇˇ yeah, i dunno, open to suggestions here :D box-shadow: var(--shadow-island);
box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px;
border-radius: 4px; border-radius: 4px;
position: absolute; position: absolute;
:root[dir="rtl"] & {
padding: 0.4rem;
}
} }
.picker-container button, .picker-container button,
@@ -55,47 +52,16 @@
padding: 0.25rem 0.28rem 0.35rem 0.25rem; 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 { .picker-content {
padding: 0.5rem;
display: grid; display: grid;
grid-template-columns: repeat(3, auto); grid-template-columns: repeat(4, auto);
grid-gap: 0.5rem; grid-gap: 0.5rem;
border-radius: 4px; border-radius: 4px;
:root[dir="rtl"] & { }
padding: 0.4rem;
} .picker-collapsible {
font-size: 0.75rem;
padding: 0.5rem 0;
} }
.picker-keybinding { .picker-keybinding {
+131 -98
View File
@@ -1,10 +1,23 @@
import React from "react"; import React, { useEffect } from "react";
import { Popover } from "./Popover"; import * as Popover from "@radix-ui/react-popover";
import "./IconPicker.scss"; import "./IconPicker.scss";
import { isArrowKey, KEYS } from "../keys"; import { isArrowKey, KEYS } from "../keys";
import { getLanguage } from "../i18n"; import { getLanguage, t } from "../i18n";
import clsx from "clsx"; 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>({ function Picker<T>({
options, options,
@@ -12,30 +25,16 @@ function Picker<T>({
label, label,
onChange, onChange,
onClose, onClose,
numberOfOptionsToAlwaysShow = options.length,
}: { }: {
label: string; label: string;
value: T; value: T;
options: { options: readonly Option<T>[];
value: T;
text: string;
icon: JSX.Element;
keyBinding: string | null;
}[];
onChange: (value: T) => void; onChange: (value: T) => void;
onClose: () => void; onClose: () => void;
numberOfOptionsToAlwaysShow?: number;
}) { }) {
const rFirstItem = React.useRef<HTMLButtonElement>(); const device = useDevice();
const rActiveItem = React.useRef<HTMLButtonElement>();
const rGallery = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
// After the component is first mounted focus on first input
if (rActiveItem.current) {
rActiveItem.current.focus();
} else if (rGallery.current) {
rGallery.current.focus();
}
}, []);
const handleKeyDown = (event: React.KeyboardEvent) => { const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find( const pressedOption = options.find(
@@ -44,28 +43,19 @@ function Picker<T>({
if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) {
// Keybinding navigation // Keybinding navigation
const index = options.indexOf(pressedOption); onChange(pressedOption.value);
(rGallery!.current!.children![index] as any).focus();
event.preventDefault(); event.preventDefault();
} else if (event.key === KEYS.TAB) { } else if (event.key === KEYS.TAB) {
// Tab navigation cycle through options. If the user tabs const index = options.findIndex((option) => option.value === value);
// away from the picker, close the picker. We need to use const nextIndex = event.shiftKey
// a timeout here to let the stack clear before checking. ? (options.length + index - 1) % options.length
setTimeout(() => { : (index + 1) % options.length;
const active = rActiveItem.current; onChange(options[nextIndex].value);
const docActive = document.activeElement;
if (active !== docActive) {
onClose();
}
}, 0);
} else if (isArrowKey(event.key)) { } else if (isArrowKey(event.key)) {
// Arrow navigation // Arrow navigation
const { activeElement } = document;
const isRTL = getLanguage().rtl; const isRTL = getLanguage().rtl;
const index = Array.prototype.indexOf.call( const index = options.findIndex((option) => option.value === value);
rGallery!.current!.children,
activeElement,
);
if (index !== -1) { if (index !== -1) {
const length = options.length; const length = options.length;
let nextIndex = index; let nextIndex = index;
@@ -73,19 +63,26 @@ function Picker<T>({
switch (event.key) { switch (event.key) {
// Select the next option // Select the next option
case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT: case isRTL ? KEYS.ARROW_LEFT : KEYS.ARROW_RIGHT:
case KEYS.ARROW_DOWN: {
nextIndex = (index + 1) % length; nextIndex = (index + 1) % length;
break; break;
}
// Select the previous option // Select the previous option
case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT: case isRTL ? KEYS.ARROW_RIGHT : KEYS.ARROW_LEFT:
case KEYS.ARROW_UP: {
nextIndex = (length + index - 1) % length; nextIndex = (length + index - 1) % length;
break; 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(); event.preventDefault();
} else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) {
@@ -97,15 +94,29 @@ function Picker<T>({
event.stopPropagation(); event.stopPropagation();
}; };
return ( const [showMoreOptions, setShowMoreOptions] = useAtom(
<div moreOptionsAtom,
className={`picker`} jotaiScope,
role="dialog" );
aria-modal="true"
aria-label={label} const alwaysVisibleOptions = React.useMemo(
onKeyDown={handleKeyDown} () => options.slice(0, numberOfOptionsToAlwaysShow),
> [options, numberOfOptionsToAlwaysShow],
<div className="picker-content" ref={rGallery}> );
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) => ( {options.map((option, i) => (
<button <button
type="button" type="button"
@@ -113,7 +124,6 @@ function Picker<T>({
active: value === option.value, active: value === option.value,
})} })}
onClick={(event) => { onClick={(event) => {
(event.currentTarget as HTMLButtonElement).focus();
onChange(option.value); onChange(option.value);
}} }}
title={`${option.text} ${ title={`${option.text} ${
@@ -122,16 +132,13 @@ function Picker<T>({
aria-label={option.text || "none"} aria-label={option.text || "none"}
aria-keyshortcuts={option.keyBinding || undefined} aria-keyshortcuts={option.keyBinding || undefined}
key={option.text} key={option.text}
ref={(el) => { ref={(ref) => {
if (el && i === 0) { if (value === option.value) {
rFirstItem.current = el; // 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} {option.icon}
@@ -141,7 +148,43 @@ function Picker<T>({
</button> </button>
))} ))}
</div> </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, options,
onChange, onChange,
group = "", group = "",
numberOfOptionsToAlwaysShow,
}: { }: {
label: string; label: string;
value: T; value: T;
@@ -159,51 +203,40 @@ export function IconPicker<T>({
text: string; text: string;
icon: JSX.Element; icon: JSX.Element;
keyBinding: string | null; keyBinding: string | null;
showInPicker?: boolean;
}[]; }[];
onChange: (value: T) => void; onChange: (value: T) => void;
numberOfOptionsToAlwaysShow?: number;
group?: string; group?: string;
}) { }) {
const [isActive, setActive] = React.useState(false); const [isActive, setActive] = React.useState(false);
const rPickerButton = React.useRef<any>(null); const rPickerButton = React.useRef<any>(null);
const isRTL = getLanguage().rtl;
return ( return (
<div> <div>
<button <Popover.Root open={isActive} onOpenChange={(open) => setActive(open)}>
name={group} <Popover.Trigger
type="button" name={group}
className={isActive ? "active" : ""} type="button"
aria-label={label} aria-label={label}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
ref={rPickerButton} ref={rPickerButton}
> className={isActive ? "active" : ""}
{options.find((option) => option.value === value)?.icon} >
</button> {options.find((option) => option.value === value)?.icon}
<React.Suspense fallback=""> </Popover.Trigger>
{isActive ? ( {isActive && (
<> <Picker
<Popover options={options}
onCloseRequest={(event) => value={value}
event.target !== rPickerButton.current && setActive(false) label={label}
} onChange={onChange}
{...(isRTL ? { right: 5.5 } : { left: -5.5 })} onClose={() => {
> setActive(false);
<Picker }}
options={options.filter((opt) => opt.showInPicker !== false)} numberOfOptionsToAlwaysShow={numberOfOptionsToAlwaysShow}
value={value} />
label={label} )}
onChange={onChange} </Popover.Root>
onClose={() => {
setActive(false);
rPickerButton.current?.focus();
}}
/>
</Popover>
<div className="picker-triangle" />
</>
) : null}
</React.Suspense>
</div> </div>
); );
} }
@@ -9,6 +9,7 @@ interface CollapsibleProps {
open: boolean; open: boolean;
openTrigger: () => void; openTrigger: () => void;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
const Collapsible = ({ const Collapsible = ({
@@ -16,6 +17,7 @@ const Collapsible = ({
open, open,
openTrigger, openTrigger,
children, children,
className,
}: CollapsibleProps) => { }: CollapsibleProps) => {
return ( return (
<> <>
@@ -26,6 +28,7 @@ const Collapsible = ({
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
}} }}
className={className}
onClick={openTrigger} onClick={openTrigger}
> >
{label} {label}
+48
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( export const FontSizeSmallIcon = createIcon(
<> <>
<g clipPath="url(#a)"> <g clipPath="url(#a)">
+8
View File
@@ -255,6 +255,14 @@ export const EXPORT_SOURCE =
// time in milliseconds // time in milliseconds
export const IMAGE_RENDER_TIMEOUT = 500; export const IMAGE_RENDER_TIMEOUT = 500;
export const TAP_TWICE_TIMEOUT = 300; 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 TOUCH_CTX_MENU_TIMEOUT = 500;
export const TITLE_TIMEOUT = 10000; export const TITLE_TIMEOUT = 10000;
export const VERSION_TIMEOUT = 30000; export const VERSION_TIMEOUT = 30000;
+2 -1
View File
@@ -5,6 +5,7 @@ import { clearElementsForExport } from "../element";
import type { ExcalidrawElement, FileId } from "../element/types"; import type { ExcalidrawElement, FileId } from "../element/types";
import { CanvasError, ImageSceneDataError } from "../errors"; import { CanvasError, ImageSceneDataError } from "../errors";
import { calculateScrollCenter } from "../scene"; import { calculateScrollCenter } from "../scene";
import { decodeSvgBase64Payload } from "../scene/export";
import type { AppState, DataURL, LibraryItem } from "../types"; import type { AppState, DataURL, LibraryItem } from "../types";
import type { ValueOf } from "../utility-types"; import type { ValueOf } from "../utility-types";
import { bytesToHexString, isPromiseLike } from "../utils"; import { bytesToHexString, isPromiseLike } from "../utils";
@@ -47,7 +48,7 @@ const parseFileContents = async (blob: Blob | File): Promise<string> => {
} }
if (blob.type === MIME_TYPES.svg) { if (blob.type === MIME_TYPES.svg) {
try { try {
return (await import("./image")).decodeSvgMetadata({ return decodeSvgBase64Payload({
svg: contents, svg: contents,
}); });
} catch (error: any) { } catch (error: any) {
+3 -1
View File
@@ -82,6 +82,7 @@ export const fileSave = (
name: string; name: string;
/** file extension */ /** file extension */
extension: FILE_EXTENSION; extension: FILE_EXTENSION;
mimeTypes?: string[];
description: string; description: string;
/** existing FileSystemHandle */ /** existing FileSystemHandle */
fileHandle?: FileSystemHandle | null; fileHandle?: FileSystemHandle | null;
@@ -93,10 +94,11 @@ export const fileSave = (
fileName: `${opts.name}.${opts.extension}`, fileName: `${opts.name}.${opts.extension}`,
description: opts.description, description: opts.description,
extensions: [`.${opts.extension}`], extensions: [`.${opts.extension}`],
mimeTypes: opts.mimeTypes,
}, },
opts.fileHandle, opts.fileHandle,
); );
}; };
export type { FileSystemHandle };
export { nativeFileSystemSupported }; export { nativeFileSystemSupported };
export type { FileSystemHandle };
+1 -54
View File
@@ -1,7 +1,7 @@
import decodePng from "png-chunks-extract"; import decodePng from "png-chunks-extract";
import tEXt from "png-chunk-text"; import tEXt from "png-chunk-text";
import encodePng from "png-chunks-encode"; 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 { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
import { blobToArrayBuffer } from "./blob"; import { blobToArrayBuffer } from "./blob";
@@ -67,56 +67,3 @@ export const decodePngMetadata = async (blob: Blob) => {
} }
throw new Error("INVALID"); 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");
};
+6 -5
View File
@@ -5,6 +5,7 @@ import {
import { import {
DEFAULT_EXPORT_PADDING, DEFAULT_EXPORT_PADDING,
DEFAULT_FILENAME, DEFAULT_FILENAME,
IMAGE_MIME_TYPES,
isFirefox, isFirefox,
MIME_TYPES, MIME_TYPES,
} from "../constants"; } from "../constants";
@@ -15,8 +16,9 @@ import type {
ExcalidrawFrameLikeElement, ExcalidrawFrameLikeElement,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "../element/types"; } from "../element/types";
import { getElementsOverlappingFrame } from "../frame";
import { t } from "../i18n"; import { t } from "../i18n";
import { isSomeElementSelected, getSelectedElements } from "../scene"; import { getSelectedElements, isSomeElementSelected } from "../scene";
import { exportToCanvas, exportToSvg } from "../scene/export"; import { exportToCanvas, exportToSvg } from "../scene/export";
import type { ExportType } from "../scene/types"; import type { ExportType } from "../scene/types";
import type { AppState, BinaryFiles } from "../types"; import type { AppState, BinaryFiles } from "../types";
@@ -25,7 +27,6 @@ import { canvasToBlob } from "./blob";
import type { FileSystemHandle } from "./filesystem"; import type { FileSystemHandle } from "./filesystem";
import { fileSave } from "./filesystem"; import { fileSave } from "./filesystem";
import { serializeAsJSON } from "./json"; import { serializeAsJSON } from "./json";
import { getElementsOverlappingFrame } from "../frame";
export { loadFromBlob } from "./blob"; export { loadFromBlob } from "./blob";
export { loadFromJSON, saveAsJSON } from "./json"; export { loadFromJSON, saveAsJSON } from "./json";
@@ -130,6 +131,7 @@ export const exportCanvas = async (
description: "Export to SVG", description: "Export to SVG",
name, name,
extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg", extension: appState.exportEmbedScene ? "excalidraw.svg" : "svg",
mimeTypes: [IMAGE_MIME_TYPES.svg],
fileHandle, fileHandle,
}, },
); );
@@ -168,9 +170,8 @@ export const exportCanvas = async (
return fileSave(blob, { return fileSave(blob, {
description: "Export to PNG", description: "Export to PNG",
name, name,
// FIXME reintroduce `excalidraw.png` when most people upgrade away extension: appState.exportEmbedScene ? "excalidraw.png" : "png",
// from 111.0.5563.64 (arm64), see #6349 mimeTypes: [IMAGE_MIME_TYPES.png],
extension: /* appState.exportEmbedScene ? "excalidraw.png" : */ "png",
fileHandle, fileHandle,
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
+43 -2
View File
@@ -35,6 +35,9 @@ import type { MaybePromise } from "../utility-types";
import { Emitter } from "../emitter"; import { Emitter } from "../emitter";
import { Queue } from "../queue"; import { Queue } from "../queue";
import { hashElementsVersion, hashString } from "../element"; import { hashElementsVersion, hashString } from "../element";
import { toValidURL } from "./url";
const ALLOWED_LIBRARY_HOSTNAMES = ["excalidraw.com"];
type LibraryUpdate = { type LibraryUpdate = {
/** deleted library items since last onLibraryChange event */ /** deleted library items since last onLibraryChange event */
@@ -467,6 +470,28 @@ export const distributeLibraryItemsOnSquareGrid = (
return resElements; 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 = () => { export const parseLibraryTokensFromUrl = () => {
const libraryUrl = const libraryUrl =
// current // current
@@ -608,6 +633,11 @@ const persistLibraryUpdate = async (
export const useHandleLibrary = ( export const useHandleLibrary = (
opts: { opts: {
excalidrawAPI: ExcalidrawImperativeAPI | null; 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 */ /** @deprecated we recommend using `opts.adapter` instead */
@@ -650,7 +680,13 @@ export const useHandleLibrary = (
}) => { }) => {
const libraryPromise = new Promise<Blob>(async (resolve, reject) => { const libraryPromise = new Promise<Blob>(async (resolve, reject) => {
try { 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(); const blob = await request.blob();
resolve(blob); resolve(blob);
} catch (error: any) { } catch (error: any) {
@@ -678,7 +714,12 @@ export const useHandleLibrary = (
defaultStatus: "published", defaultStatus: "published",
openLibraryMenu: true, openLibraryMenu: true,
}); });
} catch (error) { } catch (error: any) {
excalidrawAPI.updateScene({
appState: {
errorMessage: error.message,
},
});
throw error; throw error;
} finally { } finally {
if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) { if (window.location.hash.includes(URL_HASH_KEYS.addLibrary)) {
+2 -1
View File
@@ -25,6 +25,7 @@ describe("normalizeLink", () => {
expect(normalizeLink("file://")).toBe("file://"); expect(normalizeLink("file://")).toBe("file://");
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)"); 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("&lt;test&gt;");
expect(normalizeLink("test&")).toBe("test&amp;");
}); });
}); });
+1 -4
View File
@@ -1,8 +1,5 @@
import { sanitizeUrl } from "@braintree/sanitize-url"; import { sanitizeUrl } from "@braintree/sanitize-url";
import { sanitizeHTMLAttribute } from "../utils";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
export const normalizeLink = (link: string) => { export const normalizeLink = (link: string) => {
link = link.trim(); link = link.trim();
+2 -1
View File
@@ -40,6 +40,7 @@ import {
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding, isFixedPointBinding,
isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement, isRectangularElement,
isTextElement, isTextElement,
@@ -575,7 +576,7 @@ export const getHoveredElementForBinding = (
zoom, zoom,
// disable fullshape snapping for frame elements so we // disable fullshape snapping for frame elements so we
// can bind to frame children // can bind to frame children
fullShape, fullShape && !isFrameLikeElement(element),
), ),
); );
+19
View File
@@ -556,6 +556,10 @@ export const getArrowheadSize = (arrowhead: Arrowhead): number => {
case "diamond": case "diamond":
case "diamond_outline": case "diamond_outline":
return 12; return 12;
case "crowfoot_many":
case "crowfoot_one":
case "crowfoot_one_or_many":
return 20;
default: default:
return 15; return 15;
} }
@@ -669,6 +673,21 @@ export const getArrowheadPoints = (
const angle = getArrowheadAngle(arrowhead); 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 // Return points
const [x3, y3] = pointRotateRads( const [x3, y3] = pointRotateRads(
pointFrom(xs, ys), pointFrom(xs, ys),
+5 -2
View File
@@ -1,7 +1,11 @@
import { register } from "../actions/register"; import { register } from "../actions/register";
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants"; import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
import type { ExcalidrawProps } from "../types"; import type { ExcalidrawProps } from "../types";
import { getFontString, updateActiveTool } from "../utils"; import {
getFontString,
sanitizeHTMLAttribute,
updateActiveTool,
} from "../utils";
import { setCursorForShape } from "../cursor"; import { setCursorForShape } from "../cursor";
import { newTextElement } from "./newElement"; import { newTextElement } from "./newElement";
import { wrapText } from "./textWrapping"; import { wrapText } from "./textWrapping";
@@ -11,7 +15,6 @@ import type {
ExcalidrawIframeLikeElement, ExcalidrawIframeLikeElement,
IframeData, IframeData,
} from "./types"; } from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
import type { MarkRequired } from "../utility-types"; import type { MarkRequired } from "../utility-types";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
+4 -1
View File
@@ -303,7 +303,10 @@ export type Arrowhead =
| "triangle" | "triangle"
| "triangle_outline" | "triangle_outline"
| "diamond" | "diamond"
| "diamond_outline"; | "diamond_outline"
| "crowfoot_one"
| "crowfoot_many"
| "crowfoot_one_or_many";
export type ExcalidrawLinearElement = _ExcalidrawElementBase & export type ExcalidrawLinearElement = _ExcalidrawElementBase &
Readonly<{ Readonly<{
+4
View File
@@ -46,6 +46,10 @@
"arrowhead_triangle_outline": "Triangle (outline)", "arrowhead_triangle_outline": "Triangle (outline)",
"arrowhead_diamond": "Diamond", "arrowhead_diamond": "Diamond",
"arrowhead_diamond_outline": "Diamond (outline)", "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", "arrowtypes": "Arrow type",
"arrowtype_sharp": "Sharp arrow", "arrowtype_sharp": "Sharp arrow",
"arrowtype_round": "Curved arrow", "arrowtype_round": "Curved arrow",
@@ -449,7 +449,7 @@ const renderElementToSvg = (
symbol.appendChild(image); symbol.appendChild(image);
root.prepend(symbol); (root.querySelector("defs") || root).prepend(symbol);
} }
const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use");
+23
View File
@@ -177,6 +177,19 @@ const getArrowheadShapes = (
return []; 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) { switch (arrowhead) {
case "dot": case "dot":
case "circle": case "circle":
@@ -255,8 +268,12 @@ const getArrowheadShapes = (
), ),
]; ];
} }
case "crowfoot_one":
return generateCrowfootOne(arrowheadPoints, options);
case "bar": case "bar":
case "arrow": case "arrow":
case "crowfoot_many":
case "crowfoot_one_or_many":
default: { default: {
const [x2, y2, x3, y3, x4, y4] = arrowheadPoints; const [x2, y2, x3, y3, x4, y4] = arrowheadPoints;
@@ -272,6 +289,12 @@ const getArrowheadShapes = (
return [ return [
generator.line(x3, y3, x2, y2, options), generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options), generator.line(x4, y4, x2, y2, options),
...(arrowhead === "crowfoot_one_or_many"
? generateCrowfootOne(
getArrowheadPoints(element, shape, position, "crowfoot_one"),
options,
)
: []),
]; ];
} }
} }
+163 -53
View File
@@ -18,6 +18,8 @@ import {
SVG_NS, SVG_NS,
THEME, THEME,
THEME_FILTER, THEME_FILTER,
MIME_TYPES,
EXPORT_DATA_TYPES,
} from "../constants"; } from "../constants";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
@@ -39,8 +41,7 @@ import type { RenderableElementsMap } from "./types";
import { syncInvalidIndices } from "../fractionalIndex"; import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import { base64ToString, decode, encode, stringToBase64 } from "../data/encode";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => {
if (element.width <= maxWidth) { if (element.width <= maxWidth) {
@@ -254,6 +255,13 @@ export const exportToCanvas = async (
return canvas; 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 ( export const exportToSvg = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
appState: { appState: {
@@ -302,31 +310,20 @@ export const exportToSvg = async (
exportPadding = 0; 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( const [minX, minY, width, height] = getCanvasSize(
exportingFrame ? [exportingFrame] : getRootElements(elementsForRender), exportingFrame ? [exportingFrame] : getRootElements(elementsForRender),
exportPadding, exportPadding,
); );
// initialize SVG root const offsetX = -minX + exportPadding;
const offsetY = -minY + exportPadding;
// ---------------------------------------------------------------------------
// initialize SVG root element
// ---------------------------------------------------------------------------
const svgRoot = document.createElementNS(SVG_NS, "svg"); const svgRoot = document.createElementNS(SVG_NS, "svg");
svgRoot.setAttribute("version", "1.1"); svgRoot.setAttribute("version", "1.1");
svgRoot.setAttribute("xmlns", SVG_NS); svgRoot.setAttribute("xmlns", SVG_NS);
svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`); svgRoot.setAttribute("viewBox", `0 0 ${width} ${height}`);
@@ -336,53 +333,105 @@ export const exportToSvg = async (
svgRoot.setAttribute("filter", THEME_FILTER); svgRoot.setAttribute("filter", THEME_FILTER);
} }
const offsetX = -minX + exportPadding; const defsElement = svgRoot.ownerDocument.createElementNS(SVG_NS, "defs");
const offsetY = -minY + exportPadding;
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); const frameElements = getFrameLikeElements(elements);
let exportingFrameClipPath = ""; if (frameElements.length) {
const elementsMap = arrayToMap(elements); 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);
exportingFrameClipPath += `<clipPath id=${frame.id}> for (const frame of frameElements) {
<rect transform="translate(${frame.x + offsetX} ${ const clipPath = svgRoot.ownerDocument.createElementNS(
frame.y + offsetY SVG_NS,
}) rotate(${frame.angle} ${cx} ${cy})" "clipPath",
width="${frame.width}" );
height="${frame.height}"
${ clipPath.setAttribute("id", frame.id);
exportingFrame
? "" const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap);
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}` const cx = (x2 - x1) / 2 - (frame.x - x1);
} const cy = (y2 - y1) / 2 - (frame.y - y1);
>
</rect> const rect = svgRoot.ownerDocument.createElementNS(SVG_NS, "rect");
</clipPath>`; 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 const fontFaces = !opts?.skipInliningFonts
? await Fonts.generateFontFaceDeclarations(elements) ? await Fonts.generateFontFaceDeclarations(elements)
: []; : [];
const delimiter = "\n "; // 6 spaces const delimiter = "\n "; // 6 spaces
svgRoot.innerHTML = ` const style = svgRoot.ownerDocument.createElementNS(SVG_NS, "style");
${SVG_EXPORT_TAG} style.classList.add("style-fonts");
${metadata} style.appendChild(
<defs> document.createTextNode(`${delimiter}${fontFaces.join(delimiter)}`),
<style class="style-fonts">${delimiter}${fontFaces.join(delimiter)} );
</style>
${exportingFrameClipPath} defsElement.appendChild(style);
</defs>
`; // ---------------------------------------------------------------------------
// background
// ---------------------------------------------------------------------------
// render background rect // render background rect
if (appState.exportBackground && viewBackgroundColor) { 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("x", "0");
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${width}`);
@@ -391,6 +440,10 @@ export const exportToSvg = async (
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
// ---------------------------------------------------------------------------
// render elements
// ---------------------------------------------------------------------------
const rsvg = rough.svg(svgRoot); const rsvg = rough.svg(svgRoot);
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = opts?.renderEmbeddables ?? false;
@@ -420,9 +473,66 @@ export const exportToSvg = async (
}, },
); );
// ---------------------------------------------------------------------------
return svgRoot; 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 // calculate smallest area to fit the contents in
const getCanvasSize = ( const getCanvasSize = (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],
@@ -1006,14 +1006,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1278240551, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 1278240551,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -1042,14 +1042,14 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 401146281, "versionNonce": 449462985,
"width": 100, "width": 100,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -9792,14 +9792,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 1278240551, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 453191, "versionNonce": 1278240551,
"width": 200, "width": 200,
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -9826,14 +9826,14 @@ exports[`contextMenu element > shows context menu for element > [end of test] el
"roundness": { "roundness": {
"type": 3, "type": 3,
}, },
"seed": 449462985, "seed": 1,
"strokeColor": "#1e1e1e", "strokeColor": "#1e1e1e",
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "rectangle", "type": "rectangle",
"updated": 1, "updated": 1,
"version": 2, "version": 2,
"versionNonce": 401146281, "versionNonce": 449462985,
"width": 200, "width": 200,
"x": 0, "x": 0,
"y": 0, "y": 0,
File diff suppressed because one or more lines are too long
+27 -9
View File
@@ -2,16 +2,17 @@ import React from "react";
import { render, waitFor } from "./test-utils"; import { render, waitFor } from "./test-utils";
import { Excalidraw } from "../index"; import { Excalidraw } from "../index";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { import { encodePngMetadata } from "../data/image";
encodePngMetadata,
encodeSvgMetadata,
decodeSvgMetadata,
} from "../data/image";
import { serializeAsJSON } from "../data/json"; import { serializeAsJSON } from "../data/json";
import { exportToSvg } from "../scene/export"; import {
decodeSvgBase64Payload,
encodeSvgBase64Payload,
exportToSvg,
} from "../scene/export";
import type { FileId } from "../element/types"; import type { FileId } from "../element/types";
import { getDataURL } from "../data/blob"; import { getDataURL } from "../data/blob";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { SVG_NS } from "../constants";
const { h } = window; const { h } = window;
@@ -62,15 +63,32 @@ describe("export", () => {
}); });
it("test encoding/decoding scene for SVG export", async () => { it("test encoding/decoding scene for SVG export", async () => {
const encoded = encodeSvgMetadata({ const metadataElement = document.createElementNS(SVG_NS, "metadata");
text: serializeAsJSON(testElements, h.state, {}, "local"),
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(decoded.elements).toEqual([
expect.objectContaining({ type: "text", text: "😀" }), 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 () => { it("import embedded png (legacy v1)", async () => {
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png")); await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
await waitFor(() => { await waitFor(() => {
+1 -1
View File
@@ -220,7 +220,6 @@ export class API {
| "width" | "width"
| "height" | "height"
| "type" | "type"
| "seed"
| "version" | "version"
| "versionNonce" | "versionNonce"
| "isDeleted" | "isDeleted"
@@ -228,6 +227,7 @@ export class API {
| "link" | "link"
| "updated" | "updated"
> = { > = {
seed: 1,
x, x,
y, y,
frameId: rest.frameId ?? null, frameId: rest.frameId ?? null,
File diff suppressed because one or more lines are too long
+11 -5
View File
@@ -1,13 +1,19 @@
import * as utils from "../utils"; import { isTransparent, sanitizeHTMLAttribute } from "../utils";
describe("Test isTransparent", () => { describe("Test isTransparent", () => {
it("should return true when color is rgb transparent", () => { it("should return true when color is rgb transparent", () => {
expect(utils.isTransparent("#ff00")).toEqual(true); expect(isTransparent("#ff00")).toEqual(true);
expect(utils.isTransparent("#fff00000")).toEqual(true); expect(isTransparent("#fff00000")).toEqual(true);
expect(utils.isTransparent("transparent")).toEqual(true); expect(isTransparent("transparent")).toEqual(true);
}); });
it("should return false when color is not transparent", () => { 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;");
}); });
}); });
+13
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;")
);
};
+3 -2
View File
@@ -1,7 +1,8 @@
import { decodePngMetadata, decodeSvgMetadata } from "../excalidraw/data/image";
import type { ImportedDataState } from "../excalidraw/data/types"; import type { ImportedDataState } from "../excalidraw/data/types";
import * as utils from "../utils"; import * as utils from "../utils";
import { API } from "../excalidraw/tests/helpers/api"; 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 // 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 // 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 svg = svgNode.outerHTML;
const parsedString = decodeSvgMetadata({ svg }); const parsedString = decodeSvgBase64Payload({ svg });
const importedData: ImportedDataState = JSON.parse(parsedString); const importedData: ImportedDataState = JSON.parse(parsedString);
expect(sourceElements.map((x) => x.id)).toEqual( expect(sourceElements.map((x) => x.id)).toEqual(
+5 -2
View File
@@ -158,5 +158,8 @@ const createESMRawBuild = async () => {
await buildProd(rawConfigChunks); await buildProd(rawConfigChunks);
}; };
createESMRawBuild(); // otherwise throws "ERROR: Could not resolve "./subset-worker.chunk"
createESMBrowserBuild(); (async () => {
await createESMRawBuild();
await createESMBrowserBuild();
})();