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
|
@ -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
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";
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue