feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com

This commit is contained in:
Daniel J. Geiger 2022-12-27 15:11:52 -06:00
parent c8370b394c
commit 86f5c2ebcf
84 changed files with 8331 additions and 289 deletions

View file

@ -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) ||

View file

@ -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);

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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)}

View file

@ -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>
);
};

View 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;
};

View file

@ -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}
>

View file

@ -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";