mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Separate more things
This commit is contained in:
parent
70feced695
commit
aa873234ad
11 changed files with 64 additions and 114 deletions
105
packages/common/binaryheap.ts
Normal file
105
packages/common/binaryheap.ts
Normal file
|
@ -0,0 +1,105 @@
|
|||
export default class BinaryHeap<T> {
|
||||
private content: T[] = [];
|
||||
|
||||
constructor(private scoreFunction: (node: T) => number) {}
|
||||
|
||||
sinkDown(idx: number) {
|
||||
const node = this.content[idx];
|
||||
while (idx > 0) {
|
||||
const parentN = ((idx + 1) >> 1) - 1;
|
||||
const parent = this.content[parentN];
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
const length = this.content.length;
|
||||
const node = this.content[idx];
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
|
||||
if (child1N < length) {
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
}
|
||||
}
|
||||
|
||||
if (child2N < length) {
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
push(node: T) {
|
||||
this.content.push(node);
|
||||
this.sinkDown(this.content.length - 1);
|
||||
}
|
||||
|
||||
pop(): T | null {
|
||||
if (this.content.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = this.content[0];
|
||||
const end = this.content.pop()!;
|
||||
|
||||
if (this.content.length > 0) {
|
||||
this.content[0] = end;
|
||||
this.bubbleUp(0);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
remove(node: T) {
|
||||
if (this.content.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const i = this.content.indexOf(node);
|
||||
const end = this.content.pop()!;
|
||||
|
||||
if (i < this.content.length) {
|
||||
this.content[i] = end;
|
||||
|
||||
if (this.scoreFunction(end) < this.scoreFunction(node)) {
|
||||
this.sinkDown(i);
|
||||
} else {
|
||||
this.bubbleUp(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.content.length;
|
||||
}
|
||||
|
||||
rescoreElement(node: T) {
|
||||
this.sinkDown(this.content.indexOf(node));
|
||||
}
|
||||
}
|
271
packages/common/keys.test.ts
Normal file
271
packages/common/keys.test.ts
Normal file
|
@ -0,0 +1,271 @@
|
|||
import { KEYS, matchKey } from "./keys";
|
||||
|
||||
describe("key matcher", async () => {
|
||||
it("should not match unexpected key", async () => {
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "N" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Unidentified" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "z" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "y" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Z" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Y" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should match key (case insensitive) when key is latin", async () => {
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "z" }), KEYS.Z),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "y" }), KEYS.Y),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Z" }), KEYS.Z),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "Y" }), KEYS.Y),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should match key on QWERTY, QWERTZ, AZERTY", async () => {
|
||||
// QWERTY
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// QWERTZ
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyY" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyZ" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// AZERTY
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyW" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should match key on DVORAK, COLEMAK", async () => {
|
||||
// DVORAK
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeySemicolon" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyF" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// COLEMAK
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "y", code: "KeyJ" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should match key on Turkish-Q", async () => {
|
||||
// Turkish-Q
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyN" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "Y", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not fallback when code is not defined", async () => {
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "я" }), KEYS.Z),
|
||||
).toBeFalsy();
|
||||
|
||||
expect(
|
||||
matchKey(new KeyboardEvent("keydown", { key: "卜" }), KEYS.Y),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should not fallback when code is incorrect", async () => {
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "z", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeFalsy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "Y", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should fallback to code when key is non-latin", async () => {
|
||||
// Macedonian
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "з", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ѕ", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Russian
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "я", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "н", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Serbian
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ѕ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "з", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Greek
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ζ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "υ", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Hebrew
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ז", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ט", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Cangjie - Traditional
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "重", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "卜", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// Japanese
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "つ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ん", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
// 2-Set Korean
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ㅋ", code: "KeyZ" }),
|
||||
KEYS.Z,
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
matchKey(
|
||||
new KeyboardEvent("keydown", { key: "ㅛ", code: "KeyY" }),
|
||||
KEYS.Y,
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
151
packages/common/keys.ts
Normal file
151
packages/common/keys.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { isDarwin } from "./constants";
|
||||
|
||||
import type { ValueOf } from "../excalidraw/utility-types";
|
||||
|
||||
export const CODES = {
|
||||
EQUAL: "Equal",
|
||||
MINUS: "Minus",
|
||||
NUM_ADD: "NumpadAdd",
|
||||
NUM_SUBTRACT: "NumpadSubtract",
|
||||
NUM_ZERO: "Numpad0",
|
||||
BRACKET_RIGHT: "BracketRight",
|
||||
BRACKET_LEFT: "BracketLeft",
|
||||
ONE: "Digit1",
|
||||
TWO: "Digit2",
|
||||
THREE: "Digit3",
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
SLASH: "Slash",
|
||||
C: "KeyC",
|
||||
D: "KeyD",
|
||||
H: "KeyH",
|
||||
V: "KeyV",
|
||||
Z: "KeyZ",
|
||||
Y: "KeyY",
|
||||
R: "KeyR",
|
||||
S: "KeyS",
|
||||
} as const;
|
||||
|
||||
export const KEYS = {
|
||||
ARROW_DOWN: "ArrowDown",
|
||||
ARROW_LEFT: "ArrowLeft",
|
||||
ARROW_RIGHT: "ArrowRight",
|
||||
ARROW_UP: "ArrowUp",
|
||||
PAGE_UP: "PageUp",
|
||||
PAGE_DOWN: "PageDown",
|
||||
BACKSPACE: "Backspace",
|
||||
ALT: "Alt",
|
||||
CTRL_OR_CMD: isDarwin ? "metaKey" : "ctrlKey",
|
||||
DELETE: "Delete",
|
||||
ENTER: "Enter",
|
||||
ESCAPE: "Escape",
|
||||
QUESTION_MARK: "?",
|
||||
SPACE: " ",
|
||||
TAB: "Tab",
|
||||
CHEVRON_LEFT: "<",
|
||||
CHEVRON_RIGHT: ">",
|
||||
PERIOD: ".",
|
||||
COMMA: ",",
|
||||
SUBTRACT: "-",
|
||||
SLASH: "/",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
D: "d",
|
||||
E: "e",
|
||||
F: "f",
|
||||
G: "g",
|
||||
H: "h",
|
||||
I: "i",
|
||||
L: "l",
|
||||
O: "o",
|
||||
P: "p",
|
||||
Q: "q",
|
||||
R: "r",
|
||||
S: "s",
|
||||
T: "t",
|
||||
V: "v",
|
||||
X: "x",
|
||||
Y: "y",
|
||||
Z: "z",
|
||||
K: "k",
|
||||
W: "w",
|
||||
|
||||
0: "0",
|
||||
1: "1",
|
||||
2: "2",
|
||||
3: "3",
|
||||
4: "4",
|
||||
5: "5",
|
||||
6: "6",
|
||||
7: "7",
|
||||
8: "8",
|
||||
9: "9",
|
||||
} as const;
|
||||
|
||||
export type Key = keyof typeof KEYS;
|
||||
|
||||
// defines key code mapping for matching codes as fallback to respective keys on non-latin keyboard layouts
|
||||
export const KeyCodeMap = new Map<ValueOf<typeof KEYS>, ValueOf<typeof CODES>>([
|
||||
[KEYS.Z, CODES.Z],
|
||||
[KEYS.Y, CODES.Y],
|
||||
]);
|
||||
|
||||
export const isLatinChar = (key: string) => /^[a-z]$/.test(key.toLowerCase());
|
||||
|
||||
/**
|
||||
* Used to match key events for any keyboard layout, especially on Windows and Linux,
|
||||
* where non-latin character with modified (CMD) is not substituted with latin-based alternative.
|
||||
*
|
||||
* Uses `event.key` when it's latin, otherwise fallbacks to `event.code` (if mapping exists).
|
||||
*
|
||||
* Example of pressing "z" on different layouts, with the chosen key or code highlighted in []:
|
||||
*
|
||||
* Layout | Code | Key | Comment
|
||||
* --------------------- | ----- | --- | -------
|
||||
* U.S. | KeyZ | [z] |
|
||||
* Czech | KeyY | [z] |
|
||||
* Turkish | KeyN | [z] |
|
||||
* French | KeyW | [z] |
|
||||
* Macedonian | [KeyZ] | з | z with cmd; з is Cyrillic equivalent of z
|
||||
* Russian | [KeyZ] | я | z with cmd
|
||||
* Serbian | [KeyZ] | ѕ | z with cmd
|
||||
* Greek | [KeyZ] | ζ | z with cmd; also ζ is Greek equivalent of z
|
||||
* Hebrew | [KeyZ] | ז | z with cmd; also ז is Hebrew equivalent of z
|
||||
* Pinyin - Simplified | KeyZ | [z] | due to IME
|
||||
* Cangije - Traditional | [KeyZ] | 重 | z with cmd
|
||||
* Japanese | [KeyZ] | つ | z with cmd
|
||||
* 2-Set Korean | [KeyZ] | ㅋ | z with cmd
|
||||
*
|
||||
* More details in https://github.com/excalidraw/excalidraw/pull/5944
|
||||
*/
|
||||
export const matchKey = (
|
||||
event: KeyboardEvent | React.KeyboardEvent<Element>,
|
||||
key: ValueOf<typeof KEYS>,
|
||||
): boolean => {
|
||||
// for latin layouts use key
|
||||
if (key === event.key.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// non-latin layouts fallback to code
|
||||
const code = KeyCodeMap.get(key);
|
||||
return Boolean(code && !isLatinChar(event.key) && event.code === code);
|
||||
};
|
||||
|
||||
export const isArrowKey = (key: string) =>
|
||||
key === KEYS.ARROW_LEFT ||
|
||||
key === KEYS.ARROW_RIGHT ||
|
||||
key === KEYS.ARROW_DOWN ||
|
||||
key === KEYS.ARROW_UP;
|
||||
|
||||
export const shouldResizeFromCenter = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.altKey;
|
||||
|
||||
export const shouldMaintainAspectRatio = (event: MouseEvent | KeyboardEvent) =>
|
||||
event.shiftKey;
|
||||
|
||||
export const shouldRotateWithDiscreteAngle = (
|
||||
event: MouseEvent | KeyboardEvent | React.PointerEvent<HTMLCanvasElement>,
|
||||
) => event.shiftKey;
|
63
packages/common/points.ts
Normal file
63
packages/common/points.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
pointFromPair,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
export const getSizeFromPoints = (
|
||||
points: readonly (GlobalPoint | LocalPoint)[],
|
||||
) => {
|
||||
const xs = points.map((point) => point[0]);
|
||||
const ys = points.map((point) => point[1]);
|
||||
return {
|
||||
width: Math.max(...xs) - Math.min(...xs),
|
||||
height: Math.max(...ys) - Math.min(...ys),
|
||||
};
|
||||
};
|
||||
|
||||
/** @arg dimension, 0 for rescaling only x, 1 for y */
|
||||
export const rescalePoints = <Point extends GlobalPoint | LocalPoint>(
|
||||
dimension: 0 | 1,
|
||||
newSize: number,
|
||||
points: readonly Point[],
|
||||
normalize: boolean,
|
||||
): Point[] => {
|
||||
const coordinates = points.map((point) => point[dimension]);
|
||||
const maxCoordinate = Math.max(...coordinates);
|
||||
const minCoordinate = Math.min(...coordinates);
|
||||
const size = maxCoordinate - minCoordinate;
|
||||
const scale = size === 0 ? 1 : newSize / size;
|
||||
|
||||
let nextMinCoordinate = Infinity;
|
||||
|
||||
const scaledPoints = points.map((point): Point => {
|
||||
const newCoordinate = point[dimension] * scale;
|
||||
const newPoint = [...point];
|
||||
newPoint[dimension] = newCoordinate;
|
||||
if (newCoordinate < nextMinCoordinate) {
|
||||
nextMinCoordinate = newCoordinate;
|
||||
}
|
||||
return newPoint as Point;
|
||||
});
|
||||
|
||||
if (!normalize) {
|
||||
return scaledPoints;
|
||||
}
|
||||
|
||||
if (scaledPoints.length === 2) {
|
||||
// we don't translate two-point lines
|
||||
return scaledPoints;
|
||||
}
|
||||
|
||||
const translation = minCoordinate - nextMinCoordinate;
|
||||
|
||||
const nextPoints = scaledPoints.map((scaledPoint) =>
|
||||
pointFromPair<Point>(
|
||||
scaledPoint.map((value, currentDimension) => {
|
||||
return currentDimension === dimension ? value + translation : value;
|
||||
}) as [number, number],
|
||||
),
|
||||
);
|
||||
|
||||
return nextPoints;
|
||||
};
|
16
packages/common/random.ts
Normal file
16
packages/common/random.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { nanoid } from "nanoid";
|
||||
import { Random } from "roughjs/bin/math";
|
||||
|
||||
import { isTestEnv } from "./utils";
|
||||
|
||||
let random = new Random(Date.now());
|
||||
let testIdBase = 0;
|
||||
|
||||
export const randomInteger = () => Math.floor(random.next() * 2 ** 31);
|
||||
|
||||
export const reseed = (seed: number) => {
|
||||
random = new Random(seed);
|
||||
testIdBase = 0;
|
||||
};
|
||||
|
||||
export const randomId = () => (isTestEnv() ? `id${testIdBase++}` : nanoid());
|
31
packages/common/url.test.tsx
Normal file
31
packages/common/url.test.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
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>");
|
||||
expect(normalizeLink("test&")).toBe("test&");
|
||||
});
|
||||
});
|
37
packages/common/url.ts
Normal file
37
packages/common/url.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
import { escapeDoubleQuotes } from "./utils";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(escapeDoubleQuotes(link));
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns URL sanitized and safe for usage in places such as
|
||||
* iframe's src attribute or <a> href attributes.
|
||||
*/
|
||||
export const toValidURL = (link: string) => {
|
||||
link = normalizeLink(link);
|
||||
|
||||
// make relative links into fully-qualified urls
|
||||
if (link.startsWith("/")) {
|
||||
return `${location.origin}${link}`;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(link);
|
||||
} catch {
|
||||
// if link does not parse as URL, assume invalid and return blank page
|
||||
return "about:blank";
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
|
@ -1245,3 +1245,60 @@ export const escapeDoubleQuotes = (str: string) => {
|
|||
|
||||
export const castArray = <T>(value: T | T[]): T[] =>
|
||||
Array.isArray(value) ? value : [value];
|
||||
|
||||
|
||||
// TODO_SEP: perhaps could be refactored away?
|
||||
|
||||
// TODO: Rounding this point causes some shake when free drawing
|
||||
export const getGridPoint = (
|
||||
x: number,
|
||||
y: number,
|
||||
gridSize: NullableGridSize,
|
||||
): [number, number] => {
|
||||
if (gridSize) {
|
||||
return [
|
||||
Math.round(x / gridSize) * gridSize,
|
||||
Math.round(y / gridSize) * gridSize,
|
||||
];
|
||||
}
|
||||
return [x, y];
|
||||
};
|
||||
|
||||
export const elementsAreInSameGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const allGroups = elements.flatMap((element) => element.groupIds);
|
||||
const groupCount = new Map<string, number>();
|
||||
let maxGroup = 0;
|
||||
|
||||
for (const group of allGroups) {
|
||||
groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
|
||||
if (groupCount.get(group)! > maxGroup) {
|
||||
maxGroup = groupCount.get(group)!;
|
||||
}
|
||||
}
|
||||
|
||||
return maxGroup === elements.length;
|
||||
};
|
||||
|
||||
export const isInGroup = (element: NonDeletedExcalidrawElement) => {
|
||||
return element.groupIds.length > 0;
|
||||
};
|
||||
|
||||
export const getNewGroupIdsForDuplication = (
|
||||
groupIds: ExcalidrawElement["groupIds"],
|
||||
editingGroupId: AppState["editingGroupId"],
|
||||
mapper: (groupId: GroupId) => GroupId,
|
||||
) => {
|
||||
const copy = [...groupIds];
|
||||
const positionOfEditingGroupId = editingGroupId
|
||||
? groupIds.indexOf(editingGroupId)
|
||||
: -1;
|
||||
const endIndex =
|
||||
positionOfEditingGroupId > -1 ? positionOfEditingGroupId : groupIds.length;
|
||||
for (let index = 0; index < endIndex; index++) {
|
||||
copy[index] = mapper(copy[index]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
};
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue