Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd27e48c13 | |||
| 139b901ff1 |
@@ -12,24 +12,14 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
excalidraw/excalidraw
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: excalidraw/excalidraw:latest
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@docusaurus/core": "2.2.0",
|
||||
"@docusaurus/preset-classic": "2.2.0",
|
||||
"@docusaurus/theme-live-codeblock": "2.2.0",
|
||||
"@excalidraw/excalidraw": "0.15.2",
|
||||
"@excalidraw/excalidraw": "0.15.0",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"docusaurus-plugin-sass": "0.2.3",
|
||||
|
||||
+4
-4
@@ -1631,10 +1631,10 @@
|
||||
url-loader "^4.1.1"
|
||||
webpack "^5.73.0"
|
||||
|
||||
"@excalidraw/excalidraw@0.15.2":
|
||||
version "0.15.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.2.tgz#7dba4f6e10c52015a007efb75a9fc1afe598574c"
|
||||
integrity sha512-rTI02kgWSTXiUdIkBxt9u/581F3eXcqQgJdIxmz54TFtG3ughoxO5fr4t7Fr2LZIturBPqfocQHGKZ0t2KLKgw==
|
||||
"@excalidraw/excalidraw@0.15.0":
|
||||
version "0.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/excalidraw/-/excalidraw-0.15.0.tgz#47170de8d3ff006e9d09dfede2815682b0d4485b"
|
||||
integrity sha512-PJmh1VcuRHG4l+Zgt9qhezxrJ16tYCZFZ8if5IEfmTL9A/7c5mXxY/qrPTqiGlVC7jYs+ciePXQ0YUDzfOfbzw==
|
||||
|
||||
"@hapi/hoek@^9.0.0":
|
||||
version "9.3.0"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/tunnel-rat": "0.1.1",
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
|
||||
@@ -18,7 +18,7 @@ export const actionCopy = register({
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
|
||||
copyToClipboard(selectedElements, app.files);
|
||||
copyToClipboard(selectedElements, appState, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
|
||||
+11
-27
@@ -2,12 +2,12 @@ import {
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { BinaryFiles } from "./types";
|
||||
import { AppState, BinaryFiles } from "./types";
|
||||
import { SVG_EXPORT_TAG } from "./scene/export";
|
||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { isPromiseLike, isTestEnv } from "./utils";
|
||||
import { isPromiseLike } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
||||
@@ -55,40 +55,24 @@ const clipboardContainsElements = (
|
||||
|
||||
export const copyToClipboard = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
let foundFile = false;
|
||||
|
||||
const _files = elements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element)) {
|
||||
foundFile = true;
|
||||
if (files && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles);
|
||||
|
||||
if (foundFile && !files) {
|
||||
console.warn(
|
||||
"copyToClipboard: attempting to file element(s) without providing associated `files` object.",
|
||||
);
|
||||
}
|
||||
|
||||
// select binded text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements,
|
||||
files: files ? _files : undefined,
|
||||
files: files
|
||||
? elements.reduce((acc, element) => {
|
||||
if (isInitializedImageElement(element) && files[element.fileId]) {
|
||||
acc[element.fileId] = files[element.fileId];
|
||||
}
|
||||
return acc;
|
||||
}, {} as BinaryFiles)
|
||||
: undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
|
||||
if (isTestEnv()) {
|
||||
return json;
|
||||
}
|
||||
|
||||
CLIPBOARD = json;
|
||||
|
||||
try {
|
||||
PREFER_APP_CLIPBOARD = false;
|
||||
await copyTextToSystemClipboard(json);
|
||||
|
||||
+13
-20
@@ -60,7 +60,6 @@ import {
|
||||
ENV,
|
||||
EVENT,
|
||||
GRID_SIZE,
|
||||
IMAGE_MIME_TYPES,
|
||||
IMAGE_RENDER_TIMEOUT,
|
||||
isAndroid,
|
||||
isBrave,
|
||||
@@ -280,13 +279,12 @@ import {
|
||||
} from "../element/textElement";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
import {
|
||||
normalizeLink,
|
||||
showHyperlinkTooltip,
|
||||
hideHyperlinkToolip,
|
||||
Hyperlink,
|
||||
isPointHittingLinkIcon,
|
||||
isLocalLink,
|
||||
} from "../element/Hyperlink";
|
||||
import { isLocalLink, normalizeLink } from "../data/url";
|
||||
import { shouldShowBoundingBox } from "../element/transformHandles";
|
||||
import { Fonts } from "../scene/Fonts";
|
||||
import { actionPaste } from "../actions/actionClipboard";
|
||||
@@ -1590,7 +1588,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements: data.elements,
|
||||
files: data.files || null,
|
||||
position: "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
} else if (data.text) {
|
||||
this.addTextFromPaste(data.text, isPlainPaste);
|
||||
@@ -1604,7 +1601,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
files: BinaryFiles | null;
|
||||
position: { clientX: number; clientY: number } | "cursor" | "center";
|
||||
retainSeed?: boolean;
|
||||
}) => {
|
||||
const elements = restoreElements(opts.elements, null);
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
@@ -1642,9 +1638,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
y: element.y + gridY - minY,
|
||||
});
|
||||
}),
|
||||
{
|
||||
randomizeSeed: !opts.retainSeed,
|
||||
},
|
||||
);
|
||||
|
||||
const nextElements = [
|
||||
@@ -2953,12 +2946,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.device.isMobile,
|
||||
);
|
||||
if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) {
|
||||
const url = this.hitLinkElement.link;
|
||||
let url = this.hitLinkElement.link;
|
||||
if (url) {
|
||||
url = normalizeLink(url);
|
||||
let customEvent;
|
||||
if (this.props.onLinkOpen) {
|
||||
customEvent = wrapEvent(EVENT.EXCALIDRAW_LINK, event.nativeEvent);
|
||||
this.props.onLinkOpen(this.hitLinkElement, customEvent);
|
||||
this.props.onLinkOpen(
|
||||
{
|
||||
...this.hitLinkElement,
|
||||
link: url,
|
||||
},
|
||||
customEvent,
|
||||
);
|
||||
}
|
||||
if (!customEvent?.defaultPrevented) {
|
||||
const target = isLocalLink(url) ? "_self" : "_blank";
|
||||
@@ -2966,7 +2966,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// https://mathiasbynens.github.io/rel-noopener/
|
||||
if (newWindow) {
|
||||
newWindow.opener = null;
|
||||
newWindow.location = normalizeLink(url);
|
||||
newWindow.location = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4726,12 +4726,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.drag.hasOccurred = true;
|
||||
// prevent dragging even if we're no longer holding cmd/ctrl otherwise
|
||||
// it would have weird results (stuff jumping all over the screen)
|
||||
// Checking for editingElement to avoid jump while editing on mobile #6503
|
||||
if (
|
||||
selectedElements.length > 0 &&
|
||||
!pointerDownState.withCmdOrCtrl &&
|
||||
!this.state.editingElement
|
||||
) {
|
||||
if (selectedElements.length > 0 && !pointerDownState.withCmdOrCtrl) {
|
||||
const [dragX, dragY] = getGridPoint(
|
||||
pointerCoords.x - pointerDownState.drag.offset.x,
|
||||
pointerCoords.y - pointerDownState.drag.offset.y,
|
||||
@@ -5754,9 +5749,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
const imageFile = await fileOpen({
|
||||
description: "Image",
|
||||
extensions: Object.keys(
|
||||
IMAGE_MIME_TYPES,
|
||||
) as (keyof typeof IMAGE_MIME_TYPES)[],
|
||||
extensions: ["jpg", "png", "svg", "gif"],
|
||||
});
|
||||
|
||||
const imageElement = this.createImageElement({
|
||||
|
||||
@@ -183,7 +183,6 @@
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
border: 0;
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
font-family: inherit;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 0.2fr;
|
||||
|
||||
@@ -102,7 +102,7 @@ const LibraryMenuItems = ({
|
||||
...item,
|
||||
// duplicate each library item before inserting on canvas to confine
|
||||
// ids and bindings to each library item. See #6465
|
||||
elements: duplicateElements(item.elements, { randomizeSeed: true }),
|
||||
elements: duplicateElements(item.elements),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
|
||||
// container in body where the actual tooltip is appended to
|
||||
.excalidraw-tooltip {
|
||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
|
||||
|
||||
+16
-16
@@ -105,30 +105,20 @@ export const CANVAS_ONLY_ACTIONS = ["selectAll"];
|
||||
|
||||
export const GRID_SIZE = 20; // TODO make it configurable?
|
||||
|
||||
export const IMAGE_MIME_TYPES = {
|
||||
export const MIME_TYPES = {
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
json: "application/json",
|
||||
svg: "image/svg+xml",
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
png: "image/png",
|
||||
"excalidraw.png": "image/png",
|
||||
jpg: "image/jpeg",
|
||||
gif: "image/gif",
|
||||
webp: "image/webp",
|
||||
bmp: "image/bmp",
|
||||
ico: "image/x-icon",
|
||||
avif: "image/avif",
|
||||
jfif: "image/jfif",
|
||||
} as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
// image-encoded excalidraw data
|
||||
"excalidraw.svg": "image/svg+xml",
|
||||
"excalidraw.png": "image/png",
|
||||
// binary
|
||||
binary: "application/octet-stream",
|
||||
// image
|
||||
...IMAGE_MIME_TYPES,
|
||||
} as const;
|
||||
|
||||
export const EXPORT_DATA_TYPES = {
|
||||
@@ -199,6 +189,16 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
||||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const ALLOWED_IMAGE_MIME_TYPES = [
|
||||
MIME_TYPES.png,
|
||||
MIME_TYPES.jpg,
|
||||
MIME_TYPES.svg,
|
||||
MIME_TYPES.gif,
|
||||
MIME_TYPES.webp,
|
||||
MIME_TYPES.bmp,
|
||||
MIME_TYPES.ico,
|
||||
] as const;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -354,7 +354,6 @@
|
||||
border-radius: var(--space-factor);
|
||||
border: 1px solid var(--button-gray-2);
|
||||
font-size: 0.8rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
appearance: none;
|
||||
background-image: var(--dropdown-icon);
|
||||
@@ -414,7 +413,6 @@
|
||||
bottom: 30px;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: all;
|
||||
font-family: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
|
||||
+6
-4
@@ -1,6 +1,6 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { cleanAppStateForExport } from "../appState";
|
||||
import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
|
||||
import { clearElementsForExport } from "../element";
|
||||
import { ExcalidrawElement, FileId } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
@@ -117,9 +117,11 @@ export const isImageFileHandle = (handle: FileSystemHandle | null) => {
|
||||
|
||||
export const isSupportedImageFile = (
|
||||
blob: Blob | null | undefined,
|
||||
): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
|
||||
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
||||
const { type } = blob || {};
|
||||
return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
|
||||
return (
|
||||
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
||||
);
|
||||
};
|
||||
|
||||
export const loadSceneOrLibraryFromBlob = async (
|
||||
@@ -155,7 +157,7 @@ export const loadSceneOrLibraryFromBlob = async (
|
||||
},
|
||||
localAppState,
|
||||
localElements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{ repairBindings: true, refreshDimensions: true },
|
||||
),
|
||||
};
|
||||
} else if (isValidLibrary(data)) {
|
||||
|
||||
+10
-1
@@ -8,7 +8,16 @@ import { EVENT, MIME_TYPES } from "../constants";
|
||||
import { AbortError } from "../errors";
|
||||
import { debounce } from "../utils";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
type FILE_EXTENSION =
|
||||
| "gif"
|
||||
| "jpg"
|
||||
| "png"
|
||||
| "excalidraw.png"
|
||||
| "svg"
|
||||
| "excalidraw.svg"
|
||||
| "json"
|
||||
| "excalidraw"
|
||||
| "excalidrawlib";
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
|
||||
|
||||
+2
-1
@@ -40,6 +40,7 @@ import {
|
||||
getDefaultLineHeight,
|
||||
measureBaseline,
|
||||
} from "../element/textElement";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
@@ -139,7 +140,7 @@ const restoreElementWithProperties = <
|
||||
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
link: element.link ?? null,
|
||||
link: element.link ? normalizeLink(element.link) : null,
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
describe("normalizeLink", () => {
|
||||
// NOTE not an extensive XSS test suite, just to check if we're not
|
||||
// regressing in sanitization
|
||||
it("should sanitize links", () => {
|
||||
expect(
|
||||
// eslint-disable-next-line no-script-url
|
||||
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
|
||||
// eslint-disable-next-line no-script-url
|
||||
`javascript:`,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(normalizeLink("ola")).toBe("ola");
|
||||
expect(normalizeLink(" ola")).toBe("ola");
|
||||
|
||||
expect(normalizeLink("https://www.excalidraw.com")).toBe(
|
||||
"https://www.excalidraw.com",
|
||||
);
|
||||
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
|
||||
expect(normalizeLink("/ola")).toBe("/ola");
|
||||
expect(normalizeLink("http://test")).toBe("http://test");
|
||||
expect(normalizeLink("ftp://test")).toBe("ftp://test");
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
return sanitizeUrl(link);
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
@@ -29,6 +29,7 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { isPointHittingElementBoundingBox } from "./collision";
|
||||
import { getElementAbsoluteCoords } from "./";
|
||||
import { isLocalLink, normalizeLink } from "../data/url";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
@@ -166,7 +167,7 @@ export const Hyperlink = ({
|
||||
/>
|
||||
) : (
|
||||
<a
|
||||
href={element.link || ""}
|
||||
href={normalizeLink(element.link || "")}
|
||||
className={clsx("excalidraw-hyperlinkContainer-link", {
|
||||
"d-none": isEditing,
|
||||
})}
|
||||
@@ -177,7 +178,13 @@ export const Hyperlink = ({
|
||||
EVENT.EXCALIDRAW_LINK,
|
||||
event.nativeEvent,
|
||||
);
|
||||
onLinkOpen(element, customEvent);
|
||||
onLinkOpen(
|
||||
{
|
||||
...element,
|
||||
link: normalizeLink(element.link),
|
||||
},
|
||||
customEvent,
|
||||
);
|
||||
if (customEvent.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -231,21 +238,6 @@ const getCoordsForPopover = (
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (link) {
|
||||
// prefix with protocol if not fully-qualified
|
||||
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
|
||||
link = `https://${link}`;
|
||||
}
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
|
||||
export const actionLink = register({
|
||||
name: "hyperlink",
|
||||
perform: (elements, appState) => {
|
||||
|
||||
@@ -40,11 +40,7 @@ import { isBindingElement } from "./typeChecks";
|
||||
import { shouldRotateWithDiscreteAngle } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
DRAGGING_THRESHOLD,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { DRAGGING_THRESHOLD } from "../constants";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
const editorMidPointsCache: {
|
||||
@@ -1308,16 +1304,6 @@ export class LinearElementEditor {
|
||||
}
|
||||
x = midSegmentMidpoint[0] - boundTextElement.width / 2;
|
||||
y = midSegmentMidpoint[1] - boundTextElement.height / 2;
|
||||
if (element.points.length === 2) {
|
||||
if (boundTextElement.verticalAlign === VERTICAL_ALIGN.TOP) {
|
||||
y =
|
||||
midSegmentMidpoint[1] -
|
||||
boundTextElement.height -
|
||||
BOUND_TEXT_PADDING * 2;
|
||||
} else if (boundTextElement.verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
y = midSegmentMidpoint[1] + BOUND_TEXT_PADDING * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
measureText,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
getBoundTextMaxWidth,
|
||||
getMaxContainerWidth,
|
||||
getDefaultLineHeight,
|
||||
} from "./textElement";
|
||||
import {
|
||||
@@ -310,7 +310,7 @@ export const refreshTextDimensions = (
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getBoundTextMaxWidth(container),
|
||||
getMaxContainerWidth(container),
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
@@ -539,16 +539,8 @@ export const duplicateElement = <TElement extends ExcalidrawElement>(
|
||||
* it's advised to supply the whole elements array, or sets of elements that
|
||||
* are encapsulated (such as library items), if the purpose is to retain
|
||||
* bindings to the cloned elements intact.
|
||||
*
|
||||
* NOTE by default does not randomize or regenerate anything except the id.
|
||||
*/
|
||||
export const duplicateElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
opts?: {
|
||||
/** NOTE also updates version flags and `updated` */
|
||||
randomizeSeed: boolean;
|
||||
},
|
||||
) => {
|
||||
export const duplicateElements = (elements: readonly ExcalidrawElement[]) => {
|
||||
const clonedElements: ExcalidrawElement[] = [];
|
||||
|
||||
const origElementsMap = arrayToMap(elements);
|
||||
@@ -582,11 +574,6 @@ export const duplicateElements = (
|
||||
|
||||
clonedElement.id = maybeGetNewId(element.id)!;
|
||||
|
||||
if (opts?.randomizeSeed) {
|
||||
clonedElement.seed = randomInteger();
|
||||
bumpVersion(clonedElement);
|
||||
}
|
||||
|
||||
if (clonedElement.groupIds) {
|
||||
clonedElement.groupIds = clonedElement.groupIds.map((groupId) => {
|
||||
if (!groupNewIdsMap.has(groupId)) {
|
||||
|
||||
@@ -44,10 +44,10 @@ import {
|
||||
getBoundTextElementId,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
getMaxContainerWidth,
|
||||
getApproxMinLineHeight,
|
||||
measureText,
|
||||
getBoundTextMaxHeight,
|
||||
getMaxContainerHeight,
|
||||
} from "./textElement";
|
||||
|
||||
export const normalizeAngle = (angle: number): number => {
|
||||
@@ -204,7 +204,7 @@ const measureFontSizeFromWidth = (
|
||||
if (hasContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (container) {
|
||||
width = getBoundTextMaxWidth(container);
|
||||
width = getMaxContainerWidth(container);
|
||||
}
|
||||
}
|
||||
const nextFontSize = element.fontSize * (nextWidth / width);
|
||||
@@ -435,8 +435,8 @@ export const resizeSingleElement = (
|
||||
|
||||
const nextFont = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
getBoundTextMaxWidth(updatedElement),
|
||||
getBoundTextMaxHeight(updatedElement, boundTextElement),
|
||||
getMaxContainerWidth(updatedElement),
|
||||
getMaxContainerHeight(updatedElement),
|
||||
);
|
||||
if (nextFont === null) {
|
||||
return;
|
||||
@@ -718,10 +718,10 @@ const resizeMultipleElements = (
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (element.orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
? getMaxContainerWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
? getMaxContainerHeight(updatedElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@ import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
getContainerCoords,
|
||||
getBoundTextMaxWidth,
|
||||
getBoundTextMaxHeight,
|
||||
getMaxContainerWidth,
|
||||
getMaxContainerHeight,
|
||||
wrapText,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
parseTokens,
|
||||
} from "./textElement";
|
||||
import { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
||||
import { FontString } from "./types";
|
||||
|
||||
describe("Test wrapText", () => {
|
||||
const font = "20px Cascadia, width: Segoe UI Emoji" as FontString;
|
||||
@@ -311,7 +311,7 @@ describe("Test measureText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxWidth", () => {
|
||||
describe("Test getMaxContainerWidth", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
@@ -319,76 +319,39 @@ describe("Test measureText", () => {
|
||||
|
||||
it("should return max width when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(168);
|
||||
expect(getMaxContainerWidth(container)).toBe(168);
|
||||
});
|
||||
|
||||
it("should return max width when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(116);
|
||||
expect(getMaxContainerWidth(container)).toBe(116);
|
||||
});
|
||||
|
||||
it("should return max width when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxWidth(container)).toBe(79);
|
||||
expect(getMaxContainerWidth(container)).toBe(79);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test getBoundTextMaxHeight", () => {
|
||||
describe("Test getMaxContainerHeight", () => {
|
||||
const params = {
|
||||
width: 178,
|
||||
height: 194,
|
||||
id: '"container-id',
|
||||
};
|
||||
|
||||
const boundTextElement = API.createElement({
|
||||
type: "text",
|
||||
id: "text-id",
|
||||
x: 560.51171875,
|
||||
y: 202.033203125,
|
||||
width: 154,
|
||||
height: 175,
|
||||
fontSize: 20,
|
||||
fontFamily: 1,
|
||||
text: "Excalidraw is a\nvirtual \nopensource \nwhiteboard for \nsketching \nhand-drawn like\ndiagrams",
|
||||
textAlign: "center",
|
||||
verticalAlign: "middle",
|
||||
containerId: params.id,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
it("should return max height when container is rectangle", () => {
|
||||
const container = API.createElement({ type: "rectangle", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(184);
|
||||
expect(getMaxContainerHeight(container)).toBe(184);
|
||||
});
|
||||
|
||||
it("should return max height when container is ellipse", () => {
|
||||
const container = API.createElement({ type: "ellipse", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(127);
|
||||
expect(getMaxContainerHeight(container)).toBe(127);
|
||||
});
|
||||
|
||||
it("should return max height when container is diamond", () => {
|
||||
const container = API.createElement({ type: "diamond", ...params });
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(87);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
});
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(194);
|
||||
});
|
||||
|
||||
it("should return max height when container is arrow and height is less than threshold", () => {
|
||||
const container = API.createElement({
|
||||
type: "arrow",
|
||||
...params,
|
||||
height: 70,
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
});
|
||||
|
||||
expect(getBoundTextMaxHeight(container, boundTextElement)).toBe(
|
||||
boundTextElement.height,
|
||||
);
|
||||
expect(getMaxContainerHeight(container)).toBe(87);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+42
-35
@@ -65,7 +65,7 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
boundTextUpdates.text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
@@ -83,22 +83,27 @@ export const redrawTextBoundingBox = (
|
||||
boundTextUpdates.baseline = metrics.baseline;
|
||||
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
if (isArrowElement(container)) {
|
||||
const centerX = textElement.x + textElement.width / 2;
|
||||
const centerY = textElement.y + textElement.height / 2;
|
||||
const diffWidth = metrics.width - textElement.width;
|
||||
const diffHeight = metrics.height - textElement.height;
|
||||
boundTextUpdates.x = centerY - (textElement.height + diffHeight) / 2;
|
||||
boundTextUpdates.y = centerX - (textElement.width + diffWidth) / 2;
|
||||
} else {
|
||||
const containerDims = getContainerDims(container);
|
||||
let maxContainerHeight = getMaxContainerHeight(container);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
if (!isArrowElement(container)) {
|
||||
let nextHeight = containerDims.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: nextHeight });
|
||||
maxContainerHeight = getMaxContainerHeight(container);
|
||||
updateOriginalContainerCache(container.id, nextHeight);
|
||||
}
|
||||
const updatedTextElement = {
|
||||
...textElement,
|
||||
...boundTextUpdates,
|
||||
@@ -178,11 +183,8 @@ export const handleBindTextResize = (
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
const maxWidth = getMaxContainerWidth(container);
|
||||
const maxHeight = getMaxContainerHeight(container);
|
||||
let containerHeight = containerDims.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
@@ -254,8 +256,8 @@ export const computeBoundTextPosition = (
|
||||
);
|
||||
}
|
||||
const containerCoords = getContainerCoords(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(container, boundTextElement);
|
||||
const maxContainerWidth = getBoundTextMaxWidth(container);
|
||||
const maxContainerHeight = getMaxContainerHeight(container);
|
||||
const maxContainerWidth = getMaxContainerWidth(container);
|
||||
|
||||
let x;
|
||||
let y;
|
||||
@@ -795,11 +797,7 @@ export const shouldAllowVerticalAlign = (
|
||||
const hasBoundContainer = isBoundToContainer(element);
|
||||
if (hasBoundContainer) {
|
||||
const container = getContainerElement(element);
|
||||
if (
|
||||
isTextElement(element) &&
|
||||
isArrowElement(container) &&
|
||||
container.points.length > 2
|
||||
) {
|
||||
if (isTextElement(element) && isArrowElement(container)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -892,10 +890,18 @@ export const computeContainerDimensionForBoundText = (
|
||||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
export const getMaxContainerWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
if (isArrowElement(container)) {
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
const containerWidth = width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerWidth <= 0) {
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.width;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return containerWidth;
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
@@ -912,15 +918,16 @@ export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
return width - BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxHeight = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
export const getMaxContainerHeight = (container: ExcalidrawElement) => {
|
||||
const height = getContainerDims(container).height;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
return boundTextElement.height;
|
||||
const boundText = getBoundTextElement(container);
|
||||
if (boundText) {
|
||||
return boundText.height;
|
||||
}
|
||||
return BOUND_TEXT_PADDING * 8 * 2;
|
||||
}
|
||||
return height;
|
||||
}
|
||||
|
||||
+13
-13
@@ -32,8 +32,8 @@ import {
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
} from "./textElement";
|
||||
@@ -205,11 +205,8 @@ export const textWysiwyg = ({
|
||||
}
|
||||
}
|
||||
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
maxHeight = getMaxContainerHeight(container);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
@@ -233,14 +230,17 @@ export const textWysiwyg = ({
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// update y coord as you type, not needed for arrow as we calculate
|
||||
// position from the container element for editor and canvas when rendering labelled arrows
|
||||
else if (!isArrowElement(container)) {
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
coordY = containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
@@ -377,7 +377,7 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
`${editable.value}${data}`,
|
||||
font,
|
||||
getBoundTextMaxWidth(container),
|
||||
getMaxContainerWidth(container),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
editable.style.width = `${width}px`;
|
||||
@@ -394,7 +394,7 @@ export const textWysiwyg = ({
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getBoundTextMaxWidth(container!),
|
||||
getMaxContainerWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
|
||||
@@ -263,7 +263,7 @@ export const loadScene = async (
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
{ repairBindings: true, refreshDimensions: true },
|
||||
);
|
||||
} else {
|
||||
data = restore(localDataState || null, null, null, {
|
||||
|
||||
@@ -11,6 +11,10 @@ The change should be grouped under one of the below section and must contain PR
|
||||
Please add the latest change on the top under the correct section.
|
||||
-->
|
||||
|
||||
## 0.15.3 (2023-08-16)
|
||||
|
||||
- Properly sanitize element `link` urls. [#6728](https://github.com/excalidraw/excalidraw/pull/6728).
|
||||
|
||||
## 0.15.2 (2023-04-20)
|
||||
|
||||
### Docs
|
||||
|
||||
@@ -245,3 +245,4 @@ export { MainMenu };
|
||||
export { useDevice } from "../../components/App";
|
||||
export { WelcomeScreen };
|
||||
export { LiveCollaborationTrigger };
|
||||
export { normalizeLink } from "../../data/url";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@excalidraw/excalidraw",
|
||||
"version": "0.15.2",
|
||||
"version": "0.15.3",
|
||||
"main": "main.js",
|
||||
"types": "types/packages/excalidraw/index.d.ts",
|
||||
"files": [
|
||||
|
||||
@@ -220,7 +220,15 @@ export const exportToClipboard = async (
|
||||
} else if (opts.type === "png") {
|
||||
await copyBlobToClipboardAsPng(exportToBlob(opts));
|
||||
} else if (opts.type === "json") {
|
||||
await copyToClipboard(opts.elements, opts.files);
|
||||
const appState = {
|
||||
offsetTop: 0,
|
||||
offsetLeft: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
...getDefaultAppState(),
|
||||
...opts.appState,
|
||||
};
|
||||
await copyToClipboard(opts.elements, appState, opts.files);
|
||||
} else {
|
||||
throw new Error("Invalid export type");
|
||||
}
|
||||
|
||||
@@ -44,10 +44,11 @@ import {
|
||||
getContainerCoords,
|
||||
getContainerElement,
|
||||
getLineHeightInPx,
|
||||
getBoundTextMaxHeight,
|
||||
getBoundTextMaxWidth,
|
||||
getMaxContainerHeight,
|
||||
getMaxContainerWidth,
|
||||
} from "../element/textElement";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { normalizeLink } from "../data/url";
|
||||
|
||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||
// as a temp hack to make images in dark theme look closer to original
|
||||
@@ -868,17 +869,14 @@ const drawElementFromCanvas = (
|
||||
"true" &&
|
||||
hasBoundTextElement(element)
|
||||
) {
|
||||
const textElement = getBoundTextElement(
|
||||
element,
|
||||
) as ExcalidrawTextElementWithContainer;
|
||||
const coords = getContainerCoords(element);
|
||||
context.strokeStyle = "#c92a2a";
|
||||
context.lineWidth = 3;
|
||||
context.strokeRect(
|
||||
(coords.x + renderConfig.scrollX) * window.devicePixelRatio,
|
||||
(coords.y + renderConfig.scrollY) * window.devicePixelRatio,
|
||||
getBoundTextMaxWidth(element) * window.devicePixelRatio,
|
||||
getBoundTextMaxHeight(element, textElement) * window.devicePixelRatio,
|
||||
getMaxContainerWidth(element) * window.devicePixelRatio,
|
||||
getMaxContainerHeight(element) * window.devicePixelRatio,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1143,7 +1141,7 @@ export const renderElementToSvg = (
|
||||
// if the element has a link, create an anchor tag and make that the new root
|
||||
if (element.link) {
|
||||
const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a");
|
||||
anchorTag.setAttribute("href", element.link);
|
||||
anchorTag.setAttribute("href", normalizeLink(element.link));
|
||||
root.appendChild(anchorTag);
|
||||
root = anchorTag;
|
||||
}
|
||||
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
import { isTextElement, refreshTextDimensions } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { invalidateShapeForElement } from "../renderer/renderElement";
|
||||
import { getFontString } from "../utils";
|
||||
@@ -53,7 +52,7 @@ export class Fonts {
|
||||
let didUpdate = false;
|
||||
|
||||
this.scene.mapElements((element) => {
|
||||
if (isTextElement(element) && !isBoundToContainer(element)) {
|
||||
if (isTextElement(element)) {
|
||||
invalidateShapeForElement(element);
|
||||
didUpdate = true;
|
||||
return newElementWith(element, {
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
render,
|
||||
waitFor,
|
||||
GlobalTestState,
|
||||
createPasteEvent,
|
||||
} from "./test-utils";
|
||||
import { render, waitFor, GlobalTestState } from "./test-utils";
|
||||
import { Pointer, Keyboard } from "./helpers/ui";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { KEYS } from "../keys";
|
||||
@@ -14,8 +9,6 @@ import {
|
||||
} from "../element/textElement";
|
||||
import { getElementBounds } from "../element";
|
||||
import { NormalizedZoomValue } from "../types";
|
||||
import { API } from "./helpers/api";
|
||||
import { copyToClipboard } from "../clipboard";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
@@ -42,28 +35,38 @@ const setClipboardText = (text: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const sendPasteEvent = (text?: string) => {
|
||||
const clipboardEvent = createPasteEvent(
|
||||
text || (() => window.navigator.clipboard.readText()),
|
||||
);
|
||||
const sendPasteEvent = () => {
|
||||
const clipboardEvent = new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
|
||||
// set `clipboardData` properties.
|
||||
// @ts-ignore
|
||||
clipboardEvent.clipboardData = {
|
||||
getData: () => window.navigator.clipboard.readText(),
|
||||
files: [],
|
||||
};
|
||||
|
||||
document.dispatchEvent(clipboardEvent);
|
||||
};
|
||||
|
||||
const pasteWithCtrlCmdShiftV = (text?: string) => {
|
||||
const pasteWithCtrlCmdShiftV = () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
//triggering keydown with an empty clipboard
|
||||
Keyboard.keyPress(KEYS.V);
|
||||
//triggering paste event with faked clipboard
|
||||
sendPasteEvent(text);
|
||||
sendPasteEvent();
|
||||
});
|
||||
};
|
||||
|
||||
const pasteWithCtrlCmdV = (text?: string) => {
|
||||
const pasteWithCtrlCmdV = () => {
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
//triggering keydown with an empty clipboard
|
||||
Keyboard.keyPress(KEYS.V);
|
||||
//triggering paste event with faked clipboard
|
||||
sendPasteEvent(text);
|
||||
sendPasteEvent();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -86,32 +89,6 @@ beforeEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("general paste behavior", () => {
|
||||
it("should randomize seed on paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||
|
||||
pasteWithCtrlCmdV(clipboardJSON);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].seed).not.toBe(rectangle.seed);
|
||||
});
|
||||
});
|
||||
|
||||
it("should retain seed on shift-paste", async () => {
|
||||
const rectangle = API.createElement({ type: "rectangle" });
|
||||
const clipboardJSON = (await copyToClipboard([rectangle], null))!;
|
||||
|
||||
// assert we don't randomize seed on shift-paste
|
||||
pasteWithCtrlCmdShiftV(clipboardJSON);
|
||||
await waitFor(() => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(h.elements[0].seed).toBe(rectangle.seed);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("paste text as single lines", () => {
|
||||
it("should create an element for each line when copying with Ctrl/Cmd+V", async () => {
|
||||
const text = "sajgfakfn\naaksfnknas\nakefnkasf";
|
||||
|
||||
+14
-7
@@ -1,10 +1,5 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import {
|
||||
createPasteEvent,
|
||||
GlobalTestState,
|
||||
render,
|
||||
waitFor,
|
||||
} from "./test-utils";
|
||||
import { GlobalTestState, render, waitFor } from "./test-utils";
|
||||
import { UI, Pointer } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
import { actionFlipHorizontal, actionFlipVertical } from "../actions";
|
||||
@@ -685,7 +680,19 @@ describe("freedraw", () => {
|
||||
describe("image", () => {
|
||||
const createImage = async () => {
|
||||
const sendPasteEvent = (file?: File) => {
|
||||
const clipboardEvent = createPasteEvent("", file ? [file] : []);
|
||||
const clipboardEvent = new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
|
||||
// set `clipboardData` properties.
|
||||
// @ts-ignore
|
||||
clipboardEvent.clipboardData = {
|
||||
getData: () => window.navigator.clipboard.readText(),
|
||||
files: [file],
|
||||
};
|
||||
|
||||
document.dispatchEvent(clipboardEvent);
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import { resize, rotate } from "./utils";
|
||||
import {
|
||||
getBoundTextElementPosition,
|
||||
wrapText,
|
||||
getBoundTextMaxWidth,
|
||||
getMaxContainerWidth,
|
||||
} from "../element/textElement";
|
||||
import * as textElementUtils from "../element/textElement";
|
||||
import { ROUNDNESS, VERTICAL_ALIGN } from "../constants";
|
||||
@@ -729,11 +729,10 @@ describe("Test Linear Elements", () => {
|
||||
type: "text",
|
||||
x: 0,
|
||||
y: 0,
|
||||
text: wrapText(text, font, getBoundTextMaxWidth(container)),
|
||||
text: wrapText(text, font, getMaxContainerWidth(container)),
|
||||
containerId: container.id,
|
||||
width: 30,
|
||||
height: 20,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
}) as ExcalidrawTextElementWithContainer;
|
||||
|
||||
container = {
|
||||
@@ -1109,17 +1108,9 @@ describe("Test Linear Elements", () => {
|
||||
`);
|
||||
});
|
||||
|
||||
it("should render vertical align tool when element selected only for two pointer arrow", () => {
|
||||
let arrow = createTwoPointerLinearElement("arrow");
|
||||
|
||||
createBoundTextElement(DEFAULT_TEXT, arrow);
|
||||
API.setSelectedElements([arrow]);
|
||||
|
||||
expect(queryByTestId(container, "align-top")).not.toBeNull();
|
||||
expect(queryByTestId(container, "align-middle")).not.toBeNull();
|
||||
expect(queryByTestId(container, "align-bottom")).not.toBeNull();
|
||||
|
||||
arrow = createThreePointerLinearElement("arrow");
|
||||
it("should not render vertical align tool when element selected", () => {
|
||||
createTwoPointerLinearElement("arrow");
|
||||
const arrow = h.elements[0] as ExcalidrawLinearElement;
|
||||
|
||||
createBoundTextElement(DEFAULT_TEXT, arrow);
|
||||
API.setSelectedElements([arrow]);
|
||||
@@ -1158,7 +1149,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(rect.x).toBe(400);
|
||||
expect(rect.y).toBe(0);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard collaboration
|
||||
made easy"
|
||||
@@ -1181,7 +1172,7 @@ describe("Test Linear Elements", () => {
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
wrapText(textElement.originalText, font, getBoundTextMaxWidth(arrow)),
|
||||
wrapText(textElement.originalText, font, getMaxContainerWidth(arrow)),
|
||||
).toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
|
||||
@@ -190,24 +190,3 @@ export const toggleMenu = (container: HTMLElement) => {
|
||||
// open menu
|
||||
fireEvent.click(container.querySelector(".dropdown-menu-button")!);
|
||||
};
|
||||
|
||||
export const createPasteEvent = (
|
||||
text:
|
||||
| string
|
||||
| /* getData function */ ((type: string) => string | Promise<string>),
|
||||
files?: File[],
|
||||
) => {
|
||||
return Object.assign(
|
||||
new Event("paste", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
{
|
||||
clipboardData: {
|
||||
getData: typeof text === "string" ? () => text : text,
|
||||
files: files || [],
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
+4
-4
@@ -29,9 +29,9 @@ import { isOverScrollBars } from "./scene";
|
||||
import { MaybeTransformHandleType } from "./element/transformHandles";
|
||||
import Library from "./data/library";
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||
import { ContextMenuItems } from "./components/ContextMenu";
|
||||
import { Merge, ForwardRef, ValueOf } from "./utility-types";
|
||||
import { Merge, ForwardRef } from "./utility-types";
|
||||
import React from "react";
|
||||
|
||||
export type Point = Readonly<RoughPoint>;
|
||||
@@ -60,7 +60,7 @@ export type DataURL = string & { _brand: "DataURL" };
|
||||
|
||||
export type BinaryFileData = {
|
||||
mimeType:
|
||||
| ValueOf<typeof IMAGE_MIME_TYPES>
|
||||
| typeof ALLOWED_IMAGE_MIME_TYPES[number]
|
||||
// future user or unknown file type
|
||||
| typeof MIME_TYPES.binary;
|
||||
id: FileId;
|
||||
@@ -419,7 +419,7 @@ export type AppClassProperties = {
|
||||
FileId,
|
||||
{
|
||||
image: HTMLImageElement | Promise<HTMLImageElement>;
|
||||
mimeType: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||
mimeType: typeof ALLOWED_IMAGE_MIME_TYPES[number];
|
||||
}
|
||||
>;
|
||||
files: BinaryFiles;
|
||||
|
||||
@@ -1326,6 +1326,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
|
||||
integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==
|
||||
|
||||
"@braintree/sanitize-url@6.0.2":
|
||||
version "6.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f"
|
||||
integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==
|
||||
|
||||
"@csstools/normalize.css@*":
|
||||
version "12.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@csstools/normalize.css/-/normalize.css-12.0.0.tgz#a9583a75c3f150667771f30b60d9f059473e62c4"
|
||||
|
||||
Reference in New Issue
Block a user