mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com
This commit is contained in:
parent
c8370b394c
commit
86f5c2ebcf
84 changed files with 8331 additions and 289 deletions
|
@ -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);
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue