From 86f5c2ebcf98c4e83d25c7a3ed1f5972220f7001 Mon Sep 17 00:00:00 2001 From: "Daniel J. Geiger" <1852529+DanielJGeiger@users.noreply.github.com> Date: Tue, 27 Dec 2022 15:11:52 -0600 Subject: [PATCH] feat: Support LaTeX and AsciiMath via MathJax on stem.excalidraw.com --- package.json | 1 + public/index.html | 1 + src/actions/actionBoundText.tsx | 9 +- src/actions/actionProperties.tsx | 4 +- src/actions/guards.ts | 25 + src/actions/manager.tsx | 76 +- src/actions/register.ts | 10 +- src/actions/shortcuts.ts | 19 +- src/actions/types.ts | 191 +- src/appState.ts | 2 + src/charts.test.ts | 26 + src/charts.ts | 53 +- src/clipboard.ts | 5 + src/components/Actions.tsx | 10 + src/components/App.tsx | 174 +- src/components/ButtonSelect.tsx | 12 +- src/components/ContextMenu.tsx | 5 +- src/components/LayerUI.tsx | 4 + src/components/MobileMenu.tsx | 4 +- src/components/PasteChartDialog.tsx | 112 +- src/components/SubtypeButton.tsx | 102 + src/components/ToolButton.tsx | 2 + src/components/icons.tsx | 2 +- src/data/restore.ts | 13 +- src/element/mutateElement.ts | 28 +- src/element/newElement.ts | 68 +- src/element/resizeElements.ts | 8 +- src/element/textElement.ts | 53 +- src/element/textWysiwyg.test.tsx | 118 +- src/element/textWysiwyg.tsx | 55 +- src/element/types.ts | 2 + src/excalidraw-app/data/LocalData.ts | 25 +- src/excalidraw-app/index.tsx | 7 +- src/global.d.ts | 1 + src/i18n.ts | 34 + src/keys.ts | 2 + src/locales/en.json | 1 + src/packages/excalidraw/CHANGELOG.md | 6 + src/packages/excalidraw/example/App.tsx | 24 +- src/packages/excalidraw/example/index.tsx | 5 +- .../example/public/{ => images}/doremon.png | Bin .../example/public/{ => images}/excalibot.png | Bin .../example/public/{ => images}/pika.jpeg | Bin .../example/public/{ => images}/rocket.jpeg | Bin .../example/sidebar/ExampleSidebar.tsx | 4 +- src/packages/excalidraw/publicPath.js | 6 + src/packages/extensions/.gitignore | 2 + src/packages/extensions/CHANGELOG.md | 24 + src/packages/extensions/README.md | 45 + src/packages/extensions/env.js | 18 + src/packages/extensions/example/index.tsx | 23 + .../example/public/excalidraw-assets-dev | 1 + .../example/public/excalidraw.development.js | 1 + src/packages/extensions/example/public/images | 1 + .../extensions/example/public/index.html | 32 + src/packages/extensions/index.ts | 3 + src/packages/extensions/package.json | 86 + src/packages/extensions/publicPath.js | 14 + src/packages/extensions/ts/empty/index.ts | 26 + src/packages/extensions/ts/global.d.ts | 4 + src/packages/extensions/ts/mathjax/icon.tsx | 13 + .../extensions/ts/mathjax/implementation.tsx | 1507 +++++++ src/packages/extensions/ts/mathjax/index.ts | 32 + .../extensions/ts/mathjax/locales/en.json | 15 + src/packages/extensions/ts/mathjax/types.ts | 17 + src/packages/extensions/ts/node-main.ts | 59 + .../extensions/webpack.dev-server.config.js | 28 + src/packages/extensions/webpack.dev.config.js | 18 + .../extensions/webpack.prod.config.js | 17 + src/packages/extensions/yarn.lock | 3989 +++++++++++++++++ src/renderer/renderElement.ts | 12 + src/scene/export.ts | 75 +- src/scene/types.ts | 1 + src/subtypes.ts | 417 ++ .../linearElementEditor.test.tsx.snap | 3 +- src/tests/customActions.test.tsx | 85 + src/tests/helpers/api.ts | 34 +- src/tests/helpers/locales/en.json | 7 + src/tests/scene/export.test.ts | 1 + src/tests/scroll.test.tsx | 32 + src/tests/subtypes.test.tsx | 598 +++ src/types.ts | 16 + tsconfig-types.json | 2 +- yarn.lock | 53 + 84 files changed, 8331 insertions(+), 289 deletions(-) create mode 100644 src/actions/guards.ts create mode 100644 src/components/SubtypeButton.tsx rename src/packages/excalidraw/example/public/{ => images}/doremon.png (100%) rename src/packages/excalidraw/example/public/{ => images}/excalibot.png (100%) rename src/packages/excalidraw/example/public/{ => images}/pika.jpeg (100%) rename src/packages/excalidraw/example/public/{ => images}/rocket.jpeg (100%) create mode 100644 src/packages/extensions/.gitignore create mode 100644 src/packages/extensions/CHANGELOG.md create mode 100644 src/packages/extensions/README.md create mode 100644 src/packages/extensions/env.js create mode 100644 src/packages/extensions/example/index.tsx create mode 120000 src/packages/extensions/example/public/excalidraw-assets-dev create mode 120000 src/packages/extensions/example/public/excalidraw.development.js create mode 120000 src/packages/extensions/example/public/images create mode 100644 src/packages/extensions/example/public/index.html create mode 100644 src/packages/extensions/index.ts create mode 100644 src/packages/extensions/package.json create mode 100644 src/packages/extensions/publicPath.js create mode 100644 src/packages/extensions/ts/empty/index.ts create mode 100644 src/packages/extensions/ts/global.d.ts create mode 100644 src/packages/extensions/ts/mathjax/icon.tsx create mode 100644 src/packages/extensions/ts/mathjax/implementation.tsx create mode 100644 src/packages/extensions/ts/mathjax/index.ts create mode 100644 src/packages/extensions/ts/mathjax/locales/en.json create mode 100644 src/packages/extensions/ts/mathjax/types.ts create mode 100644 src/packages/extensions/ts/node-main.ts create mode 100644 src/packages/extensions/webpack.dev-server.config.js create mode 100644 src/packages/extensions/webpack.dev.config.js create mode 100644 src/packages/extensions/webpack.prod.config.js create mode 100644 src/packages/extensions/yarn.lock create mode 100644 src/subtypes.ts create mode 100644 src/tests/customActions.test.tsx create mode 100644 src/tests/helpers/locales/en.json create mode 100644 src/tests/subtypes.test.tsx diff --git a/package.json b/package.json index 2923ddd54..259ea2478 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/index.html b/public/index.html index e065e6700..a1531a73c 100644 --- a/public/index.html +++ b/public/index.html @@ -143,6 +143,7 @@ <% } %> diff --git a/src/actions/actionBoundText.tsx b/src/actions/actionBoundText.tsx index 812e10b3c..e2ff706ea 100644 --- a/src/actions/actionBoundText.tsx +++ b/src/actions/actionBoundText.tsx @@ -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, diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index 4e6f0d587..e3db9157f 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -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 ( +export const getFormValue = function ( elements: readonly ExcalidrawElement[], appState: AppState, getAttribute: (element: ExcalidrawElement) => T, diff --git a/src/actions/guards.ts b/src/actions/guards.ts new file mode 100644 index 000000000..a5a00ab69 --- /dev/null +++ b/src/actions/guards.ts @@ -0,0 +1,25 @@ +import { Action, ActionName, DisableFn, EnableFn } from "./types"; + +const disablers = {} as Record; +const enablers = {} as Record; + +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); + } +}; diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index 6c87aa037..9ffc5223c 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -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; + actions = {} as Record; + + disablers = {} as Record; + enablers = {} as Record; updater: (actionResult: ActionResult | Promise) => 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 ( customActions; +export const getActions = () => actions; export const register = (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"]; diff --git a/src/actions/shortcuts.ts b/src/actions/shortcuts.ts index 41686e521..0029ee120 100644 --- a/src/actions/shortcuts.ts +++ b/src/actions/shortcuts.ts @@ -80,8 +80,23 @@ const shortcutMap: Record = { toggleLock: [getShortcutKey("CtrlOrCmd+Shift+L")], }; -export const getShortcutFromShortcutName = (name: ShortcutName) => { - const shortcuts = shortcutMap[name]; +export type CustomShortcutName = string; + +let customShortcutMap: Record = {}; + +export const registerCustomShortcuts = ( + shortcuts: Record, +) => { + 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] : ""; }; diff --git a/src/actions/types.ts b/src/actions/types.ts index 93e29cfc1..30eba81a4 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -31,88 +31,110 @@ type ActionFn = ( app: AppClassProperties, ) => ActionResult | Promise; +// 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, + ) => boolean; contextItemLabel?: | string | (( diff --git a/src/appState.ts b/src/appState.ts index d1cbe92f0..f31b3c1e4 100644 --- a/src/appState.ts +++ b/src/appState.ts @@ -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 }, diff --git a/src/charts.test.ts b/src/charts.test.ts index 5c2cce708..e859ecbc3 100644 --- a/src/charts.test.ts +++ b/src/charts.test.ts @@ -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]); + }); + }); }); diff --git a/src/charts.ts b/src/charts.ts index e8980db6c..bcb9dbe9a 100644 --- a/src/charts.ts +++ b/src/charts.ts @@ -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"), }); }); diff --git a/src/clipboard.ts b/src/clipboard.ts index bf90a4b17..cfb70b735 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -130,6 +130,7 @@ export const getSystemClipboard = async ( export const parseClipboard = async ( event: ClipboardEvent | null, isPlainPaste = false, + appState?: AppState, ): Promise => { 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; } diff --git a/src/components/Actions.tsx b/src/components/Actions.tsx index fe017f776..d895c295c 100644 --- a/src/components/Actions.tsx +++ b/src/components/Actions.tsx @@ -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 && (
{renderAction("changeBackgroundColor")}
)} + {getCustomActions().map((action) => { + if ( + action.panelComponentPredicate && + action.panelComponentPredicate(targetElements, appState) + ) { + return renderAction(action.name); + } + return null; + })} {showFillIcons && renderAction("changeFillStyle")} {(hasStrokeWidth(appState.activeTool.type) || diff --git a/src/components/App.tsx b/src/components/App.tsx index daa33d0ea..83b23f375 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -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 { 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 { 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 { 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 { value={this.scene.getNonDeletedElements()} > + 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 { ); 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 { 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 { // (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 { 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 { 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 { 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 { 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 { : null, startArrowhead, endArrowhead, + ...selectSubtype(this.state, elementType), locked: false, }); this.setState((prevState) => ({ @@ -4300,6 +4386,7 @@ class App extends React.Component { : ROUNDNESS.PROPORTIONAL_RADIUS, } : null, + ...selectSubtype(this.state, elementType), locked: false, }); @@ -5947,6 +6034,28 @@ class App extends React.Component { } }; + private handleShapeContextMenu = ( + event: React.MouseEvent, + 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, ) => { @@ -6118,9 +6227,42 @@ class App extends React.Component { }; 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); diff --git a/src/components/ButtonSelect.tsx b/src/components/ButtonSelect.tsx index c47ff65e7..f7cc1a5d5 100644 --- a/src/components/ButtonSelect.tsx +++ b/src/components/ButtonSelect.tsx @@ -23,7 +23,17 @@ export const ButtonSelect = ({ onChange={() => onChange(option.value)} checked={value === option.value} /> - {option.text} + + {option.text} + ))} diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 2ec72e5ea..7787db930 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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(
{label}
{actionName - ? getShortcutFromShortcutName(actionName as ShortcutName) + ? getShortcutFromShortcutName( + actionName as ShortcutName | CustomShortcutName, + ) : ""} diff --git a/src/components/LayerUI.tsx b/src/components/LayerUI.tsx index 2d754c9ba..cbdd05ad7 100644 --- a/src/components/LayerUI.tsx +++ b/src/components/LayerUI.tsx @@ -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} @@ -492,6 +495,7 @@ const LayerUI = ({ onPenModeToggle={onPenModeToggle} canvas={canvas} isCollaborating={isCollaborating} + renderShapeToggles={renderShapeToggles} onImageAction={onImageAction} renderTopRightUI={renderTopRightUI} renderCustomStats={renderCustomStats} diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx index 37b32cc0a..bbdad362e 100644 --- a/src/components/MobileMenu.tsx +++ b/src/components/MobileMenu.tsx @@ -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} {renderTopRightUI && renderTopRightUI(true, appState)} diff --git a/src/components/PasteChartDialog.tsx b/src/components/PasteChartDialog.tsx index b71aef509..4e86f25f0 100644 --- a/src/components/PasteChartDialog.tsx +++ b/src/components/PasteChartDialog.tsx @@ -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(null); const [chartElements, setChartElements] = useState( @@ -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 (