mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com
This commit is contained in:
parent
c8370b394c
commit
86f5c2ebcf
84 changed files with 8331 additions and 289 deletions
|
@ -5,7 +5,8 @@ exports[`Test Linear Elements Test bound text element should match styles for te
|
|||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 0px; height: 0px; left: 40px; top: 20px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -20px; font: Emoji 20px 20px; line-height: 0px; font-family: Virgil, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 0px; height: 0px; left: 40px; top: 20px; transform-origin: 0px
|
||||
0px; transform: translate(0px, 0px) scale(1) rotate(0deg) translate(0px, 0px); text-align: center; vertical-align: middle; color: rgb(0, 0, 0); opacity: 1; filter: var(--theme-filter); max-height: -20px; font: Emoji 20px 20px; line-height: 0px; font-family: Virgil, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
|
85
src/tests/customActions.test.tsx
Normal file
85
src/tests/customActions.test.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { API } from "./helpers/api";
|
||||
import {
|
||||
CustomShortcutName,
|
||||
getShortcutFromShortcutName,
|
||||
registerCustomShortcuts,
|
||||
} from "../actions/shortcuts";
|
||||
import { Action, ActionName, DisableFn, EnableFn } from "../actions/types";
|
||||
import {
|
||||
getActionDisablers,
|
||||
getActionEnablers,
|
||||
registerDisableFn,
|
||||
registerEnableFn,
|
||||
} from "../actions/guards";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("regression tests", () => {
|
||||
it("should retrieve custom shortcuts", () => {
|
||||
const shortcuts: Record<CustomShortcutName, string[]> = {
|
||||
test: [getShortcutKey("CtrlOrCmd+1"), getShortcutKey("CtrlOrCmd+2")],
|
||||
};
|
||||
registerCustomShortcuts(shortcuts);
|
||||
expect(getShortcutFromShortcutName("test")).toBe("Ctrl+1");
|
||||
});
|
||||
|
||||
it("should follow action guards", () => {
|
||||
// Create the test elements
|
||||
const text1 = API.createElement({ type: "rectangle", id: "A", y: 0 });
|
||||
const text2 = API.createElement({ type: "rectangle", id: "B", y: 30 });
|
||||
const text3 = API.createElement({ type: "rectangle", id: "C", y: 60 });
|
||||
const el12: ExcalidrawElement[] = [text1, text2];
|
||||
const el13: ExcalidrawElement[] = [text1, text3];
|
||||
const el23: ExcalidrawElement[] = [text2, text3];
|
||||
const el123: ExcalidrawElement[] = [text1, text2, text3];
|
||||
// Set up the custom Action enablers
|
||||
const enableName = "custom" as Action["name"];
|
||||
const enabler: EnableFn = function (elements) {
|
||||
if (elements.some((el) => el.y === 30)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
registerEnableFn(enableName, enabler);
|
||||
// Set up the standard Action disablers
|
||||
const disableName1 = "changeFontFamily" as ActionName;
|
||||
const disableName2 = "changeFontSize" as ActionName;
|
||||
const disabler: DisableFn = function (elements) {
|
||||
if (elements.some((el) => el.y === 0)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
registerDisableFn(disableName1, disabler);
|
||||
// Test the custom Action enablers
|
||||
const enablers = getActionEnablers();
|
||||
const isCustomEnabled = function (
|
||||
elements: ExcalidrawElement[],
|
||||
name: string,
|
||||
) {
|
||||
return (
|
||||
name in enablers &&
|
||||
enablers[name].some((enabler) => enabler(elements, h.state, name))
|
||||
);
|
||||
};
|
||||
expect(isCustomEnabled(el12, enableName)).toBe(true);
|
||||
expect(isCustomEnabled(el13, enableName)).toBe(false);
|
||||
expect(isCustomEnabled(el23, enableName)).toBe(true);
|
||||
// Test the standard Action disablers
|
||||
const disablers = getActionDisablers();
|
||||
const isStandardDisabled = function (
|
||||
elements: ExcalidrawElement[],
|
||||
name: ActionName,
|
||||
) {
|
||||
return (
|
||||
name in disablers &&
|
||||
disablers[name].some((disabler) => disabler(elements, h.state, name))
|
||||
);
|
||||
};
|
||||
expect(isStandardDisabled(el12, disableName1)).toBe(true);
|
||||
expect(isStandardDisabled(el23, disableName1)).toBe(false);
|
||||
expect(isStandardDisabled(el123, disableName2)).toBe(false);
|
||||
});
|
||||
});
|
|
@ -15,7 +15,17 @@ import fs from "fs";
|
|||
import util from "util";
|
||||
import path from "path";
|
||||
import { getMimeType } from "../../data/blob";
|
||||
import { newFreeDrawElement, newImageElement } from "../../element/newElement";
|
||||
import {
|
||||
SubtypePrepFn,
|
||||
SubtypeRecord,
|
||||
prepareSubtype,
|
||||
selectSubtype,
|
||||
} from "../../subtypes";
|
||||
import {
|
||||
maybeGetSubtypeProps,
|
||||
newFreeDrawElement,
|
||||
newImageElement,
|
||||
} from "../../element/newElement";
|
||||
import { Point } from "../../types";
|
||||
import { getSelectedElements } from "../../scene/selection";
|
||||
import { isLinearElementType } from "../../element/typeChecks";
|
||||
|
@ -25,6 +35,17 @@ const readFile = util.promisify(fs.readFile);
|
|||
const { h } = window;
|
||||
|
||||
export class API {
|
||||
constructor() {
|
||||
if (true) {
|
||||
// Call `prepareSubtype()` here for `@excalidraw/excalidraw`-specific subtypes
|
||||
}
|
||||
}
|
||||
|
||||
static addSubtype = (record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) => {
|
||||
const prep = prepareSubtype(record, subtypePrepFn);
|
||||
return prep;
|
||||
};
|
||||
|
||||
static setSelectedElements = (elements: ExcalidrawElement[]) => {
|
||||
h.setState({
|
||||
selectedElementIds: elements.reduce((acc, element) => {
|
||||
|
@ -101,6 +122,8 @@ export class API {
|
|||
verticalAlign?: T extends "text"
|
||||
? ExcalidrawTextElement["verticalAlign"]
|
||||
: never;
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
boundElements?: ExcalidrawGenericElement["boundElements"];
|
||||
containerId?: T extends "text"
|
||||
? ExcalidrawTextElement["containerId"]
|
||||
|
@ -126,6 +149,14 @@ export class API {
|
|||
|
||||
const appState = h?.state || getDefaultAppState();
|
||||
|
||||
const custom = maybeGetSubtypeProps(
|
||||
{
|
||||
subtype: rest.subtype ?? selectSubtype(appState, type)?.subtype,
|
||||
customData:
|
||||
rest.customData ?? selectSubtype(appState, type)?.customData,
|
||||
},
|
||||
type,
|
||||
);
|
||||
const base: Omit<
|
||||
ExcalidrawGenericElement,
|
||||
| "id"
|
||||
|
@ -140,6 +171,7 @@ export class API {
|
|||
| "link"
|
||||
| "updated"
|
||||
> = {
|
||||
...custom,
|
||||
x,
|
||||
y,
|
||||
angle: rest.angle ?? 0,
|
||||
|
|
7
src/tests/helpers/locales/en.json
Normal file
7
src/tests/helpers/locales/en.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"toolBar": {
|
||||
"test": "Test",
|
||||
"test2": "Test 2",
|
||||
"test3": "Test 3"
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import {
|
|||
|
||||
describe("exportToSvg", () => {
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH = "/";
|
||||
const ELEMENT_HEIGHT = 100;
|
||||
const ELEMENT_WIDTH = 100;
|
||||
const ELEMENTS = [
|
||||
|
|
|
@ -6,6 +6,9 @@ import {
|
|||
} from "./test-utils";
|
||||
import { Excalidraw } from "../packages/excalidraw/index";
|
||||
import { API } from "./helpers/api";
|
||||
import { Keyboard } from "./helpers/ui";
|
||||
import { KEYS } from "../keys";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
|
||||
const { h } = window;
|
||||
|
||||
|
@ -50,4 +53,33 @@ describe("appState", () => {
|
|||
});
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
it("zoomed canvas scrolls on page keys", async () => {
|
||||
mockBoundingClientRect();
|
||||
await render(<ExcalidrawApp />, {});
|
||||
|
||||
const scrollTest = () => {
|
||||
const scrollY = h.state.scrollY;
|
||||
const pageStep = h.state.height / h.state.zoom.value;
|
||||
// Assert the following assertions have meaning
|
||||
expect(pageStep).toBeGreaterThan(0);
|
||||
// Assert we scroll up
|
||||
Keyboard.keyPress(KEYS.PAGE_UP);
|
||||
expect(h.state.scrollY).toBe(scrollY + pageStep);
|
||||
// Assert we scroll down
|
||||
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||
Keyboard.keyPress(KEYS.PAGE_DOWN);
|
||||
expect(h.state.scrollY).toBe(scrollY - pageStep);
|
||||
};
|
||||
const zoom = h.state.zoom.value;
|
||||
// Assert we scroll properly when zoomed in
|
||||
h.setState({ zoom: { value: (zoom * 1.1) as typeof zoom } });
|
||||
scrollTest();
|
||||
// Assert we scroll properly when zoomed out
|
||||
h.setState({ zoom: { value: (zoom * 0.9) as typeof zoom } });
|
||||
scrollTest();
|
||||
// Assert we scroll properly with normal zoom
|
||||
h.setState({ zoom: { value: zoom } });
|
||||
scrollTest();
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
});
|
||||
|
|
598
src/tests/subtypes.test.tsx
Normal file
598
src/tests/subtypes.test.tsx
Normal file
|
@ -0,0 +1,598 @@
|
|||
import fallbackLangData from "./helpers/locales/en.json";
|
||||
import {
|
||||
SubtypeRecord,
|
||||
SubtypeMethods,
|
||||
SubtypePrepFn,
|
||||
addSubtypeMethods,
|
||||
getSubtypeMethods,
|
||||
getSubtypeNames,
|
||||
hasAlwaysEnabledActions,
|
||||
isValidSubtype,
|
||||
selectSubtype,
|
||||
subtypeCollides,
|
||||
} from "../subtypes";
|
||||
|
||||
import { render } from "./test-utils";
|
||||
import { API } from "./helpers/api";
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
|
||||
import { FontString, Theme } from "../element/types";
|
||||
import { createIcon, iconFillColor } from "../components/icons";
|
||||
import { SubtypeButton } from "../components/SubtypeButton";
|
||||
import { registerAuxLangData } from "../i18n";
|
||||
import { getFontString, getShortcutKey } from "../utils";
|
||||
import * as textElementUtils from "../element/textElement";
|
||||
import { isTextElement } from "../element";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import { AppState } from "../types";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
|
||||
const MW = 200;
|
||||
const TWIDTH = 200;
|
||||
const THEIGHT = 20;
|
||||
const TBASELINE = 15;
|
||||
const FONTSIZE = 20;
|
||||
const DBFONTSIZE = 40;
|
||||
const TRFONTSIZE = 60;
|
||||
|
||||
const getLangData = async (langCode: string): Promise<Object | undefined> => {
|
||||
try {
|
||||
const condData = await import(
|
||||
/* webpackChunkName: "locales/[request]" */ `./helpers/locales/${langCode}.json`
|
||||
);
|
||||
if (condData) {
|
||||
return condData;
|
||||
}
|
||||
} catch (e) {}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const testSubtypeIcon = ({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
stroke={iconFillColor(theme)}
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>,
|
||||
{ width: 40, height: 20, mirror: true },
|
||||
);
|
||||
|
||||
const test1: SubtypeRecord = {
|
||||
subtype: "test",
|
||||
parents: ["line", "arrow", "rectangle", "diamond", "ellipse"],
|
||||
disabledNames: ["changeSloppiness"],
|
||||
};
|
||||
|
||||
const test1Button = SubtypeButton(
|
||||
test1.subtype,
|
||||
test1.parents[0],
|
||||
testSubtypeIcon,
|
||||
);
|
||||
const test1NonParent = "text" as const;
|
||||
|
||||
const test2: SubtypeRecord = {
|
||||
subtype: "test2",
|
||||
parents: ["text"],
|
||||
};
|
||||
|
||||
const test2Button = SubtypeButton(
|
||||
test2.subtype,
|
||||
test2.parents[0],
|
||||
testSubtypeIcon,
|
||||
);
|
||||
|
||||
const test3: SubtypeRecord = {
|
||||
subtype: "test3",
|
||||
parents: ["text", "line"],
|
||||
shortcutMap: {
|
||||
testShortcut: [getShortcutKey("Shift+T")],
|
||||
},
|
||||
alwaysEnabledNames: ["test3Always"],
|
||||
};
|
||||
|
||||
const test3Button = SubtypeButton(
|
||||
test3.subtype,
|
||||
test3.parents[0],
|
||||
testSubtypeIcon,
|
||||
);
|
||||
|
||||
const cleanTestElementUpdate = function (updates) {
|
||||
const oldUpdates = {};
|
||||
for (const key in updates) {
|
||||
if (key !== "roughness") {
|
||||
(oldUpdates as any)[key] = (updates as any)[key];
|
||||
}
|
||||
}
|
||||
(updates as any).roughness = 0;
|
||||
return oldUpdates;
|
||||
} as SubtypeMethods["clean"];
|
||||
|
||||
const prepareNullSubtype = function () {
|
||||
const methods = {} as SubtypeMethods;
|
||||
methods.clean = cleanTestElementUpdate;
|
||||
methods.measureText = measureTest2;
|
||||
methods.wrapText = wrapTest2;
|
||||
|
||||
const actions = [test1Button, test2Button, test3Button];
|
||||
return { actions, methods };
|
||||
} as SubtypePrepFn;
|
||||
|
||||
const prepareTest1Subtype = function (
|
||||
addSubtypeAction,
|
||||
addLangData,
|
||||
onSubtypeLoaded,
|
||||
) {
|
||||
const methods = {} as SubtypeMethods;
|
||||
methods.clean = cleanTestElementUpdate;
|
||||
|
||||
addLangData(fallbackLangData, getLangData);
|
||||
registerAuxLangData(fallbackLangData, getLangData);
|
||||
|
||||
const actions = [test1Button];
|
||||
actions.forEach((action) => addSubtypeAction(action));
|
||||
|
||||
return { actions, methods };
|
||||
} as SubtypePrepFn;
|
||||
|
||||
const measureTest2: SubtypeMethods["measureText"] = function (
|
||||
element,
|
||||
next,
|
||||
maxWidth,
|
||||
) {
|
||||
const text = next?.text ?? element.text;
|
||||
const customData = next?.customData ?? {};
|
||||
const fontSize = customData.triple
|
||||
? TRFONTSIZE
|
||||
: next?.fontSize ?? element.fontSize;
|
||||
const fontFamily = element.fontFamily;
|
||||
const fontString = getFontString({ fontSize, fontFamily });
|
||||
const metrics = textElementUtils.measureText(text, fontString, maxWidth);
|
||||
const width = Math.max(metrics.width - 10, 0);
|
||||
const height = Math.max(metrics.height - 5, 0);
|
||||
return { width, height, baseline: metrics.baseline + 1 };
|
||||
};
|
||||
|
||||
const wrapTest2: SubtypeMethods["wrapText"] = function (
|
||||
element,
|
||||
maxWidth,
|
||||
next,
|
||||
) {
|
||||
const text = next?.text ?? element.originalText;
|
||||
if (next?.customData && next?.customData.triple === true) {
|
||||
return `${text.split(" ").join("\n")}\nHELLO WORLD.`;
|
||||
}
|
||||
if (next?.fontSize === DBFONTSIZE) {
|
||||
return `${text.split(" ").join("\n")}\nHELLO World.`;
|
||||
}
|
||||
return `${text.split(" ").join("\n")}\nHello world.`;
|
||||
};
|
||||
|
||||
const prepareTest2Subtype = function (
|
||||
addSubtypeAction,
|
||||
addLangData,
|
||||
onSubtypeLoaded,
|
||||
) {
|
||||
const methods = {
|
||||
measureText: measureTest2,
|
||||
wrapText: wrapTest2,
|
||||
} as SubtypeMethods;
|
||||
|
||||
addLangData(fallbackLangData, getLangData);
|
||||
registerAuxLangData(fallbackLangData, getLangData);
|
||||
|
||||
const actions = [test2Button];
|
||||
actions.forEach((action) => addSubtypeAction(action));
|
||||
|
||||
return { actions, methods };
|
||||
} as SubtypePrepFn;
|
||||
|
||||
const prepareTest3Subtype = function (
|
||||
addSubtypeAction,
|
||||
addLangData,
|
||||
onSubtypeLoaded,
|
||||
) {
|
||||
const methods = {} as SubtypeMethods;
|
||||
|
||||
addLangData(fallbackLangData, getLangData);
|
||||
registerAuxLangData(fallbackLangData, getLangData);
|
||||
|
||||
const actions = [test3Button];
|
||||
actions.forEach((action) => addSubtypeAction(action));
|
||||
|
||||
return { actions, methods };
|
||||
} as SubtypePrepFn;
|
||||
|
||||
const { h } = window;
|
||||
|
||||
describe("subtype registration", () => {
|
||||
it("should check for invalid subtype or parents", async () => {
|
||||
// Define invalid subtype records
|
||||
const null1 = {} as SubtypeRecord;
|
||||
const null2 = { subtype: "" } as SubtypeRecord;
|
||||
const null3 = { subtype: "null" } as SubtypeRecord;
|
||||
const null4 = { subtype: "null", parents: [] } as SubtypeRecord;
|
||||
// Try registering the invalid subtypes
|
||||
const prepN1 = API.addSubtype(null1, prepareNullSubtype);
|
||||
const prepN2 = API.addSubtype(null2, prepareNullSubtype);
|
||||
const prepN3 = API.addSubtype(null3, prepareNullSubtype);
|
||||
const prepN4 = API.addSubtype(null4, prepareNullSubtype);
|
||||
// Verify the guards in `prepareSubtype` worked
|
||||
expect(prepN1).toStrictEqual({ actions: null, methods: {} });
|
||||
expect(prepN2).toStrictEqual({ actions: null, methods: {} });
|
||||
expect(prepN3).toStrictEqual({ actions: null, methods: {} });
|
||||
expect(prepN4).toStrictEqual({ actions: null, methods: {} });
|
||||
});
|
||||
it("should return subtype actions and methods correctly", async () => {
|
||||
// Check initial registration works
|
||||
let prep1 = API.addSubtype(test1, prepareTest1Subtype);
|
||||
expect(prep1.actions).toStrictEqual([test1Button]);
|
||||
expect(prep1.methods).toStrictEqual({ clean: cleanTestElementUpdate });
|
||||
// Check repeat registration fails
|
||||
prep1 = API.addSubtype(test1, prepareNullSubtype);
|
||||
expect(prep1.actions).toBeNull();
|
||||
expect(prep1.methods).toStrictEqual({ clean: cleanTestElementUpdate });
|
||||
|
||||
// Check initial registration works
|
||||
let prep2 = API.addSubtype(test2, prepareTest2Subtype);
|
||||
expect(prep2.actions).toStrictEqual([test2Button]);
|
||||
expect(prep2.methods).toStrictEqual({
|
||||
measureText: measureTest2,
|
||||
wrapText: wrapTest2,
|
||||
});
|
||||
// Check repeat registration fails
|
||||
prep2 = API.addSubtype(test2, prepareNullSubtype);
|
||||
expect(prep2.actions).toBeNull();
|
||||
expect(prep2.methods).toStrictEqual({
|
||||
measureText: measureTest2,
|
||||
wrapText: wrapTest2,
|
||||
});
|
||||
|
||||
// Check initial registration works
|
||||
let prep3 = API.addSubtype(test3, prepareTest3Subtype);
|
||||
expect(prep3.actions).toStrictEqual([test3Button]);
|
||||
expect(prep3.methods).toStrictEqual({});
|
||||
// Check repeat registration fails
|
||||
prep3 = API.addSubtype(test3, prepareNullSubtype);
|
||||
expect(prep3.actions).toBeNull();
|
||||
expect(prep3.methods).toStrictEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtypes", () => {
|
||||
it("should correctly register", async () => {
|
||||
const subtypes = getSubtypeNames();
|
||||
expect(subtypes).toContain(test1.subtype);
|
||||
expect(subtypes).toContain(test2.subtype);
|
||||
expect(subtypes).toContain(test3.subtype);
|
||||
});
|
||||
it("should return subtype methods", async () => {
|
||||
expect(getSubtypeMethods(undefined)).toBeUndefined();
|
||||
const test1Methods = getSubtypeMethods(test1.subtype);
|
||||
expect(test1Methods?.clean).toBeDefined();
|
||||
expect(test1Methods?.render).toBeUndefined();
|
||||
expect(test1Methods?.wrapText).toBeUndefined();
|
||||
expect(test1Methods?.renderSvg).toBeUndefined();
|
||||
expect(test1Methods?.measureText).toBeUndefined();
|
||||
expect(test1Methods?.ensureLoaded).toBeUndefined();
|
||||
});
|
||||
it("should not overwrite subtype methods", async () => {
|
||||
addSubtypeMethods(test1.subtype, {});
|
||||
addSubtypeMethods(test2.subtype, {});
|
||||
addSubtypeMethods(test3.subtype, { clean: cleanTestElementUpdate });
|
||||
const test1Methods = getSubtypeMethods(test1.subtype);
|
||||
expect(test1Methods?.clean).toBeDefined();
|
||||
const test2Methods = getSubtypeMethods(test2.subtype);
|
||||
expect(test2Methods?.measureText).toBeDefined();
|
||||
expect(test2Methods?.wrapText).toBeDefined();
|
||||
const test3Methods = getSubtypeMethods(test3.subtype);
|
||||
expect(test3Methods?.clean).toBeUndefined();
|
||||
});
|
||||
it("should register custom shortcuts", async () => {
|
||||
expect(getShortcutFromShortcutName("testShortcut")).toBe("Shift+T");
|
||||
});
|
||||
it("should correctly validate", async () => {
|
||||
test1.parents.forEach((p) => {
|
||||
expect(isValidSubtype(test1.subtype, p)).toBe(true);
|
||||
expect(isValidSubtype(undefined, p)).toBe(false);
|
||||
});
|
||||
expect(isValidSubtype(test1.subtype, test1NonParent)).toBe(false);
|
||||
expect(isValidSubtype(test1.subtype, undefined)).toBe(false);
|
||||
expect(isValidSubtype(undefined, undefined)).toBe(false);
|
||||
});
|
||||
it("should collide with themselves", async () => {
|
||||
expect(subtypeCollides(test1.subtype, [test1.subtype])).toBe(true);
|
||||
expect(subtypeCollides(test1.subtype, [test1.subtype, test2.subtype])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
it("should not collide without type overlap", async () => {
|
||||
expect(subtypeCollides(test1.subtype, [test2.subtype])).toBe(false);
|
||||
});
|
||||
it("should collide with type overlap", async () => {
|
||||
expect(subtypeCollides(test1.subtype, [test3.subtype])).toBe(true);
|
||||
});
|
||||
it("should apply to ExcalidrawElements", async () => {
|
||||
await render(<ExcalidrawApp />, {
|
||||
localStorageData: {
|
||||
elements: [
|
||||
API.createElement({ type: "line", id: "A", subtype: test1.subtype }),
|
||||
API.createElement({ type: "arrow", id: "B", subtype: test1.subtype }),
|
||||
API.createElement({
|
||||
type: "rectangle",
|
||||
id: "C",
|
||||
subtype: test1.subtype,
|
||||
}),
|
||||
API.createElement({
|
||||
type: "diamond",
|
||||
id: "D",
|
||||
subtype: test1.subtype,
|
||||
}),
|
||||
API.createElement({
|
||||
type: "ellipse",
|
||||
id: "E",
|
||||
subtype: test1.subtype,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
h.elements.forEach((el) => expect(el.subtype).toBe(test1.subtype));
|
||||
});
|
||||
it("should enforce prop value restrictions", async () => {
|
||||
await render(<ExcalidrawApp />, {
|
||||
localStorageData: {
|
||||
elements: [
|
||||
API.createElement({
|
||||
type: "line",
|
||||
id: "A",
|
||||
subtype: test1.subtype,
|
||||
roughness: 1,
|
||||
}),
|
||||
API.createElement({ type: "line", id: "B", roughness: 1 }),
|
||||
],
|
||||
},
|
||||
});
|
||||
h.elements.forEach((el) => {
|
||||
if (el.subtype === test1.subtype) {
|
||||
expect(el.roughness).toBe(0);
|
||||
} else {
|
||||
expect(el.roughness).toBe(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
it("should consider enforced prop values in version increments", async () => {
|
||||
const rectA = API.createElement({
|
||||
type: "line",
|
||||
id: "A",
|
||||
subtype: test1.subtype,
|
||||
roughness: 1,
|
||||
strokeWidth: 1,
|
||||
});
|
||||
const rectB = API.createElement({
|
||||
type: "line",
|
||||
id: "B",
|
||||
subtype: test1.subtype,
|
||||
roughness: 1,
|
||||
strokeWidth: 1,
|
||||
});
|
||||
// Initial element creation checks
|
||||
expect(rectA.roughness).toBe(0);
|
||||
expect(rectB.roughness).toBe(0);
|
||||
expect(rectA.version).toBe(1);
|
||||
expect(rectB.version).toBe(1);
|
||||
// Check that attempting to set prop values not permitted by the subtype
|
||||
// doesn't increment element versions
|
||||
mutateElement(rectA, { roughness: 2 });
|
||||
mutateElement(rectB, { roughness: 2, strokeWidth: 2 });
|
||||
expect(rectA.version).toBe(1);
|
||||
expect(rectB.version).toBe(2);
|
||||
// Check that element versions don't increment when creating new elements
|
||||
// while attempting to use prop values not permitted by the subtype
|
||||
// First check based on `rectA` (unsuccessfully mutated)
|
||||
const rectC = newElementWith(rectA, { roughness: 1 });
|
||||
const rectD = newElementWith(rectA, { roughness: 1, strokeWidth: 1.5 });
|
||||
expect(rectC.version).toBe(1);
|
||||
expect(rectD.version).toBe(2);
|
||||
// Then check based on `rectB` (successfully mutated)
|
||||
const rectE = newElementWith(rectB, { roughness: 1 });
|
||||
const rectF = newElementWith(rectB, { roughness: 1, strokeWidth: 1.5 });
|
||||
expect(rectE.version).toBe(2);
|
||||
expect(rectF.version).toBe(3);
|
||||
});
|
||||
it("should call custom text methods", async () => {
|
||||
const testString = "A quick brown fox jumps over the lazy dog.";
|
||||
await render(<ExcalidrawApp />, {
|
||||
localStorageData: {
|
||||
elements: [
|
||||
API.createElement({
|
||||
type: "text",
|
||||
id: "A",
|
||||
subtype: test2.subtype,
|
||||
text: testString,
|
||||
fontSize: FONTSIZE,
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
const mockMeasureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
if (text === testString) {
|
||||
let multiplier = 1;
|
||||
if (font.includes(`${DBFONTSIZE}`)) {
|
||||
multiplier = 2;
|
||||
}
|
||||
if (font.includes(`${TRFONTSIZE}`)) {
|
||||
multiplier = 3;
|
||||
}
|
||||
const width = maxWidth
|
||||
? Math.min(multiplier * TWIDTH, maxWidth)
|
||||
: multiplier * TWIDTH;
|
||||
const height = multiplier * THEIGHT;
|
||||
const baseline = multiplier * TBASELINE;
|
||||
return { width, height, baseline };
|
||||
}
|
||||
return { width: 1, height: 0, baseline: 0 };
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation(mockMeasureText);
|
||||
|
||||
h.elements.forEach((el) => {
|
||||
if (isTextElement(el)) {
|
||||
// First test with `ExcalidrawTextElement.text`
|
||||
const metrics = textElementUtils.measureTextElement(el);
|
||||
expect(metrics).toStrictEqual({
|
||||
width: TWIDTH - 10,
|
||||
height: THEIGHT - 5,
|
||||
baseline: TBASELINE + 1,
|
||||
});
|
||||
const mMetrics = textElementUtils.measureTextElement(el, {}, MW);
|
||||
expect(mMetrics).toStrictEqual({
|
||||
width: Math.min(TWIDTH, MW) - 10,
|
||||
height: THEIGHT - 5,
|
||||
baseline: TBASELINE + 1,
|
||||
});
|
||||
const wrappedText = textElementUtils.wrapTextElement(el, MW);
|
||||
expect(wrappedText).toEqual(
|
||||
`${testString.split(" ").join("\n")}\nHello world.`,
|
||||
);
|
||||
|
||||
// Now test with modified text in `next`
|
||||
let next: {
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
customData?: Record<string, any>;
|
||||
} = {
|
||||
text: "Hello world.",
|
||||
};
|
||||
const nextMetrics = textElementUtils.measureTextElement(el, next);
|
||||
expect(nextMetrics).toStrictEqual({ width: 0, height: 0, baseline: 1 });
|
||||
const nextWrappedText = textElementUtils.wrapTextElement(el, MW, next);
|
||||
expect(nextWrappedText).toEqual("Hello\nworld.\nHello world.");
|
||||
|
||||
// Now test modified fontSizes in `next`
|
||||
next = { fontSize: DBFONTSIZE };
|
||||
const nextFM = textElementUtils.measureTextElement(el, next);
|
||||
expect(nextFM).toStrictEqual({
|
||||
width: 2 * TWIDTH - 10,
|
||||
height: 2 * THEIGHT - 5,
|
||||
baseline: 2 * TBASELINE + 1,
|
||||
});
|
||||
const nextFMW = textElementUtils.measureTextElement(el, next, MW);
|
||||
expect(nextFMW).toStrictEqual({
|
||||
width: Math.min(2 * TWIDTH, MW) - 10,
|
||||
height: 2 * THEIGHT - 5,
|
||||
baseline: 2 * TBASELINE + 1,
|
||||
});
|
||||
const nextFWrText = textElementUtils.wrapTextElement(el, MW, next);
|
||||
expect(nextFWrText).toEqual(
|
||||
`${testString.split(" ").join("\n")}\nHELLO World.`,
|
||||
);
|
||||
|
||||
// Now test customData in `next`
|
||||
next = { customData: { triple: true } };
|
||||
const nextCD = textElementUtils.measureTextElement(el, next);
|
||||
expect(nextCD).toStrictEqual({
|
||||
width: 3 * TWIDTH - 10,
|
||||
height: 3 * THEIGHT - 5,
|
||||
baseline: 3 * TBASELINE + 1,
|
||||
});
|
||||
const nextCDMW = textElementUtils.measureTextElement(el, next, MW);
|
||||
expect(nextCDMW).toStrictEqual({
|
||||
width: Math.min(3 * TWIDTH, MW) - 10,
|
||||
height: 3 * THEIGHT - 5,
|
||||
baseline: 3 * TBASELINE + 1,
|
||||
});
|
||||
const nextCDWrText = textElementUtils.wrapTextElement(el, MW, next);
|
||||
expect(nextCDWrText).toEqual(
|
||||
`${testString.split(" ").join("\n")}\nHELLO WORLD.`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
it("should recognize subtypes with always-enabled actions", async () => {
|
||||
expect(hasAlwaysEnabledActions(test1.subtype)).toBe(false);
|
||||
expect(hasAlwaysEnabledActions(test2.subtype)).toBe(false);
|
||||
expect(hasAlwaysEnabledActions(test3.subtype)).toBe(true);
|
||||
});
|
||||
it("should select active subtypes and customData", async () => {
|
||||
const appState = {} as {
|
||||
activeSubtypes: AppState["activeSubtypes"];
|
||||
customData: AppState["customData"];
|
||||
};
|
||||
|
||||
// No active subtypes
|
||||
let subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.subtype).toBeUndefined();
|
||||
expect(subtypes.customData).toBeUndefined();
|
||||
// Subtype for both "text" and "line" types
|
||||
appState.activeSubtypes = [test3.subtype];
|
||||
subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.subtype).toBe(test3.subtype);
|
||||
subtypes = selectSubtype(appState, "line");
|
||||
expect(subtypes.subtype).toBe(test3.subtype);
|
||||
subtypes = selectSubtype(appState, "arrow");
|
||||
expect(subtypes.subtype).toBeUndefined();
|
||||
// Subtype for multiple linear types
|
||||
appState.activeSubtypes = [test1.subtype];
|
||||
subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.subtype).toBeUndefined();
|
||||
subtypes = selectSubtype(appState, "line");
|
||||
expect(subtypes.subtype).toBe(test1.subtype);
|
||||
subtypes = selectSubtype(appState, "arrow");
|
||||
expect(subtypes.subtype).toBe(test1.subtype);
|
||||
// Subtype for "text" only
|
||||
appState.activeSubtypes = [test2.subtype];
|
||||
subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.subtype).toBe(test2.subtype);
|
||||
subtypes = selectSubtype(appState, "line");
|
||||
expect(subtypes.subtype).toBeUndefined();
|
||||
subtypes = selectSubtype(appState, "arrow");
|
||||
expect(subtypes.subtype).toBeUndefined();
|
||||
|
||||
// Test customData
|
||||
appState.customData = {};
|
||||
appState.customData[test1.subtype] = { test: true };
|
||||
appState.customData[test2.subtype] = { test2: true };
|
||||
appState.customData[test3.subtype] = { test3: true };
|
||||
// Subtype for both "text" and "line" types
|
||||
appState.activeSubtypes = [test3.subtype];
|
||||
subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.customData).toBeDefined();
|
||||
expect(subtypes.customData![test1.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test2.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test3.subtype]).toBe(true);
|
||||
subtypes = selectSubtype(appState, "line");
|
||||
expect(subtypes.customData).toBeDefined();
|
||||
expect(subtypes.customData![test1.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test2.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test3.subtype]).toBe(true);
|
||||
subtypes = selectSubtype(appState, "arrow");
|
||||
expect(subtypes.customData).toBeUndefined();
|
||||
// Subtype for multiple linear types
|
||||
appState.activeSubtypes = [test1.subtype];
|
||||
subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.customData).toBeUndefined();
|
||||
subtypes = selectSubtype(appState, "line");
|
||||
expect(subtypes.customData).toBeDefined();
|
||||
expect(subtypes.customData![test1.subtype]).toBe(true);
|
||||
expect(subtypes.customData![test2.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test3.subtype]).toBeUndefined();
|
||||
// Multiple, non-colliding subtypes
|
||||
appState.activeSubtypes = [test1.subtype, test2.subtype];
|
||||
subtypes = selectSubtype(appState, "text");
|
||||
expect(subtypes.customData).toBeDefined();
|
||||
expect(subtypes.customData![test1.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test2.subtype]).toBe(true);
|
||||
expect(subtypes.customData![test3.subtype]).toBeUndefined();
|
||||
subtypes = selectSubtype(appState, "line");
|
||||
expect(subtypes.customData).toBeDefined();
|
||||
expect(subtypes.customData![test1.subtype]).toBe(true);
|
||||
expect(subtypes.customData![test2.subtype]).toBeUndefined();
|
||||
expect(subtypes.customData![test3.subtype]).toBeUndefined();
|
||||
});
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue