feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com
@ -19,6 +19,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/extensions": "link:src/packages/extensions",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
|
@ -143,6 +143,7 @@
|
||||
<% } %>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH = "/";
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
|
@ -3,7 +3,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
measureText,
|
||||
measureTextElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
@ -19,7 +19,6 @@ import {
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
@ -38,9 +37,9 @@ export const actionUnbindText = register({
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const { width, height, baseline } = measureText(
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
const { width, height, baseline } = measureTextElement(
|
||||
boundTextElement,
|
||||
{ text: boundTextElement.originalText },
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
|
@ -86,7 +86,7 @@ import { register } from "./register";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
const changeProperty = (
|
||||
export const changeProperty = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
callback: (element: ExcalidrawElement) => ExcalidrawElement,
|
||||
@ -106,7 +106,7 @@ const changeProperty = (
|
||||
});
|
||||
};
|
||||
|
||||
const getFormValue = function <T>(
|
||||
export const getFormValue = function <T>(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
getAttribute: (element: ExcalidrawElement) => T,
|
||||
|
25
src/actions/guards.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { Action, ActionName, DisableFn, EnableFn } from "./types";
|
||||
|
||||
const disablers = {} as Record<ActionName, DisableFn[]>;
|
||||
const enablers = {} as Record<Action["name"], EnableFn[]>;
|
||||
|
||||
export const getActionDisablers = () => disablers;
|
||||
export const getActionEnablers = () => enablers;
|
||||
|
||||
export const registerDisableFn = (name: ActionName, disabler: DisableFn) => {
|
||||
if (!(name in disablers)) {
|
||||
disablers[name] = [] as DisableFn[];
|
||||
}
|
||||
if (!disablers[name].includes(disabler)) {
|
||||
disablers[name].push(disabler);
|
||||
}
|
||||
};
|
||||
|
||||
export const registerEnableFn = (name: Action["name"], enabler: EnableFn) => {
|
||||
if (!(name in enablers)) {
|
||||
enablers[name] = [] as EnableFn[];
|
||||
}
|
||||
if (!enablers[name].includes(enabler)) {
|
||||
enablers[name].push(enabler);
|
||||
}
|
||||
};
|
@ -6,7 +6,11 @@ import {
|
||||
ActionResult,
|
||||
PanelComponentProps,
|
||||
ActionSource,
|
||||
DisableFn,
|
||||
EnableFn,
|
||||
isActionName,
|
||||
} from "./types";
|
||||
import { getActionDisablers, getActionEnablers } from "./guards";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { trackEvent } from "../analytics";
|
||||
@ -40,7 +44,10 @@ const trackAction = (
|
||||
};
|
||||
|
||||
export class ActionManager {
|
||||
actions = {} as Record<ActionName, Action>;
|
||||
actions = {} as Record<ActionName | Action["name"], Action>;
|
||||
|
||||
disablers = {} as Record<ActionName, DisableFn[]>;
|
||||
enablers = {} as Record<Action["name"], EnableFn[]>;
|
||||
|
||||
updater: (actionResult: ActionResult | Promise<ActionResult>) => void;
|
||||
|
||||
@ -68,6 +75,58 @@ export class ActionManager {
|
||||
this.app = app;
|
||||
}
|
||||
|
||||
public registerActionGuards() {
|
||||
const disablers = getActionDisablers();
|
||||
for (const d in disablers) {
|
||||
const dName = d as ActionName;
|
||||
disablers[dName].forEach((disabler) =>
|
||||
this.registerDisableFn(dName, disabler),
|
||||
);
|
||||
}
|
||||
const enablers = getActionEnablers();
|
||||
for (const e in enablers) {
|
||||
const eName = e as Action["name"];
|
||||
enablers[e].forEach((enabler) => this.registerEnableFn(eName, enabler));
|
||||
}
|
||||
}
|
||||
|
||||
private registerDisableFn(name: ActionName, disabler: DisableFn) {
|
||||
if (!(name in this.disablers)) {
|
||||
this.disablers[name] = [] as DisableFn[];
|
||||
}
|
||||
if (!this.disablers[name].includes(disabler)) {
|
||||
this.disablers[name].push(disabler);
|
||||
}
|
||||
}
|
||||
|
||||
private registerEnableFn(name: Action["name"], enabler: EnableFn) {
|
||||
if (!(name in this.enablers)) {
|
||||
this.enablers[name] = [] as EnableFn[];
|
||||
}
|
||||
if (!this.enablers[name].includes(enabler)) {
|
||||
this.enablers[name].push(enabler);
|
||||
}
|
||||
}
|
||||
|
||||
public isActionEnabled(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
actionName: Action["name"],
|
||||
): boolean {
|
||||
if (isActionName(actionName)) {
|
||||
return !(
|
||||
actionName in this.disablers &&
|
||||
this.disablers[actionName].some((fn) =>
|
||||
fn(elements, appState, actionName),
|
||||
)
|
||||
);
|
||||
}
|
||||
return (
|
||||
actionName in this.enablers &&
|
||||
this.enablers[actionName].some((fn) => fn(elements, appState, actionName))
|
||||
);
|
||||
}
|
||||
|
||||
registerAction(action: Action) {
|
||||
this.actions[action.name] = action;
|
||||
}
|
||||
@ -84,7 +143,11 @@ export class ActionManager {
|
||||
(action) =>
|
||||
(action.name in canvasActions
|
||||
? canvasActions[action.name as keyof typeof canvasActions]
|
||||
: true) &&
|
||||
: this.isActionEnabled(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
action.name,
|
||||
)) &&
|
||||
action.keyTest &&
|
||||
action.keyTest(
|
||||
event,
|
||||
@ -132,7 +195,7 @@ export class ActionManager {
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (
|
||||
name: ActionName,
|
||||
name: ActionName | Action["name"],
|
||||
data?: PanelComponentProps["data"],
|
||||
isInHamburgerMenu = false,
|
||||
) => {
|
||||
@ -143,7 +206,11 @@ export class ActionManager {
|
||||
"PanelComponent" in this.actions[name] &&
|
||||
(name in canvasActions
|
||||
? canvasActions[name as keyof typeof canvasActions]
|
||||
: true)
|
||||
: this.isActionEnabled(
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.getAppState(),
|
||||
name,
|
||||
))
|
||||
) {
|
||||
const action = this.actions[name];
|
||||
const PanelComponent = action.PanelComponent!;
|
||||
@ -165,6 +232,7 @@ export class ActionManager {
|
||||
|
||||
return (
|
||||
<PanelComponent
|
||||
key={name}
|
||||
elements={this.getElementsIncludingDeleted()}
|
||||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
|
@ -1,8 +1,14 @@
|
||||
import { Action } from "./types";
|
||||
import { Action, isActionName } from "./types";
|
||||
|
||||
export let actions: readonly Action[] = [];
|
||||
let actions: readonly Action[] = [];
|
||||
let customActions: readonly Action[] = [];
|
||||
export const getCustomActions = () => customActions;
|
||||
export const getActions = () => actions;
|
||||
|
||||
export const register = <T extends Action>(action: T) => {
|
||||
if (!isActionName(action.name)) {
|
||||
customActions = customActions.concat(action);
|
||||
}
|
||||
actions = actions.concat(action);
|
||||
return action as T & {
|
||||
keyTest?: unknown extends T["keyTest"] ? never : T["keyTest"];
|
||||
|
@ -80,8 +80,23 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")],
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (name: ShortcutName) => {
|
||||
const shortcuts = shortcutMap[name];
|
||||
export type CustomShortcutName = string;
|
||||
|
||||
let customShortcutMap: Record<CustomShortcutName, string[]> = {};
|
||||
|
||||
export const registerCustomShortcuts = (
|
||||
shortcuts: Record<CustomShortcutName, string[]>,
|
||||
) => {
|
||||
customShortcutMap = { ...customShortcutMap, ...shortcuts };
|
||||
};
|
||||
|
||||
export const getShortcutFromShortcutName = (
|
||||
name: ShortcutName | CustomShortcutName,
|
||||
) => {
|
||||
const shortcuts =
|
||||
name in customShortcutMap
|
||||
? customShortcutMap[name as CustomShortcutName]
|
||||
: shortcutMap[name as ShortcutName];
|
||||
// if multiple shortcuts available, take the first one
|
||||
return shortcuts && shortcuts.length > 0 ? shortcuts[0] : "";
|
||||
};
|
||||
|
@ -31,88 +31,110 @@ type ActionFn = (
|
||||
app: AppClassProperties,
|
||||
) => ActionResult | Promise<ActionResult>;
|
||||
|
||||
// Return `true` to indicate the standard Action with name `actionName`
|
||||
// should be disabled given `elements` and `appState`.
|
||||
export type DisableFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
actionName: ActionName,
|
||||
) => boolean;
|
||||
|
||||
// Return `true` to indicate the custom Action with name `actionName`
|
||||
// should be enabled given `elements` and `appState`.
|
||||
export type EnableFn = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
actionName: Action["name"],
|
||||
) => boolean;
|
||||
|
||||
export type UpdaterFn = (res: ActionResult) => void;
|
||||
export type ActionFilterFn = (action: Action) => void;
|
||||
|
||||
export type ActionName =
|
||||
| "copy"
|
||||
| "cut"
|
||||
| "paste"
|
||||
| "copyAsPng"
|
||||
| "copyAsSvg"
|
||||
| "copyText"
|
||||
| "sendBackward"
|
||||
| "bringForward"
|
||||
| "sendToBack"
|
||||
| "bringToFront"
|
||||
| "copyStyles"
|
||||
| "selectAll"
|
||||
| "pasteStyles"
|
||||
| "gridMode"
|
||||
| "zenMode"
|
||||
| "stats"
|
||||
| "changeStrokeColor"
|
||||
| "changeBackgroundColor"
|
||||
| "changeFillStyle"
|
||||
| "changeStrokeWidth"
|
||||
| "changeStrokeShape"
|
||||
| "changeSloppiness"
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
| "toggleEditMenu"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "finalize"
|
||||
| "changeProjectName"
|
||||
| "changeExportBackground"
|
||||
| "changeExportEmbedScene"
|
||||
| "changeExportScale"
|
||||
| "saveToActiveFile"
|
||||
| "saveFileToDisk"
|
||||
| "loadScene"
|
||||
| "duplicateSelection"
|
||||
| "deleteSelectedElements"
|
||||
| "changeViewBackgroundColor"
|
||||
| "clearCanvas"
|
||||
| "zoomIn"
|
||||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
| "toggleFullScreen"
|
||||
| "toggleShortcuts"
|
||||
| "group"
|
||||
| "ungroup"
|
||||
| "goToCollaborator"
|
||||
| "addToLibrary"
|
||||
| "changeRoundness"
|
||||
| "alignTop"
|
||||
| "alignBottom"
|
||||
| "alignLeft"
|
||||
| "alignRight"
|
||||
| "alignVerticallyCentered"
|
||||
| "alignHorizontallyCentered"
|
||||
| "distributeHorizontally"
|
||||
| "distributeVertically"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "viewMode"
|
||||
| "exportWithDarkMode"
|
||||
| "toggleTheme"
|
||||
| "increaseFontSize"
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor";
|
||||
const actionNames = [
|
||||
"copy",
|
||||
"cut",
|
||||
"paste",
|
||||
"copyAsPng",
|
||||
"copyAsSvg",
|
||||
"copyText",
|
||||
"sendBackward",
|
||||
"bringForward",
|
||||
"sendToBack",
|
||||
"bringToFront",
|
||||
"copyStyles",
|
||||
"selectAll",
|
||||
"pasteStyles",
|
||||
"gridMode",
|
||||
"zenMode",
|
||||
"stats",
|
||||
"changeStrokeColor",
|
||||
"changeBackgroundColor",
|
||||
"changeFillStyle",
|
||||
"changeStrokeWidth",
|
||||
"changeStrokeShape",
|
||||
"changeSloppiness",
|
||||
"changeStrokeStyle",
|
||||
"changeArrowhead",
|
||||
"changeOpacity",
|
||||
"changeFontSize",
|
||||
"toggleCanvasMenu",
|
||||
"toggleEditMenu",
|
||||
"undo",
|
||||
"redo",
|
||||
"finalize",
|
||||
"changeProjectName",
|
||||
"changeExportBackground",
|
||||
"changeExportEmbedScene",
|
||||
"changeExportScale",
|
||||
"saveToActiveFile",
|
||||
"saveFileToDisk",
|
||||
"loadScene",
|
||||
"duplicateSelection",
|
||||
"deleteSelectedElements",
|
||||
"changeViewBackgroundColor",
|
||||
"clearCanvas",
|
||||
"zoomIn",
|
||||
"zoomOut",
|
||||
"resetZoom",
|
||||
"zoomToFit",
|
||||
"zoomToSelection",
|
||||
"changeFontFamily",
|
||||
"changeTextAlign",
|
||||
"changeVerticalAlign",
|
||||
"toggleFullScreen",
|
||||
"toggleShortcuts",
|
||||
"group",
|
||||
"ungroup",
|
||||
"goToCollaborator",
|
||||
"addToLibrary",
|
||||
"changeRoundness",
|
||||
"alignTop",
|
||||
"alignBottom",
|
||||
"alignLeft",
|
||||
"alignRight",
|
||||
"alignVerticallyCentered",
|
||||
"alignHorizontallyCentered",
|
||||
"distributeHorizontally",
|
||||
"distributeVertically",
|
||||
"flipHorizontal",
|
||||
"flipVertical",
|
||||
"viewMode",
|
||||
"exportWithDarkMode",
|
||||
"toggleTheme",
|
||||
"increaseFontSize",
|
||||
"decreaseFontSize",
|
||||
"unbindText",
|
||||
"hyperlink",
|
||||
"eraser",
|
||||
"bindText",
|
||||
"toggleLock",
|
||||
"toggleLinearEditor",
|
||||
] as const;
|
||||
|
||||
// So we can have the `isActionName` type guard
|
||||
export type ActionName = typeof actionNames[number];
|
||||
export const isActionName = (n: any): n is ActionName =>
|
||||
actionNames.includes(n);
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -123,10 +145,14 @@ export type PanelComponentProps = {
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
name: string;
|
||||
PanelComponent?: React.FC<
|
||||
PanelComponentProps & { isInHamburgerMenu: boolean }
|
||||
>;
|
||||
panelComponentPredicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => boolean;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
@ -134,6 +160,11 @@ export interface Action {
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
) => boolean;
|
||||
shapeConfigPredicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
data?: Record<string, any>,
|
||||
) => boolean;
|
||||
contextItemLabel?:
|
||||
| string
|
||||
| ((
|
||||
|
@ -138,6 +138,8 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
editingLinearElement: { browser: false, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
activeSubtypes: { browser: true, export: false, server: false },
|
||||
customData: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
Spreadsheet,
|
||||
sortSpreadsheet,
|
||||
tryParseCells,
|
||||
tryParseNumber,
|
||||
VALID_SPREADSHEET,
|
||||
@ -118,4 +119,29 @@ describe("charts", () => {
|
||||
expect(values).toEqual([61, -60, 85, -67, 54, 95]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortSpreadsheet", () => {
|
||||
it("sorts strictly numerical labels columns in ascending order", () => {
|
||||
const spreadsheet = [
|
||||
["x", "y"],
|
||||
["1°", "1"],
|
||||
["9°", "2"],
|
||||
["3°", "3"],
|
||||
["6°", "4"],
|
||||
];
|
||||
|
||||
const result = tryParseCells(spreadsheet);
|
||||
|
||||
expect(result.type).toBe(VALID_SPREADSHEET);
|
||||
|
||||
const { title, labels, values } = sortSpreadsheet(
|
||||
(result as { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet })
|
||||
.spreadsheet,
|
||||
);
|
||||
|
||||
expect(title).toEqual("y");
|
||||
expect(labels).toEqual(["1°", "3°", "6°", "9°"]);
|
||||
expect(values).toEqual([1, 3, 4, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -8,6 +8,8 @@ import {
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
import { NonDeletedExcalidrawElement } from "./element/types";
|
||||
import { randomId } from "./random";
|
||||
import { AppState } from "./types";
|
||||
import { selectSubtype } from "./subtypes";
|
||||
|
||||
export type ChartElements = readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
@ -20,6 +22,8 @@ export interface Spreadsheet {
|
||||
title: string | null;
|
||||
labels: string[] | null;
|
||||
values: number[];
|
||||
activeSubtypes?: AppState["activeSubtypes"];
|
||||
customData?: AppState["customData"];
|
||||
}
|
||||
|
||||
export const NOT_SPREADSHEET = "NOT_SPREADSHEET";
|
||||
@ -29,11 +33,8 @@ type ParseSpreadsheetResult =
|
||||
| { type: typeof NOT_SPREADSHEET; reason: string }
|
||||
| { type: typeof VALID_SPREADSHEET; spreadsheet: Spreadsheet };
|
||||
|
||||
/**
|
||||
* @private exported for testing
|
||||
*/
|
||||
export const tryParseNumber = (s: string): number | null => {
|
||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%]?$/.exec(s);
|
||||
const match = /^([-+]?)[$€£¥₩]?([-+]?)([\d.,]+)[%°]?$/.exec(s);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@ -158,6 +159,32 @@ export const tryParseSpreadsheet = (text: string): ParseSpreadsheetResult => {
|
||||
return result;
|
||||
};
|
||||
|
||||
export const sortSpreadsheet = (spreadsheet: Spreadsheet) => {
|
||||
const rows = [] as { label: string; value: number }[];
|
||||
if (spreadsheet.labels == null || spreadsheet.values == null) {
|
||||
return spreadsheet;
|
||||
}
|
||||
if (spreadsheet.labels.every((val) => tryParseNumber(val))) {
|
||||
for (let i = 0; i < spreadsheet.labels.length; i++) {
|
||||
rows.push({
|
||||
label: spreadsheet.labels[i],
|
||||
value: spreadsheet.values[i],
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => {
|
||||
const aParsed = tryParseNumber(a.label)!;
|
||||
const bParsed = tryParseNumber(b.label)!;
|
||||
return aParsed - bParsed;
|
||||
});
|
||||
const newSpreadsheet = {} as Spreadsheet;
|
||||
newSpreadsheet.title = spreadsheet.title;
|
||||
newSpreadsheet.labels = rows.flatMap((row) => row.label);
|
||||
newSpreadsheet.values = rows.flatMap((row) => row.value);
|
||||
return newSpreadsheet;
|
||||
}
|
||||
return spreadsheet;
|
||||
};
|
||||
|
||||
const bgColors = colors.elementBackground.slice(
|
||||
2,
|
||||
colors.elementBackground.length,
|
||||
@ -193,13 +220,17 @@ const chartXLabels = (
|
||||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const custom = selectSubtype(spreadsheet, "text");
|
||||
return (
|
||||
spreadsheet.labels?.map((label, index) => {
|
||||
return newTextElement({
|
||||
groupIds: [groupId],
|
||||
backgroundColor,
|
||||
...commonProps,
|
||||
text: label.length > 8 ? `${label.slice(0, 5)}...` : label,
|
||||
text:
|
||||
label.length > 8 && custom.subtype === undefined
|
||||
? `${label.slice(0, 5)}...`
|
||||
: label,
|
||||
x: x + index * (BAR_WIDTH + BAR_GAP) + BAR_GAP * 2,
|
||||
y: y + BAR_GAP / 2,
|
||||
width: BAR_WIDTH,
|
||||
@ -207,6 +238,7 @@ const chartXLabels = (
|
||||
fontSize: 16,
|
||||
textAlign: "center",
|
||||
verticalAlign: "top",
|
||||
...custom,
|
||||
});
|
||||
}) || []
|
||||
);
|
||||
@ -227,6 +259,7 @@ const chartYLabels = (
|
||||
y: y - BAR_GAP,
|
||||
text: "0",
|
||||
textAlign: "right",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
});
|
||||
|
||||
const maxYLabel = newTextElement({
|
||||
@ -237,6 +270,7 @@ const chartYLabels = (
|
||||
y: y - BAR_HEIGHT - minYLabel.height / 2,
|
||||
text: Math.max(...spreadsheet.values).toLocaleString(),
|
||||
textAlign: "right",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
});
|
||||
|
||||
return [minYLabel, maxYLabel];
|
||||
@ -264,6 +298,7 @@ const chartLines = (
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const yLine = newLinearElement({
|
||||
@ -280,6 +315,7 @@ const chartLines = (
|
||||
[0, 0],
|
||||
[0, -chartHeight],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const maxLine = newLinearElement({
|
||||
@ -298,6 +334,7 @@ const chartLines = (
|
||||
[0, 0],
|
||||
[chartWidth, 0],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
return [xLine, yLine, maxLine];
|
||||
@ -325,6 +362,7 @@ const chartBaseElements = (
|
||||
roundness: null,
|
||||
strokeStyle: "solid",
|
||||
textAlign: "center",
|
||||
...selectSubtype(spreadsheet, "text"),
|
||||
})
|
||||
: null;
|
||||
|
||||
@ -341,6 +379,7 @@ const chartBaseElements = (
|
||||
strokeColor: colors.elementStroke[0],
|
||||
fillStyle: "solid",
|
||||
opacity: 6,
|
||||
...selectSubtype(spreadsheet, "rectangle"),
|
||||
})
|
||||
: null;
|
||||
|
||||
@ -373,6 +412,7 @@ const chartTypeBar = (
|
||||
y: y - barHeight - BAR_GAP,
|
||||
width: BAR_WIDTH,
|
||||
height: barHeight,
|
||||
...selectSubtype(spreadsheet, "rectangle"),
|
||||
});
|
||||
});
|
||||
|
||||
@ -425,6 +465,7 @@ const chartTypeLine = (
|
||||
width: maxX - minX,
|
||||
strokeWidth: 2,
|
||||
points: points as any,
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
|
||||
const dots = spreadsheet.values.map((value, index) => {
|
||||
@ -441,6 +482,7 @@ const chartTypeLine = (
|
||||
y: y + cy - BAR_GAP * 2,
|
||||
width: BAR_GAP,
|
||||
height: BAR_GAP,
|
||||
...selectSubtype(spreadsheet, "ellipse"),
|
||||
});
|
||||
});
|
||||
|
||||
@ -463,6 +505,7 @@ const chartTypeLine = (
|
||||
[0, 0],
|
||||
[0, cy],
|
||||
],
|
||||
...selectSubtype(spreadsheet, "line"),
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -130,6 +130,7 @@ export const getSystemClipboard = async (
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
isPlainPaste = false,
|
||||
appState?: AppState,
|
||||
): Promise<ClipboardData> => {
|
||||
const systemClipboard = await getSystemClipboard(event);
|
||||
|
||||
@ -149,6 +150,10 @@ export const parseClipboard = async (
|
||||
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
|
||||
|
||||
if (spreadsheetResult) {
|
||||
if ("spreadsheet" in spreadsheetResult) {
|
||||
spreadsheetResult.spreadsheet.activeSubtypes = appState?.activeSubtypes;
|
||||
spreadsheetResult.spreadsheet.customData = appState?.customData;
|
||||
}
|
||||
return spreadsheetResult;
|
||||
}
|
||||
|
||||
|
@ -28,6 +28,7 @@ import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import { getCustomActions } from "../actions/register";
|
||||
import "./Actions.scss";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { shouldAllowVerticalAlign } from "../element/textElement";
|
||||
@ -92,6 +93,15 @@ export const SelectedShapeActions = ({
|
||||
{showChangeBackgroundIcons && (
|
||||
<div>{renderAction("changeBackgroundColor")}</div>
|
||||
)}
|
||||
{getCustomActions().map((action) => {
|
||||
if (
|
||||
action.panelComponentPredicate &&
|
||||
action.panelComponentPredicate(targetElements, appState)
|
||||
) {
|
||||
return renderAction(action.name);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
|
@ -38,7 +38,7 @@ import {
|
||||
} from "../actions";
|
||||
import { createRedoAction, createUndoAction } from "../actions/actionHistory";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { actions } from "../actions/register";
|
||||
import { getActions, getCustomActions } from "../actions/register";
|
||||
import { ActionResult } from "../actions/types";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
@ -86,6 +86,7 @@ import {
|
||||
getCursorForResizingElement,
|
||||
getDragOffsetXY,
|
||||
getElementWithTransformHandleType,
|
||||
getNonDeletedElements,
|
||||
getNormalizedDimensions,
|
||||
getResizeArrowDirection,
|
||||
getResizeOffsetXY,
|
||||
@ -231,6 +232,14 @@ import {
|
||||
import LayerUI from "./LayerUI";
|
||||
import { Toast } from "./Toast";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import {
|
||||
SubtypeRecord,
|
||||
SubtypePrepFn,
|
||||
getSubtypeNames,
|
||||
hasAlwaysEnabledActions,
|
||||
prepareSubtype,
|
||||
selectSubtype,
|
||||
} from "../subtypes";
|
||||
import {
|
||||
dataURLToFile,
|
||||
generateIdFromFile,
|
||||
@ -259,8 +268,10 @@ import {
|
||||
getBoundTextElement,
|
||||
getContainerCenter,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextBindableContainerAtPosition,
|
||||
isValidTextContainer,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import { isHittingElementNotConsideringBoundingBox } from "../element/collision";
|
||||
import {
|
||||
@ -323,6 +334,7 @@ export const useExcalidrawAppState = () =>
|
||||
export const useExcalidrawSetAppState = () =>
|
||||
useContext(ExcalidrawSetAppStateContext);
|
||||
|
||||
let refreshTimer = 0;
|
||||
let didTapTwice: boolean = false;
|
||||
let tappedTwiceTimer = 0;
|
||||
let cursorX = 0;
|
||||
@ -415,6 +427,19 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.id = nanoid();
|
||||
|
||||
this.library = new Library(this);
|
||||
this.scene = new Scene();
|
||||
this.fonts = new Fonts({
|
||||
scene: this.scene,
|
||||
onSceneUpdated: this.onSceneUpdated,
|
||||
});
|
||||
|
||||
this.actionManager = new ActionManager(
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
() => this.scene.getElementsIncludingDeleted(),
|
||||
this,
|
||||
);
|
||||
|
||||
if (excalidrawRef) {
|
||||
const readyPromise =
|
||||
("current" in excalidrawRef && excalidrawRef.current?.readyPromise) ||
|
||||
@ -435,6 +460,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
getSceneElements: this.getSceneElements,
|
||||
getAppState: () => this.state,
|
||||
getFiles: () => this.files,
|
||||
actionManager: this.actionManager,
|
||||
addSubtype: this.addSubtype,
|
||||
refresh: this.refresh,
|
||||
setToast: this.setToast,
|
||||
id: this.id,
|
||||
@ -456,22 +483,46 @@ class App extends React.Component<AppProps, AppState> {
|
||||
id: this.id,
|
||||
};
|
||||
|
||||
this.scene = new Scene();
|
||||
this.fonts = new Fonts({
|
||||
scene: this.scene,
|
||||
onSceneUpdated: this.onSceneUpdated,
|
||||
});
|
||||
this.history = new History();
|
||||
this.actionManager = new ActionManager(
|
||||
this.syncActionResult,
|
||||
() => this.state,
|
||||
() => this.scene.getElementsIncludingDeleted(),
|
||||
this,
|
||||
);
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAll(getActions());
|
||||
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
// Call `this.addSubtype()` here for `@excalidraw/excalidraw`-specific subtypes
|
||||
this.actionManager.registerActionGuards();
|
||||
}
|
||||
|
||||
private addSubtype(record: SubtypeRecord, subtypePrepFn: SubtypePrepFn) {
|
||||
// Call this method after finishing any async loading for
|
||||
// subtypes of ExcalidrawElement if the newly loaded code
|
||||
// would change the rendering.
|
||||
const refresh = (hasSubtype: (element: ExcalidrawElement) => boolean) => {
|
||||
const elements = this.getSceneElementsIncludingDeleted();
|
||||
let refreshNeeded = false;
|
||||
getNonDeletedElements(elements).forEach((element) => {
|
||||
// If the element is of the subtype that was just
|
||||
// registered, update the element's dimensions, mark the
|
||||
// element for a re-render, and mark the scene for a refresh.
|
||||
if (hasSubtype(element)) {
|
||||
invalidateShapeForElement(element);
|
||||
if (isTextElement(element)) {
|
||||
redrawTextBoundingBox(element, getContainerElement(element));
|
||||
}
|
||||
refreshNeeded = true;
|
||||
}
|
||||
});
|
||||
// If there are any elements of the just-registered subtype,
|
||||
// refresh the scene to re-render each such element.
|
||||
if (refreshNeeded) {
|
||||
this.refresh();
|
||||
}
|
||||
};
|
||||
const prep = prepareSubtype(record, subtypePrepFn, refresh);
|
||||
if (prep.actions) {
|
||||
this.actionManager.registerAll(prep.actions);
|
||||
}
|
||||
this.actionManager.registerActionGuards();
|
||||
return prep;
|
||||
}
|
||||
|
||||
private renderCanvas() {
|
||||
@ -560,6 +611,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
value={this.scene.getNonDeletedElements()}
|
||||
>
|
||||
<LayerUI
|
||||
renderShapeToggles={getSubtypeNames().map((subtype) =>
|
||||
this.actionManager.renderAction(
|
||||
subtype,
|
||||
hasAlwaysEnabledActions(subtype)
|
||||
? { onContextMenu: this.handleShapeContextMenu }
|
||||
: {},
|
||||
),
|
||||
)}
|
||||
canvas={this.canvas}
|
||||
appState={this.state}
|
||||
files={this.files}
|
||||
@ -1302,7 +1361,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
cursorButton[socketId] = user.button;
|
||||
});
|
||||
|
||||
const refresh = () => {
|
||||
// If a scene refresh is cued, restart the countdown.
|
||||
// This way we are not calling this.setState({}) once per
|
||||
// ExcalidrawElement. The countdown improves performance
|
||||
// when there are large numbers of ExcalidrawElements
|
||||
// executing this refresh() callback.
|
||||
if (refreshTimer !== 0) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
this.refresh();
|
||||
window.clearTimeout(refreshTimer);
|
||||
}, 50);
|
||||
};
|
||||
const renderingElements = this.scene
|
||||
.getNonDeletedElements()
|
||||
.filter((element) => {
|
||||
@ -1350,6 +1422,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
imageCache: this.imageCache,
|
||||
isExporting: false,
|
||||
renderScrollbars: !this.device.isMobile,
|
||||
renderCb: refresh,
|
||||
},
|
||||
callback: ({ atLeastOneVisibleElement, scrollBars }) => {
|
||||
if (scrollBars) {
|
||||
@ -1500,7 +1573,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
// (something something security)
|
||||
let file = event?.clipboardData?.files[0];
|
||||
|
||||
const data = await parseClipboard(event, isPlainPaste);
|
||||
const data = await parseClipboard(event, isPlainPaste, this.state);
|
||||
|
||||
if (!file && data.text && !isPlainPaste) {
|
||||
const string = data.text.trim();
|
||||
@ -1682,6 +1755,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
textAlign: this.state.currentItemTextAlign,
|
||||
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||
...selectSubtype(this.state, "text"),
|
||||
locked: false,
|
||||
};
|
||||
|
||||
@ -2020,6 +2094,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ isBindingEnabled: false });
|
||||
}
|
||||
|
||||
if (event.key === KEYS.PAGE_UP || event.key === KEYS.PAGE_DOWN) {
|
||||
let offsetY = this.state.height / this.state.zoom.value;
|
||||
if (event.key === KEYS.PAGE_DOWN) {
|
||||
offsetY = -offsetY;
|
||||
}
|
||||
const scrollY = this.state.scrollY + offsetY;
|
||||
this.setState({ scrollY });
|
||||
}
|
||||
|
||||
if (isArrowKey(event.key)) {
|
||||
const step =
|
||||
(this.state.gridSize &&
|
||||
@ -2596,6 +2679,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
verticalAlign: parentCenterPosition
|
||||
? VERTICAL_ALIGN.MIDDLE
|
||||
: DEFAULT_VERTICAL_ALIGN,
|
||||
...selectSubtype(this.state, "text"),
|
||||
containerId: shouldBindToContainer ? container?.id : undefined,
|
||||
groupIds: container?.groupIds ?? [],
|
||||
locked: false,
|
||||
@ -4153,6 +4237,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
roughness: this.state.currentItemRoughness,
|
||||
roundness: null,
|
||||
opacity: this.state.currentItemOpacity,
|
||||
...selectSubtype(this.state, "image"),
|
||||
locked: false,
|
||||
});
|
||||
|
||||
@ -4244,6 +4329,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
...selectSubtype(this.state, elementType),
|
||||
locked: false,
|
||||
});
|
||||
this.setState((prevState) => ({
|
||||
@ -4300,6 +4386,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
...selectSubtype(this.state, elementType),
|
||||
locked: false,
|
||||
});
|
||||
|
||||
@ -5947,6 +6034,28 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private handleShapeContextMenu = (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
source: string,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
const container = this.excalidrawContainerRef.current!;
|
||||
const { top: offsetTop, left: offsetLeft } =
|
||||
container.getBoundingClientRect();
|
||||
const left = event.clientX - offsetLeft;
|
||||
const top = event.clientY - offsetTop;
|
||||
this.setState({}, () => {
|
||||
this.setState({
|
||||
contextMenu: {
|
||||
top,
|
||||
left,
|
||||
items: this.getContextMenuItems("shape", source),
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private handleCanvasContextMenu = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
) => {
|
||||
@ -6118,9 +6227,42 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
private getContextMenuItems = (
|
||||
type: "canvas" | "element",
|
||||
type: "canvas" | "element" | "shape",
|
||||
source?: string,
|
||||
): ContextMenuItems => {
|
||||
const options: ContextMenuItems = [];
|
||||
const allElements = this.actionManager.getElementsIncludingDeleted();
|
||||
const appState = this.actionManager.getAppState();
|
||||
let addedCustom = false;
|
||||
getCustomActions().forEach((action) => {
|
||||
if (action.contextItemPredicate && type !== "shape") {
|
||||
if (
|
||||
action.contextItemPredicate!(
|
||||
allElements,
|
||||
appState,
|
||||
this.actionManager.app.props,
|
||||
this.actionManager.app,
|
||||
) &&
|
||||
this.actionManager.isActionEnabled(allElements, appState, action.name)
|
||||
) {
|
||||
addedCustom = true;
|
||||
options.push(action);
|
||||
}
|
||||
} else if (action.shapeConfigPredicate && type === "shape") {
|
||||
if (
|
||||
action.shapeConfigPredicate!(allElements, appState, { source }) &&
|
||||
this.actionManager.isActionEnabled(allElements, appState, action.name)
|
||||
) {
|
||||
options.push(action);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (type === "shape") {
|
||||
return options;
|
||||
}
|
||||
if (addedCustom) {
|
||||
options.push(CONTEXT_MENU_SEPARATOR);
|
||||
}
|
||||
|
||||
options.push(actionCopyAsPng, actionCopyAsSvg);
|
||||
|
||||
|
@ -23,7 +23,17 @@ export const ButtonSelect = <T extends Object>({
|
||||
onChange={() => onChange(option.value)}
|
||||
checked={value === option.value}
|
||||
/>
|
||||
{option.text}
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: "0.6rem",
|
||||
color: "var(--icon-fill-color)",
|
||||
fontWeight: "bold",
|
||||
opacity: value === option.value ? 1.0 : 0.6,
|
||||
}}
|
||||
>
|
||||
{option.text}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
@ -5,6 +5,7 @@ import { t } from "../i18n";
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
getShortcutFromShortcutName,
|
||||
CustomShortcutName,
|
||||
ShortcutName,
|
||||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
@ -110,7 +111,9 @@ export const ContextMenu = React.memo(
|
||||
<div className="context-menu-item__label">{label}</div>
|
||||
<kbd className="context-menu-item__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
? getShortcutFromShortcutName(
|
||||
actionName as ShortcutName | CustomShortcutName,
|
||||
)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
|
@ -76,6 +76,7 @@ interface LayerUIProps {
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderShapeToggles?: (JSX.Element | null)[];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
@ -102,6 +103,7 @@ const LayerUI = ({
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
isCollaborating,
|
||||
renderShapeToggles,
|
||||
renderTopRightUI,
|
||||
|
||||
renderCustomStats,
|
||||
@ -394,6 +396,7 @@ const LayerUI = ({
|
||||
{/* {actionManager.renderAction("eraser", {
|
||||
// size: "small",
|
||||
})} */}
|
||||
{renderShapeToggles}
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
</Stack.Row>
|
||||
@ -492,6 +495,7 @@ const LayerUI = ({
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderShapeToggles={renderShapeToggles}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
|
@ -36,7 +36,7 @@ type MobileMenuProps = {
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
isCollaborating: boolean;
|
||||
|
||||
renderShapeToggles?: (JSX.Element | null)[];
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
@ -60,6 +60,7 @@ export const MobileMenu = ({
|
||||
onPenModeToggle,
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderShapeToggles,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
@ -105,6 +106,7 @@ export const MobileMenu = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{renderShapeToggles}
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
|
@ -1,13 +1,26 @@
|
||||
import oc from "open-color";
|
||||
import React, { useLayoutEffect, useRef, useState } from "react";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ChartElements, renderSpreadsheet, Spreadsheet } from "../charts";
|
||||
import {
|
||||
ChartElements,
|
||||
renderSpreadsheet,
|
||||
sortSpreadsheet,
|
||||
Spreadsheet,
|
||||
tryParseNumber,
|
||||
} from "../charts";
|
||||
import { ChartType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { exportToSvg } from "../scene/export";
|
||||
import { AppState, LibraryItem } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import "./PasteChartDialog.scss";
|
||||
import { ensureSubtypesLoaded } from "../subtypes";
|
||||
import { isTextElement } from "../element";
|
||||
import {
|
||||
getContainerElement,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
|
||||
type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void;
|
||||
|
||||
@ -16,6 +29,7 @@ const ChartPreviewBtn = (props: {
|
||||
chartType: ChartType;
|
||||
selected: boolean;
|
||||
onClick: OnInsertChart;
|
||||
sortChartLabels: boolean;
|
||||
}) => {
|
||||
const previewRef = useRef<HTMLDivElement | null>(null);
|
||||
const [chartElements, setChartElements] = useState<ChartElements | null>(
|
||||
@ -23,42 +37,58 @@ const ChartPreviewBtn = (props: {
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!props.spreadsheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = renderSpreadsheet(
|
||||
props.chartType,
|
||||
props.spreadsheet,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
setChartElements(elements);
|
||||
let svg: SVGSVGElement;
|
||||
const previewNode = previewRef.current!;
|
||||
|
||||
(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
(async () => {
|
||||
let elements: ChartElements;
|
||||
await ensureSubtypesLoaded(
|
||||
props.spreadsheet?.activeSubtypes ?? [],
|
||||
() => {
|
||||
if (!props.spreadsheet) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
const spreadsheet = props.sortChartLabels
|
||||
? sortSpreadsheet(props.spreadsheet)
|
||||
: props.spreadsheet;
|
||||
elements = renderSpreadsheet(props.chartType, spreadsheet, 0, 0);
|
||||
elements.forEach(
|
||||
(el) =>
|
||||
isTextElement(el) &&
|
||||
redrawTextBoundingBox(el, getContainerElement(el)),
|
||||
);
|
||||
setChartElements(elements);
|
||||
},
|
||||
).then(async () => {
|
||||
svg = await exportToSvg(
|
||||
elements,
|
||||
{
|
||||
exportBackground: false,
|
||||
viewBackgroundColor: oc.white,
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
if (props.selected) {
|
||||
(previewNode.parentNode as HTMLDivElement).focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.replaceChildren();
|
||||
};
|
||||
})();
|
||||
|
||||
return () => {
|
||||
previewNode.replaceChildren();
|
||||
};
|
||||
}, [props.spreadsheet, props.chartType, props.selected]);
|
||||
}, [
|
||||
props.spreadsheet,
|
||||
props.chartType,
|
||||
props.selected,
|
||||
props.sortChartLabels,
|
||||
]);
|
||||
|
||||
return (
|
||||
<button
|
||||
@ -102,6 +132,10 @@ export const PasteChartDialog = ({
|
||||
},
|
||||
});
|
||||
};
|
||||
const showSortChartLabels = appState.pasteDialog.data?.labels?.every((val) =>
|
||||
tryParseNumber(val),
|
||||
);
|
||||
const [sortChartLabels, setSortChartLabels] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -117,14 +151,28 @@ export const PasteChartDialog = ({
|
||||
spreadsheet={appState.pasteDialog.data}
|
||||
selected={appState.currentChartType === "bar"}
|
||||
onClick={handleChartClick}
|
||||
sortChartLabels={(showSortChartLabels && sortChartLabels) ?? false}
|
||||
/>
|
||||
<ChartPreviewBtn
|
||||
chartType="line"
|
||||
spreadsheet={appState.pasteDialog.data}
|
||||
selected={appState.currentChartType === "line"}
|
||||
onClick={handleChartClick}
|
||||
sortChartLabels={(showSortChartLabels && sortChartLabels) ?? false}
|
||||
/>
|
||||
</div>
|
||||
{showSortChartLabels && (
|
||||
<div className={"container"}>
|
||||
<CheckboxItem
|
||||
checked={sortChartLabels}
|
||||
onChange={(checked: boolean) => {
|
||||
setSortChartLabels(checked);
|
||||
}}
|
||||
>
|
||||
{t("labels.sortChartLabels")}
|
||||
</CheckboxItem>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
102
src/components/SubtypeButton.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { t } from "../i18n";
|
||||
import { Action } from "../actions/types";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import clsx from "clsx";
|
||||
import { Subtype, isValidSubtype, subtypeCollides } from "../subtypes";
|
||||
import { ExcalidrawElement, Theme } from "../element/types";
|
||||
|
||||
export const SubtypeButton = (
|
||||
subtype: Subtype,
|
||||
parentType: ExcalidrawElement["type"],
|
||||
icon: ({ theme }: { theme: Theme }) => JSX.Element,
|
||||
key?: string,
|
||||
) => {
|
||||
const title = key !== undefined ? ` - ${getShortcutKey(key)}` : "";
|
||||
const keyTest: Action["keyTest"] =
|
||||
key !== undefined ? (event) => event.code === `Key${key}` : undefined;
|
||||
const subtypeAction: Action = {
|
||||
name: subtype,
|
||||
trackEvent: false,
|
||||
perform: (elements, appState) => {
|
||||
const inactive = !appState.activeSubtypes?.includes(subtype) ?? true;
|
||||
const activeSubtypes: Subtype[] = [];
|
||||
if (appState.activeSubtypes) {
|
||||
activeSubtypes.push(...appState.activeSubtypes);
|
||||
}
|
||||
let activated = false;
|
||||
if (inactive) {
|
||||
// Ensure `element.subtype` is well-defined
|
||||
if (!subtypeCollides(subtype, activeSubtypes)) {
|
||||
activeSubtypes.push(subtype);
|
||||
activated = true;
|
||||
}
|
||||
} else {
|
||||
// Can only be active if appState.activeSubtypes is defined
|
||||
// and contains subtype.
|
||||
activeSubtypes.splice(activeSubtypes.indexOf(subtype), 1);
|
||||
}
|
||||
const type =
|
||||
appState.activeTool.type !== "custom" &&
|
||||
isValidSubtype(subtype, appState.activeTool.type)
|
||||
? appState.activeTool.type
|
||||
: parentType;
|
||||
const activeTool = !inactive
|
||||
? appState.activeTool
|
||||
: updateActiveTool(appState, { type });
|
||||
const selectedElementIds = activated ? {} : appState.selectedElementIds;
|
||||
const selectedGroupIds = activated ? {} : appState.selectedGroupIds;
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
activeSubtypes,
|
||||
selectedElementIds,
|
||||
selectedGroupIds,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="icon"
|
||||
icon={icon.call(this, { theme: appState.theme })}
|
||||
selected={
|
||||
appState.activeSubtypes !== undefined &&
|
||||
appState.activeSubtypes.includes(subtype)
|
||||
}
|
||||
className={clsx({
|
||||
selected:
|
||||
appState.activeSubtypes &&
|
||||
appState.activeSubtypes.includes(subtype),
|
||||
})}
|
||||
title={`${t(`toolBar.${subtype}`)}${title}`}
|
||||
aria-label={t(`toolBar.${subtype}`)}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
onContextMenu={
|
||||
data && "onContextMenu" in data
|
||||
? (event: React.MouseEvent) => {
|
||||
if (
|
||||
appState.activeSubtypes === undefined ||
|
||||
(appState.activeSubtypes !== undefined &&
|
||||
!appState.activeSubtypes.includes(subtype))
|
||||
) {
|
||||
updateData(null);
|
||||
}
|
||||
data.onContextMenu(event, subtype);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
size={data?.size || "medium"}
|
||||
></ToolButton>
|
||||
),
|
||||
};
|
||||
if (key === "") {
|
||||
delete subtypeAction.keyTest;
|
||||
}
|
||||
return subtypeAction;
|
||||
};
|
@ -43,6 +43,7 @@ type ToolButtonProps =
|
||||
type: "icon";
|
||||
children?: React.ReactNode;
|
||||
onClick?(): void;
|
||||
onContextMenu?: React.MouseEventHandler;
|
||||
})
|
||||
| (ToolButtonBaseProps & {
|
||||
type: "radio";
|
||||
@ -120,6 +121,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
||||
aria-label={props["aria-label"]}
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
onContextMenu={props.type === "icon" ? props.onContextMenu : undefined}
|
||||
ref={innerRef}
|
||||
disabled={isLoading || props.isLoading}
|
||||
>
|
||||
|
@ -13,7 +13,7 @@ import clsx from "clsx";
|
||||
import { Theme } from "../element/types";
|
||||
import { THEME } from "../constants";
|
||||
|
||||
const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
export const iconFillColor = (theme: Theme) => "var(--icon-fill-color)";
|
||||
|
||||
const handlerColor = (theme: Theme) =>
|
||||
theme === THEME.LIGHT ? oc.white : "#1e1e1e";
|
||||
|
@ -33,6 +33,7 @@ import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { bumpVersion } from "../element/mutateElement";
|
||||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isValidSubtype } from "../subtypes";
|
||||
import oc from "open-color";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
@ -73,7 +74,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "customData">> & {
|
||||
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
/** @deprecated */
|
||||
boundElementIds?: readonly ExcalidrawElement["id"][];
|
||||
@ -136,6 +138,9 @@ const restoreElementWithProperties = <
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
if ("subtype" in element && isValidSubtype(element.subtype, base.type)) {
|
||||
base.subtype = element.subtype;
|
||||
}
|
||||
if ("customData" in element) {
|
||||
base.customData = element.customData;
|
||||
}
|
||||
@ -450,6 +455,12 @@ export const restoreAppState = (
|
||||
: defaultValue;
|
||||
}
|
||||
|
||||
if ("activeSubtypes" in appState) {
|
||||
nextAppState.activeSubtypes = appState.activeSubtypes;
|
||||
}
|
||||
if ("customData" in appState) {
|
||||
nextAppState.customData = appState.customData;
|
||||
}
|
||||
return {
|
||||
...nextAppState,
|
||||
cursorButton: localAppState?.cursorButton || "up",
|
||||
|
@ -5,12 +5,23 @@ import { getSizeFromPoints } from "../points";
|
||||
import { randomInteger } from "../random";
|
||||
import { Point } from "../types";
|
||||
import { getUpdatedTimestamp } from "../utils";
|
||||
import { maybeGetSubtypeProps } from "./newElement";
|
||||
import { getSubtypeMethods } from "../subtypes";
|
||||
|
||||
type ElementUpdate<TElement extends ExcalidrawElement> = Omit<
|
||||
Partial<TElement>,
|
||||
"id" | "version" | "versionNonce"
|
||||
>;
|
||||
|
||||
const cleanUpdates = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
): ElementUpdate<TElement> => {
|
||||
const subtype = maybeGetSubtypeProps(element, element.type).subtype;
|
||||
const map = getSubtypeMethods(subtype);
|
||||
return map?.clean ? (map.clean(updates) as typeof updates) : updates;
|
||||
};
|
||||
|
||||
// This function tracks updates of text elements for the purposes for collaboration.
|
||||
// The version is used to compare updates when more than one user is working in
|
||||
// the same drawing. Note: this will trigger the component to update. Make sure you
|
||||
@ -21,6 +32,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
informMutation = true,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
let increment = false;
|
||||
const oldUpdates = cleanUpdates(element, updates);
|
||||
|
||||
// casting to any because can't use `in` operator
|
||||
// (see https://github.com/microsoft/TypeScript/issues/21732)
|
||||
@ -76,6 +89,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
|
||||
(element as any)[key] = value;
|
||||
didChange = true;
|
||||
key in oldUpdates && (increment = true);
|
||||
}
|
||||
}
|
||||
if (!didChange) {
|
||||
@ -91,9 +105,11 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
invalidateShapeForElement(element);
|
||||
}
|
||||
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
if (increment) {
|
||||
element.version++;
|
||||
element.versionNonce = randomInteger();
|
||||
element.updated = getUpdatedTimestamp();
|
||||
}
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
@ -107,6 +123,8 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
updates: ElementUpdate<TElement>,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
let increment = false;
|
||||
const oldUpdates = cleanUpdates(element, updates);
|
||||
for (const key in updates) {
|
||||
const value = (updates as any)[key];
|
||||
if (typeof value !== "undefined") {
|
||||
@ -118,6 +136,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
continue;
|
||||
}
|
||||
didChange = true;
|
||||
key in oldUpdates && (increment = true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,6 +144,9 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
return element;
|
||||
}
|
||||
|
||||
if (!increment) {
|
||||
return { ...element, ...updates };
|
||||
}
|
||||
return {
|
||||
...element,
|
||||
...updates,
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
} from "../element/types";
|
||||
import { getFontString, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import { getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { mutateElement, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
@ -26,12 +26,36 @@ import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
measureTextElement,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
wrapTextElement,
|
||||
} from "./textElement";
|
||||
import { BOUND_TEXT_PADDING, VERTICAL_ALIGN } from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { getSubtypeMethods, isValidSubtype } from "../subtypes";
|
||||
|
||||
export const maybeGetSubtypeProps = (
|
||||
obj: {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
},
|
||||
type: ExcalidrawElement["type"],
|
||||
) => {
|
||||
const data: typeof obj = {};
|
||||
if ("subtype" in obj) {
|
||||
data.subtype = obj.subtype;
|
||||
}
|
||||
if ("customData" in obj) {
|
||||
data.customData = obj.customData;
|
||||
}
|
||||
if ("subtype" in data && !isValidSubtype(data.subtype, type)) {
|
||||
delete data.subtype;
|
||||
}
|
||||
if (!("subtype" in data) && "customData" in data) {
|
||||
delete data.customData;
|
||||
}
|
||||
return data as typeof obj;
|
||||
};
|
||||
|
||||
type ElementConstructorOpts = MarkOptional<
|
||||
Omit<ExcalidrawGenericElement, "id" | "type" | "isDeleted" | "updated">,
|
||||
@ -44,6 +68,8 @@ type ElementConstructorOpts = MarkOptional<
|
||||
| "version"
|
||||
| "versionNonce"
|
||||
| "link"
|
||||
| "subtype"
|
||||
| "customData"
|
||||
>;
|
||||
|
||||
const _newElementBase = <T extends ExcalidrawElement>(
|
||||
@ -69,8 +95,10 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
||||
...rest
|
||||
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
|
||||
) => {
|
||||
const { subtype, customData } = rest;
|
||||
// assign type to guard against excess properties
|
||||
const element: Merge<ExcalidrawGenericElement, { type: T["type"] }> = {
|
||||
...maybeGetSubtypeProps({ subtype, customData }, type),
|
||||
id: rest.id || randomId(),
|
||||
type,
|
||||
x,
|
||||
@ -103,8 +131,11 @@ export const newElement = (
|
||||
opts: {
|
||||
type: ExcalidrawGenericElement["type"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
): NonDeleted<ExcalidrawGenericElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
@ -138,8 +169,13 @@ export const newTextElement = (
|
||||
containerId?: ExcalidrawTextContainer["id"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureText(text, getFontString(opts));
|
||||
const metrics = measureTextElement(opts, {
|
||||
text,
|
||||
customData: opts.customData,
|
||||
});
|
||||
const offsets = getTextElementPositionOffsets(opts, metrics);
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
@ -181,7 +217,7 @@ const getAdjustedDimensions = (
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
baseline: nextBaseline,
|
||||
} = measureText(nextText, getFontString(element), maxWidth);
|
||||
} = measureTextElement(element, { text: nextText }, maxWidth);
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
@ -190,9 +226,9 @@ const getAdjustedDimensions = (
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
) {
|
||||
const prevMetrics = measureText(
|
||||
element.text,
|
||||
getFontString(element),
|
||||
const prevMetrics = measureTextElement(
|
||||
element,
|
||||
{ fontSize: element.fontSize },
|
||||
maxWidth,
|
||||
);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
@ -268,11 +304,9 @@ export const refreshTextDimensions = (
|
||||
) => {
|
||||
const container = getContainerElement(textElement);
|
||||
if (container) {
|
||||
text = wrapText(
|
||||
text = wrapTextElement(textElement, getMaxContainerWidth(container), {
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getMaxContainerWidth(container),
|
||||
);
|
||||
});
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, text);
|
||||
return { text, ...dimensions };
|
||||
@ -336,6 +370,8 @@ export const newFreeDrawElement = (
|
||||
simulatePressure: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFreeDrawElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return {
|
||||
..._newElementBase<ExcalidrawFreeDrawElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
@ -353,6 +389,8 @@ export const newLinearElement = (
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
@ -372,6 +410,8 @@ export const newImageElement = (
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawImageElement> => {
|
||||
const map = getSubtypeMethods(opts?.subtype);
|
||||
map?.clean && map.clean(opts);
|
||||
return {
|
||||
..._newElementBase<ExcalidrawImageElement>("image", opts),
|
||||
// in the future we'll support changing stroke color for some SVG elements,
|
||||
|
@ -46,7 +46,7 @@ import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerElement,
|
||||
handleBindTextResize,
|
||||
measureText,
|
||||
measureTextElement,
|
||||
} from "./textElement";
|
||||
import { getMaxContainerWidth } from "./newElement";
|
||||
|
||||
@ -211,9 +211,9 @@ const measureFontSizeFromWH = (
|
||||
if (nextFontSize < MIN_FONT_SIZE) {
|
||||
return null;
|
||||
}
|
||||
const metrics = measureText(
|
||||
element.text,
|
||||
getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }),
|
||||
const metrics = measureTextElement(
|
||||
element,
|
||||
{ fontSize: nextFontSize },
|
||||
element.containerId ? width : null,
|
||||
);
|
||||
return {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { getSubtypeMethods, SubtypeMethods } from "../subtypes";
|
||||
import { getFontString, arrayToMap, isTestEnv } from "../utils";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
@ -29,6 +30,30 @@ import {
|
||||
updateOriginalContainerCache,
|
||||
} from "./textWysiwyg";
|
||||
|
||||
export const measureTextElement = function (element, next, maxWidth) {
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
if (map?.measureText) {
|
||||
return map.measureText(element, next, maxWidth);
|
||||
}
|
||||
|
||||
const fontSize = next?.fontSize ?? element.fontSize;
|
||||
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
|
||||
const text = next?.text ?? element.text;
|
||||
return measureText(text, font, maxWidth);
|
||||
} as SubtypeMethods["measureText"];
|
||||
|
||||
export const wrapTextElement = function (element, containerWidth, next) {
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
if (map?.wrapText) {
|
||||
return map.wrapText(element, containerWidth, next);
|
||||
}
|
||||
|
||||
const fontSize = next?.fontSize ?? element.fontSize;
|
||||
const font = getFontString({ fontSize, fontFamily: element.fontFamily });
|
||||
const text = next?.text ?? element.originalText;
|
||||
return wrapText(text, font, containerWidth);
|
||||
} as SubtypeMethods["wrapText"];
|
||||
|
||||
export const normalizeText = (text: string) => {
|
||||
return (
|
||||
text
|
||||
@ -47,13 +72,15 @@ export const redrawTextBoundingBox = (
|
||||
let text = textElement.text;
|
||||
if (container) {
|
||||
maxWidth = getMaxContainerWidth(container);
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
text = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
const metrics = measureText(text, getFontString(textElement), maxWidth);
|
||||
const width = measureTextElement(
|
||||
textElement,
|
||||
{ text: textElement.originalText },
|
||||
maxWidth,
|
||||
).width;
|
||||
const { height, baseline } = measureTextElement(textElement, { text });
|
||||
const metrics = { width, height, baseline };
|
||||
let coordY = textElement.y;
|
||||
let coordX = textElement.x;
|
||||
// Resize container and vertically center align the text
|
||||
@ -175,16 +202,12 @@ export const handleBindTextResize = (
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (text) {
|
||||
text = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
text = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
const dimensions = measureText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
const dimensions = measureTextElement(
|
||||
textElement,
|
||||
{ text },
|
||||
container.width,
|
||||
);
|
||||
nextHeight = dimensions.height;
|
||||
nextWidth = dimensions.width;
|
||||
|
@ -10,8 +10,10 @@ import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import {
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontString,
|
||||
} from "./types";
|
||||
import * as textElementUtils from "./textElement";
|
||||
import { getFontString } from "../utils";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { resize } from "../tests/utils";
|
||||
@ -675,39 +677,53 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
it("should wrap text and vertcially center align once text submitted", async () => {
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation((text, font, maxWidth) => {
|
||||
let width = INITIAL_WIDTH;
|
||||
let height = APPROX_LINE_HEIGHT;
|
||||
let baseline = 10;
|
||||
if (!text) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
baseline = 30;
|
||||
width = DUMMY_WIDTH;
|
||||
if (text === "Hello \nWorld!") {
|
||||
height = APPROX_LINE_HEIGHT * 2;
|
||||
}
|
||||
if (maxWidth) {
|
||||
width = maxWidth;
|
||||
// To capture cases where maxWidth passed is initial width
|
||||
// due to which the text is not wrapped correctly
|
||||
if (maxWidth === INITIAL_WIDTH) {
|
||||
height = DUMMY_HEIGHT;
|
||||
}
|
||||
}
|
||||
const mockMeasureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
let width = INITIAL_WIDTH;
|
||||
let height = APPROX_LINE_HEIGHT;
|
||||
let baseline = 10;
|
||||
if (!text) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
});
|
||||
}
|
||||
baseline = 30;
|
||||
width = DUMMY_WIDTH;
|
||||
if (text === "Hello \nWorld!") {
|
||||
height = APPROX_LINE_HEIGHT * 2;
|
||||
}
|
||||
if (maxWidth) {
|
||||
width = maxWidth;
|
||||
// To capture cases where maxWidth passed is initial width
|
||||
// due to which the text is not wrapped correctly
|
||||
if (maxWidth === INITIAL_WIDTH) {
|
||||
height = DUMMY_HEIGHT;
|
||||
}
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
};
|
||||
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation(mockMeasureText);
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureTextElement")
|
||||
.mockImplementation((element, next, maxWidth) => {
|
||||
return mockMeasureText(
|
||||
next?.text ?? element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
});
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
@ -1057,28 +1073,42 @@ describe("textWysiwyg", () => {
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation((text, font, maxWidth) => {
|
||||
let width = INITIAL_WIDTH;
|
||||
let height = APPROX_LINE_HEIGHT;
|
||||
let baseline = 10;
|
||||
if (!text) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
baseline = 30;
|
||||
width = DUMMY_WIDTH;
|
||||
height = APPROX_LINE_HEIGHT * 5;
|
||||
|
||||
const mockMeasureText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth?: number | null,
|
||||
) => {
|
||||
let width = INITIAL_WIDTH;
|
||||
let height = APPROX_LINE_HEIGHT;
|
||||
let baseline = 10;
|
||||
if (!text) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
}
|
||||
baseline = 30;
|
||||
width = DUMMY_WIDTH;
|
||||
height = APPROX_LINE_HEIGHT * 5;
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
baseline,
|
||||
};
|
||||
};
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureText")
|
||||
.mockImplementation(mockMeasureText);
|
||||
jest
|
||||
.spyOn(textElementUtils, "measureTextElement")
|
||||
.mockImplementation((element, next, maxWidth) => {
|
||||
return mockMeasureText(
|
||||
next?.text ?? element.text,
|
||||
getFontString(element),
|
||||
maxWidth,
|
||||
);
|
||||
});
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
measureText,
|
||||
getTextWidth,
|
||||
normalizeText,
|
||||
wrapText,
|
||||
@ -43,6 +44,7 @@ import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { parseClipboard } from "../clipboard";
|
||||
|
||||
const getTransform = (
|
||||
offsetX: number,
|
||||
width: number,
|
||||
height: number,
|
||||
angle: number,
|
||||
@ -60,7 +62,7 @@ const getTransform = (
|
||||
if (height > maxHeight && zoom.value !== 1) {
|
||||
translateY = (maxHeight * (zoom.value - 1)) / 2;
|
||||
}
|
||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
|
||||
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg) translate(${offsetX}px, 0px)`;
|
||||
};
|
||||
|
||||
const originalContainerCache: {
|
||||
@ -153,11 +155,19 @@ export const textWysiwyg = ({
|
||||
if (updatedTextElement && isTextElement(updatedTextElement)) {
|
||||
let coordX = updatedTextElement.x;
|
||||
let coordY = updatedTextElement.y;
|
||||
let eCoordY = coordY;
|
||||
const container = getContainerElement(updatedTextElement);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
|
||||
let maxHeight = updatedTextElement.height;
|
||||
const width = updatedTextElement.width;
|
||||
// Editing metrics
|
||||
const eMetrics = measureText(
|
||||
updatedTextElement.originalText,
|
||||
getFontString(updatedTextElement),
|
||||
container ? getContainerDims(container).width : null,
|
||||
);
|
||||
|
||||
let maxWidth = eMetrics.width;
|
||||
let maxHeight = eMetrics.height;
|
||||
const width = eMetrics.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
@ -170,6 +180,7 @@ export const textWysiwyg = ({
|
||||
);
|
||||
coordX = boundTextCoords.x;
|
||||
coordY = boundTextCoords.y;
|
||||
eCoordY = coordY;
|
||||
}
|
||||
const propertiesUpdated = textPropertiesUpdated(
|
||||
updatedTextElement,
|
||||
@ -183,7 +194,14 @@ export const textWysiwyg = ({
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
const font = getFontString(updatedTextElement);
|
||||
textElementHeight =
|
||||
getApproxLineHeight(font) *
|
||||
updatedTextElement.text.split("\n").length;
|
||||
textElementHeight = Math.max(
|
||||
textElementHeight,
|
||||
updatedTextElement.height,
|
||||
);
|
||||
}
|
||||
|
||||
let originalContainerData;
|
||||
@ -235,6 +253,7 @@ export const textWysiwyg = ({
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
container.y + containerDims.height / 2 - textElementHeight / 2;
|
||||
eCoordY = coordY + textElementHeight / 2 - eMetrics.height / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
@ -243,10 +262,11 @@ export const textWysiwyg = ({
|
||||
containerDims.height -
|
||||
textElementHeight -
|
||||
getBoundTextElementOffset(updatedTextElement);
|
||||
eCoordY = coordY + textElementHeight - eMetrics.height;
|
||||
}
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, eCoordY);
|
||||
const initialSelectionStart = editable.selectionStart;
|
||||
const initialSelectionEnd = editable.selectionEnd;
|
||||
const initialLength = editable.value.length;
|
||||
@ -268,10 +288,24 @@ export const textWysiwyg = ({
|
||||
const lines = updatedTextElement.originalText.split("\n");
|
||||
const lineHeight = updatedTextElement.containerId
|
||||
? approxLineHeight
|
||||
: updatedTextElement.height / lines.length;
|
||||
: eMetrics.height / lines.length;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
}
|
||||
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
|
||||
const offWidth = container
|
||||
? Math.min(
|
||||
0,
|
||||
updatedTextElement.width - Math.min(maxWidth, eMetrics.width),
|
||||
)
|
||||
: Math.min(maxWidth, updatedTextElement.width) -
|
||||
Math.min(maxWidth, eMetrics.width);
|
||||
const offsetX =
|
||||
textAlign === "right"
|
||||
? offWidth
|
||||
: textAlign === "center"
|
||||
? offWidth / 2
|
||||
: 0;
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
@ -285,9 +319,12 @@ export const textWysiwyg = ({
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
top: `${viewportY}px`,
|
||||
transformOrigin: `${updatedTextElement.width / 2}px
|
||||
${updatedTextElement.height / 2}px`,
|
||||
transform: getTransform(
|
||||
width,
|
||||
textElementHeight,
|
||||
offsetX,
|
||||
updatedTextElement.width,
|
||||
updatedTextElement.height,
|
||||
getTextElementAngle(updatedTextElement),
|
||||
appState,
|
||||
maxWidth,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Subtype } from "../subtypes";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
FONT_FAMILY,
|
||||
@ -63,6 +64,7 @@ type _ExcalidrawElementBase = Readonly<{
|
||||
updated: number;
|
||||
link: string | null;
|
||||
locked: boolean;
|
||||
subtype?: Subtype;
|
||||
customData?: Record<string, any>;
|
||||
}>;
|
||||
|
||||
|
@ -46,12 +46,15 @@ class LocalFileManager extends FileManager {
|
||||
const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appStateOnly = false,
|
||||
) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
if (!appStateOnly) {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
|
||||
JSON.stringify(clearElementsForLocalStorage(elements)),
|
||||
);
|
||||
}
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
|
||||
JSON.stringify(clearAppStateForLocalStorage(appState)),
|
||||
@ -72,8 +75,12 @@ export class LocalData {
|
||||
appState: AppState,
|
||||
files: BinaryFiles,
|
||||
onFilesSaved: () => void,
|
||||
appStateOnly = false,
|
||||
) => {
|
||||
saveDataStateToLocalStorage(elements, appState);
|
||||
saveDataStateToLocalStorage(elements, appState, appStateOnly);
|
||||
if (appStateOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.fileStorage.saveFiles({
|
||||
elements,
|
||||
@ -97,6 +104,14 @@ export class LocalData {
|
||||
}
|
||||
};
|
||||
|
||||
/** Saves the AppState, only if saving is paused. */
|
||||
static saveAppState = (appState: AppState) => {
|
||||
// we need to make the `isSavePaused` check synchronously (undebounced)
|
||||
if (this.isSavePaused()) {
|
||||
this._save([], appState, {}, () => {}, true);
|
||||
}
|
||||
};
|
||||
|
||||
static flushSave = () => {
|
||||
this._save.flush();
|
||||
};
|
||||
|
@ -5,6 +5,7 @@ import { trackEvent } from "../analytics";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { ErrorDialog } from "../components/ErrorDialog";
|
||||
import { TopErrorBoundary } from "../components/TopErrorBoundary";
|
||||
import { useExtensions } from "@excalidraw/extensions";
|
||||
import {
|
||||
APP_NAME,
|
||||
EVENT,
|
||||
@ -189,7 +190,7 @@ const initializeScene = async (opts: {
|
||||
...restoreAppState(
|
||||
{
|
||||
...scene?.appState,
|
||||
theme: localDataState?.appState?.theme || scene?.appState?.theme,
|
||||
...localDataState?.appState,
|
||||
},
|
||||
excalidrawAPI.getAppState(),
|
||||
),
|
||||
@ -252,6 +253,8 @@ const ExcalidrawWrapper = () => {
|
||||
const [excalidrawAPI, excalidrawRefCallback] =
|
||||
useCallbackRefState<ExcalidrawImperativeAPI>();
|
||||
|
||||
useExtensions(excalidrawAPI);
|
||||
|
||||
const [collabAPI] = useAtom(collabAPIAtom);
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
||||
@ -538,6 +541,8 @@ const ExcalidrawWrapper = () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
LocalData.saveAppState(appState);
|
||||
}
|
||||
};
|
||||
|
||||
|
1
src/global.d.ts
vendored
@ -14,6 +14,7 @@ interface Document {
|
||||
interface Window {
|
||||
ClipboardItem: any;
|
||||
__EXCALIDRAW_SHA__: string | undefined;
|
||||
EXCALIDRAW_EXTENSIONS_ASSET_PATH: string | undefined;
|
||||
EXCALIDRAW_ASSET_PATH: string | undefined;
|
||||
EXCALIDRAW_EXPORT_SOURCE: string;
|
||||
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
|
||||
|
34
src/i18n.ts
@ -82,6 +82,22 @@ if (process.env.NODE_ENV === ENV.DEVELOPMENT) {
|
||||
let currentLang: Language = defaultLang;
|
||||
let currentLangData = {};
|
||||
|
||||
const auxCurrentLangData = Array<Object>();
|
||||
const auxFallbackLangData = Array<Object>();
|
||||
const auxSetLanguageFuncs =
|
||||
Array<(langCode: string) => Promise<Object | undefined>>();
|
||||
|
||||
export const registerAuxLangData = (
|
||||
fallbackLangData: Object,
|
||||
setLanguageAux: (langCode: string) => Promise<Object | undefined>,
|
||||
) => {
|
||||
if (auxFallbackLangData.includes(fallbackLangData)) {
|
||||
return;
|
||||
}
|
||||
auxFallbackLangData.push(fallbackLangData);
|
||||
auxSetLanguageFuncs.push(setLanguageAux);
|
||||
};
|
||||
|
||||
export const setLanguage = async (lang: Language) => {
|
||||
currentLang = lang;
|
||||
document.documentElement.dir = currentLang.rtl ? "rtl" : "ltr";
|
||||
@ -94,6 +110,17 @@ export const setLanguage = async (lang: Language) => {
|
||||
currentLangData = await import(
|
||||
/* webpackChunkName: "locales/[request]" */ `./locales/${currentLang.code}.json`
|
||||
);
|
||||
// Empty the auxCurrentLangData array
|
||||
while (auxCurrentLangData.length > 0) {
|
||||
auxCurrentLangData.pop();
|
||||
}
|
||||
// Fill the auxCurrentLangData array with each locale file found in auxLangDataRoots for this language
|
||||
auxSetLanguageFuncs.forEach(async (setLanguageFn) => {
|
||||
const condData = await setLanguageFn(currentLang.code);
|
||||
if (condData) {
|
||||
auxCurrentLangData.push(condData);
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to load language ${lang.code}:`, error.message);
|
||||
currentLangData = fallbackLangData;
|
||||
@ -132,6 +159,13 @@ export const t = (
|
||||
let translation =
|
||||
findPartsForData(currentLangData, parts) ||
|
||||
findPartsForData(fallbackLangData, parts);
|
||||
const auxData = Array<Object>().concat(
|
||||
auxCurrentLangData,
|
||||
auxFallbackLangData,
|
||||
);
|
||||
for (let i = 0; i < auxData.length; i++) {
|
||||
translation = translation || findPartsForData(auxData[i], parts);
|
||||
}
|
||||
if (translation === undefined) {
|
||||
throw new Error(`Can't find translation for ${path}`);
|
||||
}
|
||||
|
@ -29,6 +29,8 @@ export const KEYS = {
|
||||
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",
|
||||
|
@ -3,6 +3,7 @@
|
||||
"paste": "Paste",
|
||||
"pasteAsPlaintext": "Paste as plaintext",
|
||||
"pasteCharts": "Paste charts",
|
||||
"sortChartLabels": "Sort numerical labels in ascending order",
|
||||
"selectAll": "Select all",
|
||||
"multiSelect": "Add element to selection",
|
||||
"moveCanvas": "Move canvas",
|
||||
|
@ -31,6 +31,12 @@ Please add the latest change on the top under the correct section.
|
||||
|
||||
#### Features
|
||||
|
||||
- Render math notation using the MathJax library. Both standard Latex input and simplified AsciiMath input are supported.
|
||||
|
||||
Also added plugin-like subtypes for `ExcalidrawElement`. These allow easily supporting custom extensions of `ExcalidrawElement`s such as for MathJax, Markdown, or inline code.
|
||||
|
||||
Also created an `@excalidraw/extensions` package. This package holds the MathJax extension to make it completely decoupled from `@excalidraw/excalidraw`. The MathJax extension is implemented as a `math` subtype of `ExcalidrawTextElement`. [#2993](https://github.com/excalidraw/excalidraw/pull/2993).
|
||||
|
||||
- `restoreElements()` now takes an optional parameter to indicate whether we should also recalculate text element dimensions. Defaults to `true`, but since this is a potentially costly operation, you may want to disable it if you restore elements in tight loops, such as during collaboration [#5432](https://github.com/excalidraw/excalidraw/pull/5432).
|
||||
- Support rendering custom sidebar using [`renderSidebar`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#renderSidebar) prop ([#5663](https://github.com/excalidraw/excalidraw/pull/5663)).
|
||||
- Add [`toggleMenu`](https://github.com/excalidraw/excalidraw/blob/master/src/packages/excalidraw/README.md#toggleMenu) prop to toggle specific menu open/close state ([#5663](https://github.com/excalidraw/excalidraw/pull/5663)).
|
||||
|
@ -94,7 +94,7 @@ const COMMENT_INPUT_WIDTH = 150;
|
||||
const renderTopRightUI = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => alert("This is dummy top right UI")}
|
||||
onClick={() => alert("This is an empty top right UI")}
|
||||
style={{ height: "2.5rem" }}
|
||||
>
|
||||
{" "}
|
||||
@ -103,7 +103,13 @@ const renderTopRightUI = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
export interface AppProps {
|
||||
appTitle: string;
|
||||
useCustom: (api: ExcalidrawImperativeAPI | null, customArgs?: any[]) => void;
|
||||
customArgs?: any[];
|
||||
}
|
||||
|
||||
export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
||||
const appRef = useRef<any>(null);
|
||||
const [viewModeEnabled, setViewModeEnabled] = useState(false);
|
||||
const [zenModeEnabled, setZenModeEnabled] = useState(false);
|
||||
@ -130,6 +136,8 @@ export default function App() {
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI | null>(null);
|
||||
|
||||
useCustom(excalidrawAPI, customArgs);
|
||||
|
||||
useHandleLibrary({ excalidrawAPI });
|
||||
|
||||
useEffect(() => {
|
||||
@ -137,7 +145,7 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
const fetchData = async () => {
|
||||
const res = await fetch("/rocket.jpeg");
|
||||
const res = await fetch("/images/rocket.jpeg");
|
||||
const imageData = await res.blob();
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(imageData);
|
||||
@ -397,7 +405,7 @@ export default function App() {
|
||||
}}
|
||||
>
|
||||
<div className="comment-avatar">
|
||||
<img src="doremon.png" alt="doremon" />
|
||||
<img src="images/doremon.png" alt="doremon" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -497,7 +505,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div className="App" ref={appRef}>
|
||||
<h1> Excalidraw Example</h1>
|
||||
<h1>{appTitle}</h1>
|
||||
<ExampleSidebar>
|
||||
<div className="button-wrapper">
|
||||
<button onClick={loadSceneOrLibrary}>Load Scene or Library</button>
|
||||
@ -583,15 +591,15 @@ export default function App() {
|
||||
const collaborators = new Map();
|
||||
collaborators.set("id1", {
|
||||
username: "Doremon",
|
||||
avatarUrl: "doremon.png",
|
||||
avatarUrl: "images/doremon.png",
|
||||
});
|
||||
collaborators.set("id2", {
|
||||
username: "Excalibot",
|
||||
avatarUrl: "excalibot.png",
|
||||
avatarUrl: "images/excalibot.png",
|
||||
});
|
||||
collaborators.set("id3", {
|
||||
username: "Pika",
|
||||
avatarUrl: "pika.jpeg",
|
||||
avatarUrl: "images/pika.jpeg",
|
||||
});
|
||||
collaborators.set("id4", {
|
||||
username: "fallback",
|
||||
|
@ -8,6 +8,9 @@ const root = createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<App
|
||||
appTitle={"Excalidraw Example"}
|
||||
useCustom={(api: any, args?: any[]) => {}}
|
||||
/>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 39 KiB |
@ -10,8 +10,8 @@ export default function Sidebar({ children }: { children: React.ReactNode }) {
|
||||
x
|
||||
</button>
|
||||
<div className="sidebar-links">
|
||||
<button>Dummy Home</button>
|
||||
<button>Dummy About</button>{" "}
|
||||
<button>Empty Home</button>
|
||||
<button>Empty About</button>{" "}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${open ? "sidebar-open" : ""}`}>
|
||||
|
@ -2,6 +2,12 @@ import { ENV } from "../../constants";
|
||||
if (process.env.NODE_ENV !== ENV.TEST) {
|
||||
/* eslint-disable */
|
||||
/* global __webpack_public_path__:writable */
|
||||
if (process.env.NODE_ENV === ENV.DEVELOPMENT && (
|
||||
window.EXCALIDRAW_ASSET_PATH === undefined ||
|
||||
window.EXCALIDRAW_ASSET_PATH === ""
|
||||
)) {
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
}
|
||||
__webpack_public_path__ =
|
||||
window.EXCALIDRAW_ASSET_PATH ||
|
||||
`https://unpkg.com/${process.env.PKG_NAME}@${process.env.PKG_VERSION}/dist/`;
|
||||
|
2
src/packages/extensions/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
dist
|
24
src/packages/extensions/CHANGELOG.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
<!--
|
||||
Guidelines for changelog:
|
||||
The change should be grouped under one of the following sections and must contain a PR link.
|
||||
- Features: For new features.
|
||||
- Fixes: For bug fixes.
|
||||
- Chore: Changes for non src files example package.json.
|
||||
- Refactor: For any refactoring.
|
||||
|
||||
Please add the latest change at the top under the correct section.
|
||||
-->
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Excalidraw Extensions
|
||||
|
||||
#### Features
|
||||
|
||||
- Render math notation using the MathJax library. Both standard Latex input and simplified AsciiMath input are supported. MathJax support is implemented as a `math` subtype of `ExcalidrawTextElement`.
|
||||
|
||||
Also added plugin-like subtypes for `ExcalidrawElement`. These allow easily supporting custom extensions of `ExcalidrawElement`s such as for MathJax, Markdown, or inline code. [#5311](https://github.com/excalidraw/excalidraw/pull/5311).
|
||||
|
||||
- Provided a stub example extension (`./empty/index.ts`).
|
45
src/packages/extensions/README.md
Normal file
@ -0,0 +1,45 @@
|
||||
#### Note
|
||||
|
||||
⚠️ ⚠️ ⚠️ You are viewing the docs for the **next** release, in case you want to check the docs for the stable release, you can view it [here](https://www.npmjs.com/package/@excalidraw/extensions).
|
||||
|
||||
### Extensions
|
||||
|
||||
Excalidraw extensions to be used in Excalidraw.
|
||||
|
||||
### Installation
|
||||
|
||||
You can use npm
|
||||
|
||||
```
|
||||
npm install react react-dom @excalidraw/extensions
|
||||
```
|
||||
|
||||
or via yarn
|
||||
|
||||
```
|
||||
yarn add react react-dom @excalidraw/extensions
|
||||
```
|
||||
|
||||
After installation you will see a folder `excalidraw-extensions-assets` and `excalidraw-extensions-assets-dev` in `dist` directory which contains the assets needed for this app in prod and dev mode respectively.
|
||||
|
||||
Move the folder `excalidraw-extensions-assets` and `excalidraw-extensions-assets-dev` to the path where your assets are served.
|
||||
|
||||
By default it will try to load the files from `https://unpkg.com/@excalidraw/extensions/dist/`
|
||||
|
||||
If you want to load assets from a different path you can set a variable `window.EXCALIDRAW_EXTENSIONS_ASSET_PATH` depending on environment (for example if you have different URL's for dev and prod) to the url from where you want to load the assets.
|
||||
|
||||
#### Note
|
||||
|
||||
**If you don't want to wait for the next stable release and try out the unreleased changes you can use `@excalidraw/extensions@next`.**
|
||||
|
||||
### Need help?
|
||||
|
||||
Check out the existing [Q&A](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aextensions). If you have any queries or need help, ask us [here](https://github.com/excalidraw/excalidraw/discussions?discussions_q=label%3Apackage%3Aextensions).
|
||||
|
||||
### Development
|
||||
|
||||
#### Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
```
|
18
src/packages/extensions/env.js
Normal file
@ -0,0 +1,18 @@
|
||||
const dotenv = require("dotenv");
|
||||
const { readFileSync } = require("fs");
|
||||
const pkg = require("./package.json");
|
||||
const parseEnvVariables = (filepath) => {
|
||||
const envVars = Object.entries(dotenv.parse(readFileSync(filepath))).reduce(
|
||||
(env, [key, value]) => {
|
||||
env[key] = JSON.stringify(value);
|
||||
return env;
|
||||
},
|
||||
{},
|
||||
);
|
||||
envVars.PKG_NAME = JSON.stringify(pkg.name);
|
||||
envVars.PKG_VERSION = JSON.stringify(pkg.version);
|
||||
envVars.IS_EXCALIDRAW_EXTENSIONS_NPM_PACKAGE = JSON.stringify(true);
|
||||
return envVars;
|
||||
};
|
||||
|
||||
module.exports = { parseEnvVariables };
|
23
src/packages/extensions/example/index.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import App from "../../excalidraw/example/App";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ExcalidrawExtensionsLib: any;
|
||||
}
|
||||
}
|
||||
const { useExtensions } = window.ExcalidrawExtensionsLib;
|
||||
|
||||
const rootElement = document.getElementById("root");
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App
|
||||
appTitle={"Excalidraw Extensions Example"}
|
||||
useCustom={useExtensions}
|
||||
customArgs={["mathjax"]}
|
||||
/>
|
||||
</React.StrictMode>,
|
||||
rootElement,
|
||||
);
|
1
src/packages/extensions/example/public/excalidraw-assets-dev
Symbolic link
@ -0,0 +1 @@
|
||||
../../../excalidraw/dist/excalidraw-assets-dev/
|
1
src/packages/extensions/example/public/excalidraw.development.js
Symbolic link
@ -0,0 +1 @@
|
||||
../../../excalidraw/dist/excalidraw.development.js
|
1
src/packages/extensions/example/public/images
Symbolic link
@ -0,0 +1 @@
|
||||
../../../excalidraw/example/public/images/
|
32
src/packages/extensions/example/public/index.html
Normal file
@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
|
||||
<title>React App</title>
|
||||
<script>
|
||||
window.EXCALIDRAW_ASSET_PATH = "/";
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH = "/";
|
||||
window.name = "codesandbox";
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript> You need to enable JavaScript to run this app. </noscript>
|
||||
<div id="root"></div>
|
||||
<script src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
|
||||
<script src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- This is so that we use the bundled excalidraw.development.js file instead
|
||||
of the actual source code -->
|
||||
<script src="./excalidraw.development.js"></script>
|
||||
<script src="./excalidraw-extensions.development.js"></script>
|
||||
|
||||
<script src="./bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
3
src/packages/extensions/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import "./publicPath";
|
||||
|
||||
export * from "./ts/node-main";
|
86
src/packages/extensions/package.json
Normal file
@ -0,0 +1,86 @@
|
||||
{
|
||||
"name": "@excalidraw/extensions",
|
||||
"version": "0.12.0",
|
||||
"main": "index.ts",
|
||||
"files": [
|
||||
"dist/*"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"description": "Excalidraw extensions",
|
||||
"repository": "https://github.com/excalidraw/excalidraw",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"excalidraw",
|
||||
"excalidraw-embed",
|
||||
"react",
|
||||
"npm",
|
||||
"npm excalidraw"
|
||||
],
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not ie <= 11",
|
||||
"not op_mini all",
|
||||
"not safari < 12",
|
||||
"not kaios <= 2.5",
|
||||
"not edge < 79",
|
||||
"not chrome < 70",
|
||||
"not and_uc < 13",
|
||||
"not samsung < 10"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.18.9",
|
||||
"@babel/plugin-transform-arrow-functions": "7.18.6",
|
||||
"@babel/plugin-transform-async-to-generator": "7.18.6",
|
||||
"@babel/plugin-transform-runtime": "7.18.9",
|
||||
"@babel/plugin-transform-typescript": "7.18.8",
|
||||
"@babel/preset-env": "7.18.6",
|
||||
"@babel/preset-react": "7.18.6",
|
||||
"@babel/preset-typescript": "7.18.6",
|
||||
"autoprefixer": "10.4.7",
|
||||
"babel-loader": "8.2.5",
|
||||
"babel-plugin-transform-class-properties": "6.24.1",
|
||||
"cross-env": "7.0.3",
|
||||
"css-loader": "6.7.1",
|
||||
"dotenv": "16.0.1",
|
||||
"mini-css-extract-plugin": "2.6.1",
|
||||
"postcss-loader": "7.0.1",
|
||||
"sass-loader": "13.0.2",
|
||||
"terser-webpack-plugin": "5.3.3",
|
||||
"ts-loader": "9.3.1",
|
||||
"typescript": "4.7.4",
|
||||
"webpack": "5.73.0",
|
||||
"webpack-bundle-analyzer": "4.5.0",
|
||||
"webpack-cli": "4.10.0",
|
||||
"webpack-dev-server": "4.9.3",
|
||||
"webpack-merge": "5.8.0"
|
||||
},
|
||||
"bugs": "https://github.com/excalidraw/excalidraw/issues",
|
||||
"homepage": "https://github.com/excalidraw/excalidraw/tree/master/src/packages/extensions",
|
||||
"scripts": {
|
||||
"gen:types": "tsc --project ../../../tsconfig-types.json",
|
||||
"build:umd": "rm -rf dist && cross-env NODE_ENV=production webpack --config webpack.prod.config.js && cross-env NODE_ENV=development webpack --config webpack.dev.config.js && yarn gen:types",
|
||||
"build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js",
|
||||
"pack": "yarn build:umd && yarn pack",
|
||||
"start": "webpack serve --config webpack.dev-server.config.js",
|
||||
"install:deps": "yarn install --frozen-lockfile && yarn --cwd ../../../",
|
||||
"build:deps": "yarn --cwd ../excalidraw cross-env NODE_ENV=development webpack --config webpack.dev.config.js",
|
||||
"build:example": "EXAMPLE=true webpack --config webpack.dev-server.config.js && yarn gen:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"mathjax-full": "3.2.2"
|
||||
}
|
||||
}
|
14
src/packages/extensions/publicPath.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { ENV } from "../../constants";
|
||||
if (process.env.NODE_ENV !== ENV.TEST) {
|
||||
/* eslint-disable */
|
||||
/* global __webpack_public_path__:writable */
|
||||
if (process.env.NODE_ENV === ENV.DEVELOPMENT && (
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH === undefined ||
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH === ""
|
||||
)) {
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH = "/";
|
||||
}
|
||||
__webpack_public_path__ =
|
||||
window.EXCALIDRAW_EXTENSIONS_ASSET_PATH ||
|
||||
`https://unpkg.com/${process.env.PKG_NAME}@${process.env.PKG_VERSION}/dist/`;
|
||||
}
|
26
src/packages/extensions/ts/empty/index.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { useEffect } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../../../types";
|
||||
|
||||
// Extension authors: provide a extension name here like "myextension"
|
||||
export const EmptyExtension = "empty";
|
||||
|
||||
// Extension authors: provide a hook like `useMyExtension` in `myextension/index`
|
||||
export const useEmptyExtension = (api: ExcalidrawImperativeAPI | null) => {
|
||||
const enabled = emptyExtensionLoadable;
|
||||
useEffect(() => {
|
||||
if (enabled) {
|
||||
}
|
||||
}, [enabled, api]);
|
||||
};
|
||||
|
||||
// Extension authors: Use a variable like `myExtensionLoadable` to determine
|
||||
// whether or not to do anything in each of `useMyExtension` and `testMyExtension`.
|
||||
let emptyExtensionLoadable = false;
|
||||
|
||||
export const getEmptyExtensionLoadable = () => {
|
||||
return emptyExtensionLoadable;
|
||||
};
|
||||
|
||||
export const setEmptyExtensionLoadable = (loadable: boolean) => {
|
||||
emptyExtensionLoadable = loadable;
|
||||
};
|
4
src/packages/extensions/ts/global.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module SREfeature {
|
||||
function custom(locale: string): Promise<string>;
|
||||
export = custom;
|
||||
}
|
13
src/packages/extensions/ts/mathjax/icon.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Theme } from "../../../../element/types";
|
||||
import { createIcon, iconFillColor } from "../../../../components/icons";
|
||||
|
||||
// We inline font-awesome icons in order to save on js size rather than including the font awesome react library
|
||||
export const mathSubtypeIcon = ({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<path
|
||||
fill={iconFillColor(theme)}
|
||||
// fa-square-root-variable-solid
|
||||
d="M289 24.2C292.5 10 305.3 0 320 0H544c17.7 0 32 14.3 32 32s-14.3 32-32 32H345L239 487.8c-3.2 13-14.2 22.6-27.6 24s-26.1-5.5-32.1-17.5L76.2 288H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H96c12.1 0 23.2 6.8 28.6 17.7l73.3 146.6L289 24.2zM393.4 233.4c12.5-12.5 32.8-12.5 45.3 0L480 274.7l41.4-41.4c12.5-12.5 32.8-12.5 45.3 0s12.5 32.8 0 45.3L525.3 320l41.4 41.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L480 365.3l-41.4 41.4c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L434.7 320l-41.4-41.4c-12.5-12.5-12.5-32.8 0-45.3z"
|
||||
/>,
|
||||
{ width: 576, height: 512, mirror: true, strokeWidth: 1.25 },
|
||||
);
|
1507
src/packages/extensions/ts/mathjax/implementation.tsx
Normal file
32
src/packages/extensions/ts/mathjax/index.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { useEffect } from "react";
|
||||
import { ExcalidrawImperativeAPI } from "../../../../types";
|
||||
import { addSubtypeMethods } from "../../../../subtypes";
|
||||
import { getMathSubtypeRecord } from "./types";
|
||||
import { prepareMathSubtype } from "./implementation";
|
||||
|
||||
export const MathJaxExtension = "mathjax";
|
||||
|
||||
// Extension authors: provide a hook like `useMyExtension` in `myextension/index`
|
||||
export const useMathJaxExtension = (api: ExcalidrawImperativeAPI | null) => {
|
||||
const enabled = mathJaxExtensionLoadable;
|
||||
useEffect(() => {
|
||||
if (enabled && api) {
|
||||
const prep = api.addSubtype(getMathSubtypeRecord(), prepareMathSubtype);
|
||||
if (prep) {
|
||||
addSubtypeMethods(getMathSubtypeRecord().subtype, prep.methods);
|
||||
}
|
||||
}
|
||||
}, [enabled, api]);
|
||||
};
|
||||
|
||||
// Extension authors: Use a variable like `myExtensionLoadable` to determine
|
||||
// whether or not to do anything in each of `useMyExtension` and `testMyExtension`.
|
||||
let mathJaxExtensionLoadable = false;
|
||||
|
||||
export const getMathJaxExtensionLoadable = () => {
|
||||
return mathJaxExtensionLoadable;
|
||||
};
|
||||
|
||||
export const setMathJaxExtensionLoadable = (loadable: boolean) => {
|
||||
mathJaxExtensionLoadable = loadable;
|
||||
};
|
15
src/packages/extensions/ts/mathjax/locales/en.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"labels": {
|
||||
"changeMathOnly": "Math display",
|
||||
"mathOnlyTrue": "Math only",
|
||||
"mathOnlyFalse": "Mixed text",
|
||||
"resetUseTex": "Reset math input type",
|
||||
"useTexTrueActive": "✔ Standard input",
|
||||
"useTexTrueInactive": "Standard input",
|
||||
"useTexFalseActive": "✔ Simplified input",
|
||||
"useTexFalseInactive": "Simplified input"
|
||||
},
|
||||
"toolBar": {
|
||||
"math": "Math"
|
||||
}
|
||||
}
|
17
src/packages/extensions/ts/mathjax/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { getShortcutKey } from "../../../../utils";
|
||||
import { SubtypeRecord } from "../../../../subtypes";
|
||||
|
||||
// Exports
|
||||
export const getMathSubtypeRecord = () => mathSubtype;
|
||||
|
||||
// Use `getMathSubtype` so we don't have to export this
|
||||
const mathSubtype: SubtypeRecord = {
|
||||
subtype: "math",
|
||||
parents: ["text"],
|
||||
actionNames: ["useTexTrue", "useTexFalse", "resetUseTex", "changeMathOnly"],
|
||||
disabledNames: ["changeFontFamily"],
|
||||
shortcutMap: {
|
||||
resetUseTex: [getShortcutKey("Shift+R")],
|
||||
},
|
||||
alwaysEnabledNames: ["useTexTrue", "useTexFalse"],
|
||||
};
|
59
src/packages/extensions/ts/node-main.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { ExcalidrawImperativeAPI } from "../../../types";
|
||||
import {
|
||||
EmptyExtension,
|
||||
setEmptyExtensionLoadable,
|
||||
useEmptyExtension,
|
||||
} from "./empty";
|
||||
import {
|
||||
MathJaxExtension,
|
||||
setMathJaxExtensionLoadable,
|
||||
useMathJaxExtension,
|
||||
} from "./mathjax";
|
||||
|
||||
// Extension authors: do imports like follows:
|
||||
// ```
|
||||
// import {
|
||||
// MyExtension,
|
||||
// setMyExtensionLoadable,
|
||||
// useMyExtension,
|
||||
// } from "./myExtension";
|
||||
// ```
|
||||
|
||||
// Extension authors: include `MyExtension` in `validExtensions`
|
||||
const validExtensions: readonly string[] = [EmptyExtension, MathJaxExtension];
|
||||
const extensionsUsed: string[] = [];
|
||||
|
||||
// The main invocation hook for use in the UI
|
||||
export const useExtensions = (
|
||||
api: ExcalidrawImperativeAPI | null,
|
||||
extensions?: string[],
|
||||
) => {
|
||||
selectExtensionsToLoad(extensions);
|
||||
useEmptyExtension(api);
|
||||
useMathJaxExtension(api);
|
||||
// Extension authors: add a line here like `useMyExtension();`
|
||||
};
|
||||
|
||||
// This MUST be called before the `useExtension`/`testExtension` calls.
|
||||
const selectExtensionsToLoad = (extensions?: string[]) => {
|
||||
const extensionList: string[] = [];
|
||||
if (extensions === undefined) {
|
||||
extensionList.push(...validExtensions);
|
||||
} else {
|
||||
extensions.forEach(
|
||||
(val) => validExtensions.includes(val) && extensionList.push(val),
|
||||
);
|
||||
}
|
||||
while (extensionsUsed.length > 0) {
|
||||
extensionsUsed.pop();
|
||||
}
|
||||
extensionsUsed.push(...extensionList);
|
||||
setLoadableExtensions();
|
||||
};
|
||||
|
||||
const setLoadableExtensions = () => {
|
||||
setEmptyExtensionLoadable(extensionsUsed.includes(EmptyExtension));
|
||||
setMathJaxExtensionLoadable(extensionsUsed.includes(MathJaxExtension));
|
||||
// Extension authors: add a line here like
|
||||
// `setMyExtensionLoadable(extensionsUsed.includes(MyExtension));`
|
||||
};
|
28
src/packages/extensions/webpack.dev-server.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
const path = require("path");
|
||||
const { merge } = require("webpack-merge");
|
||||
|
||||
const devConfig = require("./webpack.dev.config");
|
||||
|
||||
const devServerConfig = {
|
||||
entry: {
|
||||
bundle: "./example/index.tsx",
|
||||
},
|
||||
// Server Configuration options
|
||||
devServer: {
|
||||
port: 3001,
|
||||
host: "localhost",
|
||||
hot: true,
|
||||
compress: true,
|
||||
static: {
|
||||
directory: path.join(__dirname, "./example/public"),
|
||||
},
|
||||
client: {
|
||||
progress: true,
|
||||
logging: "info",
|
||||
overlay: true, //Shows a full-screen overlay in the browser when there are compiler errors or warnings.
|
||||
},
|
||||
open: ["./"],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = merge(devServerConfig, devConfig);
|
18
src/packages/extensions/webpack.dev.config.js
Normal file
@ -0,0 +1,18 @@
|
||||
global.__childdir = __dirname;
|
||||
const path = require("path");
|
||||
const { merge } = require("webpack-merge");
|
||||
const commonConfig = require("../common.webpack.dev.config");
|
||||
|
||||
const outputDir = process.env.EXAMPLE === "true" ? "example/public" : "dist";
|
||||
const config = {
|
||||
entry: {
|
||||
"excalidraw-extensions.development": "./index.ts",
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, outputDir),
|
||||
library: "ExcalidrawExtensionsLib",
|
||||
chunkFilename: "excalidraw-extensions-assets-dev/[name]-[contenthash].js",
|
||||
assetModuleFilename: "excalidraw-extensions-assets-dev/[name][ext]",
|
||||
},
|
||||
};
|
||||
module.exports = merge(commonConfig, config);
|
17
src/packages/extensions/webpack.prod.config.js
Normal file
@ -0,0 +1,17 @@
|
||||
global.__childdir = __dirname;
|
||||
const path = require("path");
|
||||
const { merge } = require("webpack-merge");
|
||||
const commonConfig = require("../common.webpack.prod.config");
|
||||
|
||||
const config = {
|
||||
entry: {
|
||||
"excalidraw-extensions.production.min": "./index.ts",
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, "dist"),
|
||||
library: "ExcalidrawExtensionsLib",
|
||||
chunkFilename: "excalidraw-extensions-assets/[name]-[contenthash].js",
|
||||
assetModuleFilename: "excalidraw-extensions-assets/[name][ext]",
|
||||
},
|
||||
};
|
||||
module.exports = merge(commonConfig, config);
|
3989
src/packages/extensions/yarn.lock
Normal file
@ -31,6 +31,7 @@ import { getCornerRadius, isPathALoop } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { AppState, BinaryFiles, Zoom } from "../types";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { getSubtypeMethods } from "../subtypes";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||
@ -206,6 +207,12 @@ const drawElementOnCanvas = (
|
||||
renderConfig: RenderConfig,
|
||||
) => {
|
||||
context.globalAlpha = element.opacity / 100;
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
if (map?.render) {
|
||||
map.render(element, context, renderConfig.renderCb);
|
||||
context.globalAlpha = 1;
|
||||
return;
|
||||
}
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "diamond":
|
||||
@ -1053,6 +1060,11 @@ export const renderElementToSvg = (
|
||||
root = anchorTag;
|
||||
}
|
||||
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
if (map?.renderSvg) {
|
||||
map.renderSvg(svgRoot, root, element, { offsetX, offsetY });
|
||||
return;
|
||||
}
|
||||
switch (element.type) {
|
||||
case "selection": {
|
||||
// Since this is used only during editing experience, which is canvas based,
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
getInitializedImageElements,
|
||||
updateImageCache,
|
||||
} from "../element/image";
|
||||
import { ensureSubtypesLoadedForElements } from "../subtypes";
|
||||
|
||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||
|
||||
@ -51,30 +52,48 @@ export const exportToCanvas = async (
|
||||
files,
|
||||
});
|
||||
|
||||
renderScene({
|
||||
elements,
|
||||
appState,
|
||||
scale,
|
||||
rc: rough.canvas(canvas),
|
||||
canvas,
|
||||
renderConfig: {
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: -minX + exportPadding,
|
||||
scrollY: -minY + exportPadding,
|
||||
zoom: defaultAppState.zoom,
|
||||
remotePointerViewportCoords: {},
|
||||
remoteSelectedElementIds: {},
|
||||
shouldCacheIgnoreZoom: false,
|
||||
remotePointerUsernames: {},
|
||||
remotePointerUserStates: {},
|
||||
theme: appState.exportWithDarkMode ? "dark" : "light",
|
||||
imageCache,
|
||||
renderScrollbars: false,
|
||||
renderSelection: false,
|
||||
renderGrid: false,
|
||||
isExporting: true,
|
||||
let refreshTimer = 0;
|
||||
|
||||
const renderConfig = {
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: -minX + exportPadding,
|
||||
scrollY: -minY + exportPadding,
|
||||
zoom: defaultAppState.zoom,
|
||||
remotePointerViewportCoords: {},
|
||||
remoteSelectedElementIds: {},
|
||||
shouldCacheIgnoreZoom: false,
|
||||
remotePointerUsernames: {},
|
||||
remotePointerUserStates: {},
|
||||
theme: appState.exportWithDarkMode ? "dark" : "light",
|
||||
imageCache,
|
||||
renderScrollbars: false,
|
||||
renderSelection: false,
|
||||
renderGrid: false,
|
||||
isExporting: true,
|
||||
renderCb: () => {
|
||||
if (refreshTimer !== 0) {
|
||||
window.clearTimeout(refreshTimer);
|
||||
}
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
renderConfig.renderCb = () => {};
|
||||
window.clearTimeout(refreshTimer);
|
||||
// Here instead of setState({}), call renderScene() again
|
||||
render();
|
||||
}, 50);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
renderScene({
|
||||
elements,
|
||||
appState,
|
||||
scale,
|
||||
rc: rough.canvas(canvas),
|
||||
canvas,
|
||||
renderConfig,
|
||||
});
|
||||
};
|
||||
render();
|
||||
|
||||
return canvas;
|
||||
};
|
||||
@ -163,10 +182,12 @@ export const exportToSvg = async (
|
||||
}
|
||||
|
||||
const rsvg = rough.svg(svgRoot);
|
||||
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
await ensureSubtypesLoadedForElements(elements, () => {
|
||||
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
||||
offsetX: -minX + exportPadding,
|
||||
offsetY: -minY + exportPadding,
|
||||
exportWithDarkMode: appState.exportWithDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
return svgRoot;
|
||||
|
@ -24,6 +24,7 @@ export type RenderConfig = {
|
||||
renderScrollbars?: boolean;
|
||||
renderSelection?: boolean;
|
||||
renderGrid?: boolean;
|
||||
renderCb?: () => void;
|
||||
/** when exporting the behavior is slightly different (e.g. we can't use
|
||||
CSS filters), and we disable render optimizations for best output */
|
||||
isExporting: boolean;
|
||||
|
417
src/subtypes.ts
Normal file
@ -0,0 +1,417 @@
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
} from "./element/types";
|
||||
import { getNonDeletedElements } from "./element";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { AppState } from "./types";
|
||||
import { registerAuxLangData } from "./i18n";
|
||||
|
||||
import { Action, ActionName, DisableFn, EnableFn } from "./actions/types";
|
||||
import {
|
||||
CustomShortcutName,
|
||||
registerCustomShortcuts,
|
||||
} from "./actions/shortcuts";
|
||||
import { register } from "./actions/register";
|
||||
import { registerDisableFn, registerEnableFn } from "./actions/guards";
|
||||
import { hasBoundTextElement } from "./element/typeChecks";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
|
||||
// Use "let" instead of "const" so we can dynamically add subtypes
|
||||
let subtypeNames: readonly Subtype[] = [];
|
||||
let parentTypeMap: readonly {
|
||||
subtype: Subtype;
|
||||
parentType: ExcalidrawElement["type"];
|
||||
}[] = [];
|
||||
let subtypeActionMap: readonly {
|
||||
subtype: Subtype;
|
||||
actions: readonly SubtypeActionName[];
|
||||
}[] = [];
|
||||
let disabledActionMap: readonly {
|
||||
subtype: Subtype;
|
||||
actions: readonly DisabledActionName[];
|
||||
}[] = [];
|
||||
let alwaysEnabledMap: readonly {
|
||||
subtype: Subtype;
|
||||
actions: readonly SubtypeActionName[];
|
||||
}[] = [];
|
||||
|
||||
export type SubtypeRecord = Readonly<{
|
||||
subtype: Subtype;
|
||||
parents: readonly ExcalidrawElement["type"][];
|
||||
actionNames?: readonly SubtypeActionName[];
|
||||
disabledNames?: readonly DisabledActionName[];
|
||||
shortcutMap?: Record<CustomShortcutName, string[]>;
|
||||
alwaysEnabledNames?: readonly SubtypeActionName[];
|
||||
}>;
|
||||
|
||||
// Subtype Names
|
||||
export type Subtype = string;
|
||||
export const getSubtypeNames = (): readonly Subtype[] => {
|
||||
return subtypeNames;
|
||||
};
|
||||
export const isValidSubtype = (s: any, t: any): s is Subtype =>
|
||||
parentTypeMap.find(
|
||||
(val) => val.subtype === (s as string) && val.parentType === (t as string),
|
||||
) !== undefined;
|
||||
const isSubtypeName = (s: any): s is Subtype => subtypeNames.includes(s);
|
||||
|
||||
// Subtype Actions
|
||||
|
||||
// Used for context menus in the shape chooser
|
||||
export const hasAlwaysEnabledActions = (s: any): boolean => {
|
||||
if (!isSubtypeName(s)) {
|
||||
return false;
|
||||
}
|
||||
return alwaysEnabledMap.some((value) => value.subtype === s);
|
||||
};
|
||||
|
||||
type SubtypeActionName = string;
|
||||
|
||||
const isSubtypeActionName = (s: any): s is SubtypeActionName =>
|
||||
subtypeActionMap.some((val) => val.actions.includes(s));
|
||||
|
||||
const addSubtypeAction = (action: Action) => {
|
||||
if (isSubtypeActionName(action.name) || isSubtypeName(action.name)) {
|
||||
register(action);
|
||||
}
|
||||
};
|
||||
|
||||
// Standard actions disabled by subtypes
|
||||
type DisabledActionName = ActionName;
|
||||
|
||||
const isDisabledActionName = (s: any): s is DisabledActionName =>
|
||||
disabledActionMap.some((val) => val.actions.includes(s));
|
||||
|
||||
// Is the `actionName` one of the subtype actions for `subtype`
|
||||
// (if `isAdded` is true) or one of the standard actions disabled
|
||||
// by `subtype` (if `isAdded` is false)?
|
||||
const isForSubtype = (
|
||||
subtype: ExcalidrawElement["subtype"],
|
||||
actionName: ActionName | SubtypeActionName,
|
||||
isAdded: boolean,
|
||||
) => {
|
||||
const actions = isAdded ? subtypeActionMap : disabledActionMap;
|
||||
const map = actions.find((value) => value.subtype === subtype);
|
||||
if (map) {
|
||||
return map.actions.includes(actionName);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isActionDisabled: DisableFn = function (elements, appState, actionName) {
|
||||
return !isActionEnabled(elements, appState, actionName);
|
||||
};
|
||||
|
||||
const isActionEnabled: EnableFn = function (elements, appState, actionName) {
|
||||
// We always enable subtype actions. Also let through standard actions
|
||||
// which no subtypes might have disabled.
|
||||
if (
|
||||
isSubtypeName(actionName) ||
|
||||
(!isSubtypeActionName(actionName) && !isDisabledActionName(actionName))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const chosen = appState.editingElement
|
||||
? [appState.editingElement, ...selectedElements]
|
||||
: selectedElements;
|
||||
// Now handle actions added by subtypes
|
||||
if (isSubtypeActionName(actionName)) {
|
||||
// Has any ExcalidrawElement enabled this actionName through having
|
||||
// its subtype?
|
||||
return (
|
||||
chosen.some((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return isForSubtype(e.subtype, actionName, true);
|
||||
}) ||
|
||||
// Or has any active subtype enabled this actionName?
|
||||
(appState.activeSubtypes !== undefined &&
|
||||
appState.activeSubtypes?.some((subtype) => {
|
||||
if (!isValidSubtype(subtype, appState.activeTool.type)) {
|
||||
return false;
|
||||
}
|
||||
return isForSubtype(subtype, actionName, true);
|
||||
})) ||
|
||||
alwaysEnabledMap.some((value) => {
|
||||
return value.actions.includes(actionName);
|
||||
})
|
||||
);
|
||||
}
|
||||
// Now handle standard actions disabled by subtypes
|
||||
if (isDisabledActionName(actionName)) {
|
||||
return (
|
||||
// Has every ExcalidrawElement not disabled this actionName?
|
||||
(chosen.every((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return !isForSubtype(e.subtype, actionName, false);
|
||||
}) &&
|
||||
// And has every active subtype not disabled this actionName?
|
||||
(appState.activeSubtypes === undefined ||
|
||||
appState.activeSubtypes?.every((subtype) => {
|
||||
if (!isValidSubtype(subtype, appState.activeTool.type)) {
|
||||
return true;
|
||||
}
|
||||
return !isForSubtype(subtype, actionName, false);
|
||||
}))) ||
|
||||
// Or is there an ExcalidrawElement without a subtype which would
|
||||
// disable this action if it had a subtype?
|
||||
chosen.some((el) => {
|
||||
const e = hasBoundTextElement(el) ? getBoundTextElement(el)! : el;
|
||||
return parentTypeMap.some(
|
||||
(value) =>
|
||||
value.parentType === e.type &&
|
||||
e.subtype === undefined &&
|
||||
disabledActionMap
|
||||
.find((val) => val.subtype === value.subtype)!
|
||||
.actions.includes(actionName),
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
// Shouldn't happen
|
||||
return true;
|
||||
};
|
||||
|
||||
// Are any of the parent types of `subtype` shared by any subtype
|
||||
// in the array?
|
||||
export const subtypeCollides = (subtype: Subtype, subtypeArray: Subtype[]) => {
|
||||
const subtypeParents = parentTypeMap
|
||||
.filter((value) => value.subtype === subtype)
|
||||
.map((value) => value.parentType);
|
||||
const subtypeArrayParents = subtypeArray.flatMap((s) =>
|
||||
parentTypeMap
|
||||
.filter((value) => value.subtype === s)
|
||||
.map((value) => value.parentType),
|
||||
);
|
||||
return subtypeParents.some((t) => subtypeArrayParents.includes(t));
|
||||
};
|
||||
|
||||
// Subtype Methods
|
||||
export type SubtypeMethods = {
|
||||
clean: (
|
||||
updates: Omit<
|
||||
Partial<ExcalidrawElement>,
|
||||
"id" | "version" | "versionNonce"
|
||||
>,
|
||||
) => Omit<Partial<ExcalidrawElement>, "id" | "version" | "versionNonce">;
|
||||
ensureLoaded: (callback?: () => void) => Promise<void>;
|
||||
measureText: (
|
||||
element: Pick<
|
||||
ExcalidrawTextElement,
|
||||
"subtype" | "customData" | "fontSize" | "fontFamily" | "text"
|
||||
>,
|
||||
next?: {
|
||||
fontSize?: number;
|
||||
text?: string;
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
},
|
||||
maxWidth?: number | null,
|
||||
) => { width: number; height: number; baseline: number };
|
||||
render: (
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
context: CanvasRenderingContext2D,
|
||||
renderCb?: () => void,
|
||||
) => void;
|
||||
renderSvg: (
|
||||
svgRoot: SVGElement,
|
||||
root: SVGElement,
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
opt?: { offsetX?: number; offsetY?: number },
|
||||
) => void;
|
||||
wrapText: (
|
||||
element: Pick<
|
||||
ExcalidrawTextElement,
|
||||
"subtype" | "customData" | "fontSize" | "fontFamily" | "originalText"
|
||||
>,
|
||||
containerWidth: number,
|
||||
next?: {
|
||||
fontSize?: number;
|
||||
text?: string;
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
},
|
||||
) => string;
|
||||
};
|
||||
|
||||
type MethodMap = { subtype: Subtype; methods: Partial<SubtypeMethods> };
|
||||
const methodMaps = [] as Array<MethodMap>;
|
||||
|
||||
// Use `getSubtypeMethods` to call subtype-specialized methods, like `render`.
|
||||
export const getSubtypeMethods = (subtype: Subtype | undefined) => {
|
||||
const map = methodMaps.find((method) => method.subtype === subtype);
|
||||
return map?.methods;
|
||||
};
|
||||
|
||||
export const addSubtypeMethods = (
|
||||
subtype: Subtype,
|
||||
methods: Partial<SubtypeMethods>,
|
||||
) => {
|
||||
if (!methodMaps.find((method) => method.subtype === subtype)) {
|
||||
methodMaps.push({ subtype, methods });
|
||||
}
|
||||
};
|
||||
|
||||
// For a given `ExcalidrawElement` type, return the active subtype
|
||||
// and associated customData (if any) from the AppState. Assume
|
||||
// only one subtype is active for a given `ExcalidrawElement` type
|
||||
// at any given time.
|
||||
export const selectSubtype = (
|
||||
appState: {
|
||||
activeSubtypes?: AppState["activeSubtypes"];
|
||||
customData?: AppState["customData"];
|
||||
},
|
||||
type: ExcalidrawElement["type"],
|
||||
): {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
customData?: ExcalidrawElement["customData"];
|
||||
} => {
|
||||
if (appState.activeSubtypes === undefined) {
|
||||
return {};
|
||||
}
|
||||
const subtype = appState.activeSubtypes.find((subtype) =>
|
||||
isValidSubtype(subtype, type),
|
||||
);
|
||||
if (subtype === undefined) {
|
||||
return {};
|
||||
}
|
||||
if (appState.customData === undefined || !(subtype in appState.customData)) {
|
||||
return { subtype };
|
||||
}
|
||||
const customData = appState.customData[subtype];
|
||||
return { subtype, customData };
|
||||
};
|
||||
|
||||
// Callback to re-render subtyped `ExcalidrawElement`s after completing
|
||||
// async loading of the subtype.
|
||||
export type SubtypeLoadedCb = (
|
||||
hasSubtype: (element: ExcalidrawElement) => boolean,
|
||||
) => void;
|
||||
|
||||
// Functions to prepare subtypes for use
|
||||
export type SubtypePrepFn = (
|
||||
addSubtypeAction: (action: Action) => void,
|
||||
addLangData: (
|
||||
fallbackLangData: Object,
|
||||
setLanguageAux: (langCode: string) => Promise<Object | undefined>,
|
||||
) => void,
|
||||
onSubtypeLoaded?: SubtypeLoadedCb,
|
||||
) => {
|
||||
actions: Action[];
|
||||
methods: Partial<SubtypeMethods>;
|
||||
};
|
||||
|
||||
// This is the main method to set up the subtype. The optional
|
||||
// `onSubtypeLoaded` callback may be used to re-render subtyped
|
||||
// `ExcalidrawElement`s after the subtype has finished async loading.
|
||||
// See the MathJax extension in `@excalidraw/extensions` for example.
|
||||
export const prepareSubtype = (
|
||||
record: SubtypeRecord,
|
||||
subtypePrepFn: SubtypePrepFn,
|
||||
onSubtypeLoaded?: SubtypeLoadedCb,
|
||||
): { actions: Action[] | null; methods: Partial<SubtypeMethods> } => {
|
||||
const map = getSubtypeMethods(record.subtype);
|
||||
if (map) {
|
||||
return { actions: null, methods: map };
|
||||
}
|
||||
|
||||
// Check for undefined/null subtypes and parentTypes
|
||||
if (
|
||||
record.subtype === undefined ||
|
||||
record.subtype === "" ||
|
||||
record.parents === undefined ||
|
||||
record.parents.length === 0
|
||||
) {
|
||||
return { actions: null, methods: {} };
|
||||
}
|
||||
|
||||
// Register the types
|
||||
const subtype = record.subtype;
|
||||
subtypeNames = [...subtypeNames, subtype];
|
||||
record.parents.forEach((parentType) => {
|
||||
parentTypeMap = [...parentTypeMap, { subtype, parentType }];
|
||||
});
|
||||
if (record.actionNames) {
|
||||
subtypeActionMap = [
|
||||
...subtypeActionMap,
|
||||
{ subtype, actions: record.actionNames },
|
||||
];
|
||||
}
|
||||
if (record.disabledNames) {
|
||||
disabledActionMap = [
|
||||
...disabledActionMap,
|
||||
{ subtype, actions: record.disabledNames },
|
||||
];
|
||||
}
|
||||
if (record.alwaysEnabledNames) {
|
||||
alwaysEnabledMap = [
|
||||
...alwaysEnabledMap,
|
||||
{ subtype, actions: record.alwaysEnabledNames },
|
||||
];
|
||||
}
|
||||
if (record.shortcutMap) {
|
||||
registerCustomShortcuts(record.shortcutMap);
|
||||
}
|
||||
|
||||
// Prepare the subtype
|
||||
const { actions, methods } = subtypePrepFn(
|
||||
addSubtypeAction,
|
||||
registerAuxLangData,
|
||||
onSubtypeLoaded,
|
||||
);
|
||||
|
||||
record.disabledNames?.forEach((name) => {
|
||||
registerDisableFn(name, isActionDisabled);
|
||||
});
|
||||
record.actionNames?.forEach((name) => {
|
||||
registerEnableFn(name, isActionEnabled);
|
||||
});
|
||||
registerEnableFn(record.subtype, isActionEnabled);
|
||||
// Register the subtype's methods
|
||||
addSubtypeMethods(record.subtype, methods);
|
||||
return { actions, methods };
|
||||
};
|
||||
|
||||
// Ensure all subtypes are loaded before continuing, eg to
|
||||
// render SVG previews of new charts. Chart-relevant subtypes
|
||||
// include math equations in titles or non hand-drawn line styles.
|
||||
export const ensureSubtypesLoadedForElements = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
callback?: () => void,
|
||||
) => {
|
||||
// Only ensure the loading of subtypes which are actually needed.
|
||||
// We don't want to be held up by eg downloading the MathJax SVG fonts
|
||||
// if we don't actually need them yet.
|
||||
const subtypesUsed = [] as Subtype[];
|
||||
elements.forEach((el) => {
|
||||
if (
|
||||
"subtype" in el &&
|
||||
isValidSubtype(el.subtype, el.type) &&
|
||||
!subtypesUsed.includes(el.subtype)
|
||||
) {
|
||||
subtypesUsed.push(el.subtype);
|
||||
}
|
||||
});
|
||||
await ensureSubtypesLoaded(subtypesUsed, callback);
|
||||
};
|
||||
|
||||
export const ensureSubtypesLoaded = async (
|
||||
subtypes: Subtype[],
|
||||
callback?: () => void,
|
||||
) => {
|
||||
// Use a for loop so we can do `await map.ensureLoaded()`
|
||||
for (let i = 0; i < subtypes.length; i++) {
|
||||
const subtype = subtypes[i];
|
||||
// Should be defined if prepareSubtype() has run
|
||||
const map = getSubtypeMethods(subtype);
|
||||
if (map?.ensureLoaded) {
|
||||
await map.ensureLoaded();
|
||||
}
|
||||
}
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
};
|
@ -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
@ -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
@ -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
@ -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();
|
||||
});
|
||||
});
|
16
src/types.ts
@ -15,6 +15,7 @@ import {
|
||||
Theme,
|
||||
StrokeRoundness,
|
||||
} from "./element/types";
|
||||
import { Action } from "./actions/types";
|
||||
import { SHAPES } from "./shapes";
|
||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||
import { LinearElementEditor } from "./element/linearElementEditor";
|
||||
@ -28,6 +29,12 @@ import { ClipboardData } from "./clipboard";
|
||||
import { isOverScrollBars } from "./scene";
|
||||
import { MaybeTransformHandleType } from "./element/transformHandles";
|
||||
import Library from "./data/library";
|
||||
import {
|
||||
SubtypeMethods,
|
||||
Subtype,
|
||||
SubtypePrepFn,
|
||||
SubtypeRecord,
|
||||
} from "./subtypes";
|
||||
import type { FileSystemHandle } from "./data/filesystem";
|
||||
import type { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "./constants";
|
||||
import { ContextMenuItems } from "./components/ContextMenu";
|
||||
@ -112,6 +119,10 @@ export type AppState = {
|
||||
// (e.g. text element when typing into the input)
|
||||
editingElement: NonDeletedExcalidrawElement | null;
|
||||
editingLinearElement: LinearElementEditor | null;
|
||||
activeSubtypes?: Subtype[];
|
||||
customData?: {
|
||||
[subtype: Subtype]: ExcalidrawElement["customData"];
|
||||
};
|
||||
activeTool:
|
||||
| {
|
||||
type: typeof SHAPES[number]["value"] | "eraser";
|
||||
@ -497,6 +508,11 @@ export type ExcalidrawImperativeAPI = {
|
||||
getSceneElements: InstanceType<typeof App>["getSceneElements"];
|
||||
getAppState: () => InstanceType<typeof App>["state"];
|
||||
getFiles: () => InstanceType<typeof App>["files"];
|
||||
actionManager: InstanceType<typeof App>["actionManager"];
|
||||
addSubtype: (
|
||||
record: SubtypeRecord,
|
||||
subtypePrepFn: SubtypePrepFn,
|
||||
) => { actions: Action[] | null; methods: Partial<SubtypeMethods> };
|
||||
refresh: InstanceType<typeof App>["refresh"];
|
||||
setToast: InstanceType<typeof App>["setToast"];
|
||||
addFiles: (data: BinaryFileData[]) => void;
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"include": ["src/packages/excalidraw", "src/global.d.ts", "src/css.d.ts"],
|
||||
"include": ["src/packages/excalidraw", "src/packages/extensions", "src/global.d.ts", "src/css.d.ts"],
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"declaration": true,
|
||||
|
53
yarn.lock
@ -1472,6 +1472,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/eslint-config/-/eslint-config-1.0.0.tgz#1cc527a88cfe20fd730496c1b631c3aecf9c825e"
|
||||
integrity sha512-5SQFsQXO4vzny7OFSJr/DDPr1Mzeh6w0uwmZ0Fk2qjBcS5nhPtnx5KOTRPmpc6Hf5Ao57WK/MKI4lhCzw66V/A==
|
||||
|
||||
"@excalidraw/extensions@link:src/packages/extensions":
|
||||
version "0.0.0"
|
||||
uid ""
|
||||
|
||||
"@excalidraw/prettier-config@1.0.2":
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/prettier-config/-/prettier-config-1.0.2.tgz#b7c061c99cee2f78b9ca470ea1fbd602683bba65"
|
||||
@ -4019,6 +4023,11 @@ combined-stream@^1.0.8:
|
||||
dependencies:
|
||||
delayed-stream "~1.0.0"
|
||||
|
||||
commander@9.2.0:
|
||||
version "9.2.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-9.2.0.tgz#6e21014b2ed90d8b7c9647230d8b7a94a4a419a9"
|
||||
integrity sha512-e2i4wANQiSXgnrBlIatyHtP1odfUp0BbV5Y5nEGbxtIrStkEOAAzCUirvLBNXHLr7kwLvJl6V+4V3XV9x7Wd9w==
|
||||
|
||||
commander@^2.20.0:
|
||||
version "2.20.3"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
|
||||
@ -5256,6 +5265,11 @@ eslint@^8.3.0:
|
||||
strip-json-comments "^3.1.0"
|
||||
text-table "^0.2.0"
|
||||
|
||||
esm@^3.2.25:
|
||||
version "3.2.25"
|
||||
resolved "https://registry.yarnpkg.com/esm/-/esm-3.2.25.tgz#342c18c29d56157688ba5ce31f8431fbb795cc10"
|
||||
integrity sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==
|
||||
|
||||
espree@^7.3.0, espree@^7.3.1:
|
||||
version "7.3.1"
|
||||
resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6"
|
||||
@ -7489,6 +7503,16 @@ makeerror@1.0.12:
|
||||
dependencies:
|
||||
tmpl "1.0.5"
|
||||
|
||||
mathjax-full@3.2.2:
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/mathjax-full/-/mathjax-full-3.2.2.tgz#43f02e55219db393030985d2b6537ceae82f1fa7"
|
||||
integrity sha512-+LfG9Fik+OuI8SLwsiR02IVdjcnRCy5MufYLi0C3TdMT56L/pjB0alMVGgoWJF8pN9Rc7FESycZB9BMNWIid5w==
|
||||
dependencies:
|
||||
esm "^3.2.25"
|
||||
mhchemparser "^4.1.0"
|
||||
mj-context-menu "^0.6.1"
|
||||
speech-rule-engine "^4.0.6"
|
||||
|
||||
mdn-data@2.0.14:
|
||||
version "2.0.14"
|
||||
resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50"
|
||||
@ -7531,6 +7555,11 @@ methods@~1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
|
||||
integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
|
||||
|
||||
mhchemparser@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/mhchemparser/-/mhchemparser-4.1.0.tgz#db204c394c46c0070e115270a7e45c15b5f0c2f5"
|
||||
integrity sha512-rFj6nGMLJQQ0WcDw3j4LY/kWCq1EftcsarQWnDg38U47XMR36Tlda19WsN4spHr0Qc9Wn4oj6YtvXuwVnOKC/g==
|
||||
|
||||
micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5:
|
||||
version "4.0.5"
|
||||
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
|
||||
@ -7597,6 +7626,11 @@ minimist@^1.2.0, minimist@^1.2.6:
|
||||
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18"
|
||||
integrity sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==
|
||||
|
||||
mj-context-menu@^0.6.1:
|
||||
version "0.6.1"
|
||||
resolved "https://registry.yarnpkg.com/mj-context-menu/-/mj-context-menu-0.6.1.tgz#a043c5282bf7e1cf3821de07b13525ca6f85aa69"
|
||||
integrity sha512-7NO5s6n10TIV96d4g2uDpG7ZDpIhMh0QNfGdJw/W47JswFcosz457wqz/b5sAKvl12sxINGFCn80NZHKwxQEXA==
|
||||
|
||||
mkdirp@^0.5.6, mkdirp@~0.5.1:
|
||||
version "0.5.6"
|
||||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
|
||||
@ -9770,6 +9804,15 @@ spdy@^4.0.2:
|
||||
select-hose "^2.0.0"
|
||||
spdy-transport "^3.0.0"
|
||||
|
||||
speech-rule-engine@^4.0.6:
|
||||
version "4.0.6"
|
||||
resolved "https://registry.yarnpkg.com/speech-rule-engine/-/speech-rule-engine-4.0.6.tgz#325dfa4528f25f6aa52d44cf7ee158c7fc0c395d"
|
||||
integrity sha512-Hqa4ywf7d3lX2YsnnE8BeEdqFyaTwPSyyVhVGWZlQw4XVh0NCijyVsMZD3I9HsG5JBuDXyRaMVVNZcGJlKbZxA==
|
||||
dependencies:
|
||||
commander "9.2.0"
|
||||
wicked-good-xpath "1.3.0"
|
||||
xmldom-sre "0.1.31"
|
||||
|
||||
sprintf-js@~1.0.2:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
|
||||
@ -10733,6 +10776,11 @@ which@^2.0.1:
|
||||
dependencies:
|
||||
isexe "^2.0.0"
|
||||
|
||||
wicked-good-xpath@1.3.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz#81b0e95e8650e49c94b22298fff8686b5553cf6c"
|
||||
integrity sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==
|
||||
|
||||
word-wrap@^1.2.3, word-wrap@~1.2.3:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
|
||||
@ -10967,6 +11015,11 @@ xmlchars@^2.2.0:
|
||||
resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb"
|
||||
integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==
|
||||
|
||||
xmldom-sre@0.1.31:
|
||||
version "0.1.31"
|
||||
resolved "https://registry.yarnpkg.com/xmldom-sre/-/xmldom-sre-0.1.31.tgz#10860d5bab2c603144597d04bf2c4980e98067f4"
|
||||
integrity sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==
|
||||
|
||||
xmlhttprequest-ssl@~1.5.4:
|
||||
version "1.5.5"
|
||||
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
|
||||
|