From 73bf50e8a8ebb7f3665c04bace5bd6b5adca56ea Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 15 Feb 2024 11:11:18 +0530 Subject: [PATCH 01/66] fix: remove t from getDefaultAppState and allow name to be nullable (#7666) * fix: remove t and allow name to be nullable * pass name as required prop * remove Unnamed * pass name to excalidrawPlus as well for better type safe * render once we have excalidrawAPI to avoid defaulting * rename `getAppName` -> `getName` (temporary) * stop preventing editing filenames when `props.name` supplied * keep `name` as optional param for export functions * keep `appState.name` on `props.name` state separate * fix lint * assertive first * fix lint * Add TODO --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 46 ++++++++++--------- .../components/ExportToExcalidrawPlus.tsx | 8 ++-- .../excalidraw/actions/actionClipboard.tsx | 2 + packages/excalidraw/actions/actionExport.tsx | 17 ++++--- packages/excalidraw/appState.ts | 4 +- packages/excalidraw/components/App.tsx | 26 +++++------ .../components/ImageExportDialog.tsx | 13 +++--- packages/excalidraw/components/LayerUI.tsx | 1 + .../excalidraw/components/ProjectName.tsx | 27 ++++------- packages/excalidraw/constants.ts | 6 +++ packages/excalidraw/data/index.ts | 12 +++-- packages/excalidraw/data/json.ts | 5 +- packages/excalidraw/data/resave.ts | 3 +- packages/excalidraw/tests/excalidraw.test.tsx | 5 +- packages/excalidraw/types.ts | 7 ++- 15 files changed, 101 insertions(+), 81 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index e38dd7a94..972737b9d 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -709,27 +709,30 @@ const ExcalidrawWrapper = () => { toggleTheme: true, export: { onExportToBackend, - renderCustomUI: (elements, appState, files) => { - return ( - { - excalidrawAPI?.updateScene({ - appState: { - errorMessage: error.message, - }, - }); - }} - onSuccess={() => { - excalidrawAPI?.updateScene({ - appState: { openDialog: null }, - }); - }} - /> - ); - }, + renderCustomUI: excalidrawAPI + ? (elements, appState, files) => { + return ( + { + excalidrawAPI?.updateScene({ + appState: { + errorMessage: error.message, + }, + }); + }} + onSuccess={() => { + excalidrawAPI.updateScene({ + appState: { openDialog: null }, + }); + }} + /> + ); + } + : undefined, }, }, }} @@ -775,6 +778,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.getSceneElements(), excalidrawAPI.getAppState(), excalidrawAPI.getFiles(), + excalidrawAPI.getName(), ); }} > diff --git a/excalidraw-app/components/ExportToExcalidrawPlus.tsx b/excalidraw-app/components/ExportToExcalidrawPlus.tsx index 4c566950b..bfbb4a556 100644 --- a/excalidraw-app/components/ExportToExcalidrawPlus.tsx +++ b/excalidraw-app/components/ExportToExcalidrawPlus.tsx @@ -30,6 +30,7 @@ export const exportToExcalidrawPlus = async ( elements: readonly NonDeletedExcalidrawElement[], appState: Partial, files: BinaryFiles, + name: string, ) => { const firebase = await loadFirebaseStorage(); @@ -53,7 +54,7 @@ export const exportToExcalidrawPlus = async ( .ref(`/migrations/scenes/${id}`) .put(blob, { customMetadata: { - data: JSON.stringify({ version: 2, name: appState.name }), + data: JSON.stringify({ version: 2, name }), created: Date.now().toString(), }, }); @@ -89,9 +90,10 @@ export const ExportToExcalidrawPlus: React.FC<{ elements: readonly NonDeletedExcalidrawElement[]; appState: Partial; files: BinaryFiles; + name: string; onError: (error: Error) => void; onSuccess: () => void; -}> = ({ elements, appState, files, onError, onSuccess }) => { +}> = ({ elements, appState, files, name, onError, onSuccess }) => { const { t } = useI18n(); return ( @@ -117,7 +119,7 @@ export const ExportToExcalidrawPlus: React.FC<{ onClick={async () => { try { trackEvent("export", "eplus", `ui (${getFrame()})`); - await exportToExcalidrawPlus(elements, appState, files); + await exportToExcalidrawPlus(elements, appState, files, name); onSuccess(); } catch (error: any) { console.error(error); diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index dadc61013..b2457341d 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -138,6 +138,7 @@ export const actionCopyAsSvg = register({ { ...appState, exportingFrame, + name: app.getName(), }, ); return { @@ -184,6 +185,7 @@ export const actionCopyAsPng = register({ await exportCanvas("clipboard", exportedElements, appState, app.files, { ...appState, exportingFrame, + name: app.getName(), }); return { appState: { diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index 74dff34c8..7cb6b1291 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -26,14 +26,11 @@ export const actionChangeProjectName = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, name: value }, commitToHistory: false }; }, - PanelComponent: ({ appState, updateData, appProps, data }) => ( + PanelComponent: ({ appState, updateData, appProps, data, app }) => ( updateData(name)} - isNameEditable={ - typeof appProps.name === "undefined" && !appState.viewModeEnabled - } ignoreFocus={data?.ignoreFocus ?? false} /> ), @@ -144,8 +141,13 @@ export const actionSaveToActiveFile = register({ try { const { fileHandle } = isImageFileHandle(appState.fileHandle) - ? await resaveAsImageWithScene(elements, appState, app.files) - : await saveAsJSON(elements, appState, app.files); + ? await resaveAsImageWithScene( + elements, + appState, + app.files, + app.getName(), + ) + : await saveAsJSON(elements, appState, app.files, app.getName()); return { commitToHistory: false, @@ -190,6 +192,7 @@ export const actionSaveFileToDisk = register({ fileHandle: null, }, app.files, + app.getName(), ); return { commitToHistory: false, diff --git a/packages/excalidraw/appState.ts b/packages/excalidraw/appState.ts index 4dec9a790..a0ab233c9 100644 --- a/packages/excalidraw/appState.ts +++ b/packages/excalidraw/appState.ts @@ -7,9 +7,7 @@ import { EXPORT_SCALES, THEME, } from "./constants"; -import { t } from "./i18n"; import { AppState, NormalizedZoomValue } from "./types"; -import { getDateTime } from "./utils"; const defaultExportScale = EXPORT_SCALES.includes(devicePixelRatio) ? devicePixelRatio @@ -65,7 +63,7 @@ export const getDefaultAppState = (): Omit< isRotating: false, lastPointerDownWith: "mouse", multiElement: null, - name: `${t("labels.untitled")}-${getDateTime()}`, + name: null, contextMenu: null, openMenu: null, openPopup: null, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7b9cc8cc2..3d3838afc 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -270,6 +270,7 @@ import { updateStable, addEventListener, normalizeEOL, + getDateTime, } from "../utils"; import { createSrcDoc, @@ -619,7 +620,7 @@ class App extends React.Component { gridModeEnabled = false, objectsSnapModeEnabled = false, theme = defaultAppState.theme, - name = defaultAppState.name, + name = `${t("labels.untitled")}-${getDateTime()}`, } = props; this.state = { ...defaultAppState, @@ -662,6 +663,7 @@ class App extends React.Component { getSceneElements: this.getSceneElements, getAppState: () => this.state, getFiles: () => this.files, + getName: this.getName, registerAction: (action: Action) => { this.actionManager.registerAction(action); }, @@ -1734,7 +1736,7 @@ class App extends React.Component { this.files, { exportBackground: this.state.exportBackground, - name: this.state.name, + name: this.getName(), viewBackgroundColor: this.state.viewBackgroundColor, exportingFrame: opts.exportingFrame, }, @@ -2133,7 +2135,7 @@ class App extends React.Component { let gridSize = actionResult?.appState?.gridSize || null; const theme = actionResult?.appState?.theme || this.props.theme || THEME.LIGHT; - let name = actionResult?.appState?.name ?? this.state.name; + const name = actionResult?.appState?.name ?? this.state.name; const errorMessage = actionResult?.appState?.errorMessage ?? this.state.errorMessage; if (typeof this.props.viewModeEnabled !== "undefined") { @@ -2148,10 +2150,6 @@ class App extends React.Component { gridSize = this.props.gridModeEnabled ? GRID_SIZE : null; } - if (typeof this.props.name !== "undefined") { - name = this.props.name; - } - editingElement = editingElement || actionResult.appState?.editingElement || null; @@ -2709,12 +2707,6 @@ class App extends React.Component { }); } - if (this.props.name && prevProps.name !== this.props.name) { - this.setState({ - name: this.props.name, - }); - } - this.excalidrawContainerRef.current?.classList.toggle( "theme--dark", this.state.theme === "dark", @@ -4122,6 +4114,14 @@ class App extends React.Component { return gesture.pointers.size >= 2; }; + public getName = () => { + return ( + this.state.name || + this.props.name || + `${t("labels.untitled")}-${getDateTime()}` + ); + }; + // fires only on Safari private onGestureStart = withBatchedUpdates((event: GestureEvent) => { event.preventDefault(); diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index 7ca54e985..cecdfa79a 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -32,7 +32,6 @@ import { Switch } from "./Switch"; import { Tooltip } from "./Tooltip"; import "./ImageExportDialog.scss"; -import { useAppProps } from "./App"; import { FilledButton } from "./FilledButton"; import { cloneJSON } from "../utils"; import { prepareElementsForExport } from "../data"; @@ -58,6 +57,7 @@ type ImageExportModalProps = { files: BinaryFiles; actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; + name: string; }; const ImageExportModal = ({ @@ -66,14 +66,14 @@ const ImageExportModal = ({ files, actionManager, onExportImage, + name, }: ImageExportModalProps) => { const hasSelection = isSomeElementSelected( elementsSnapshot, appStateSnapshot, ); - const appProps = useAppProps(); - const [projectName, setProjectName] = useState(appStateSnapshot.name); + const [projectName, setProjectName] = useState(name); const [exportSelectionOnly, setExportSelectionOnly] = useState(hasSelection); const [exportWithBackground, setExportWithBackground] = useState( appStateSnapshot.exportBackground, @@ -158,10 +158,6 @@ const ImageExportModal = ({ className="TextInput" value={projectName} style={{ width: "30ch" }} - disabled={ - typeof appProps.name !== "undefined" || - appStateSnapshot.viewModeEnabled - } onChange={(event) => { setProjectName(event.target.value); actionManager.executeAction( @@ -347,6 +343,7 @@ export const ImageExportDialog = ({ actionManager, onExportImage, onCloseRequest, + name, }: { appState: UIAppState; elements: readonly NonDeletedExcalidrawElement[]; @@ -354,6 +351,7 @@ export const ImageExportDialog = ({ actionManager: ActionManager; onExportImage: AppClassProperties["onExportImage"]; onCloseRequest: () => void; + name: string; }) => { // we need to take a snapshot so that the exported state can't be modified // while the dialog is open @@ -372,6 +370,7 @@ export const ImageExportDialog = ({ files={files} actionManager={actionManager} onExportImage={onExportImage} + name={name} /> ); diff --git a/packages/excalidraw/components/LayerUI.tsx b/packages/excalidraw/components/LayerUI.tsx index 7247e8bf1..eb8027138 100644 --- a/packages/excalidraw/components/LayerUI.tsx +++ b/packages/excalidraw/components/LayerUI.tsx @@ -195,6 +195,7 @@ const LayerUI = ({ actionManager={actionManager} onExportImage={onExportImage} onCloseRequest={() => setAppState({ openDialog: null })} + name={app.getName()} /> ); }; diff --git a/packages/excalidraw/components/ProjectName.tsx b/packages/excalidraw/components/ProjectName.tsx index 69ff33527..592961793 100644 --- a/packages/excalidraw/components/ProjectName.tsx +++ b/packages/excalidraw/components/ProjectName.tsx @@ -11,7 +11,6 @@ type Props = { value: string; onChange: (value: string) => void; label: string; - isNameEditable: boolean; ignoreFocus?: boolean; }; @@ -42,23 +41,17 @@ export const ProjectName = (props: Props) => { return (
- {props.isNameEditable ? ( - setFileName(event.target.value)} - /> - ) : ( - - {props.value} - - )} + setFileName(event.target.value)} + />
); }; diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 021c706a9..09e497564 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -381,3 +381,9 @@ export const EDITOR_LS_KEYS = { MERMAID_TO_EXCALIDRAW: "mermaid-to-excalidraw", PUBLISH_LIBRARY: "publish-library-data", } as const; + +/** + * not translated as this is used only in public, stateless API as default value + * where filename is optional and we can't retrieve name from app state + */ +export const DEFAULT_FILENAME = "Untitled"; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index fa2ec9de6..51446921f 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -2,7 +2,12 @@ import { copyBlobToClipboardAsPng, copyTextToSystemClipboard, } from "../clipboard"; -import { DEFAULT_EXPORT_PADDING, isFirefox, MIME_TYPES } from "../constants"; +import { + DEFAULT_EXPORT_PADDING, + DEFAULT_FILENAME, + isFirefox, + MIME_TYPES, +} from "../constants"; import { getNonDeletedElements } from "../element"; import { isFrameLikeElement } from "../element/typeChecks"; import { @@ -84,14 +89,15 @@ export const exportCanvas = async ( exportBackground, exportPadding = DEFAULT_EXPORT_PADDING, viewBackgroundColor, - name, + name = appState.name || DEFAULT_FILENAME, fileHandle = null, exportingFrame = null, }: { exportBackground: boolean; exportPadding?: number; viewBackgroundColor: string; - name: string; + /** filename, if applicable */ + name?: string; fileHandle?: FileSystemHandle | null; exportingFrame: ExcalidrawFrameLikeElement | null; }, diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 037c5ca18..94dddf288 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -1,6 +1,7 @@ import { fileOpen, fileSave } from "./filesystem"; import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState"; import { + DEFAULT_FILENAME, EXPORT_DATA_TYPES, EXPORT_SOURCE, MIME_TYPES, @@ -71,6 +72,8 @@ export const saveAsJSON = async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, + /** filename */ + name: string = appState.name || DEFAULT_FILENAME, ) => { const serialized = serializeAsJSON(elements, appState, files, "local"); const blob = new Blob([serialized], { @@ -78,7 +81,7 @@ export const saveAsJSON = async ( }); const fileHandle = await fileSave(blob, { - name: appState.name, + name, extension: "excalidraw", description: "Excalidraw file", fileHandle: isImageFileHandle(appState.fileHandle) diff --git a/packages/excalidraw/data/resave.ts b/packages/excalidraw/data/resave.ts index 0998fd3c7..c73890e22 100644 --- a/packages/excalidraw/data/resave.ts +++ b/packages/excalidraw/data/resave.ts @@ -7,8 +7,9 @@ export const resaveAsImageWithScene = async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, + name: string, ) => { - const { exportBackground, viewBackgroundColor, name, fileHandle } = appState; + const { exportBackground, viewBackgroundColor, fileHandle } = appState; const fileHandleType = getFileHandleType(fileHandle); diff --git a/packages/excalidraw/tests/excalidraw.test.tsx b/packages/excalidraw/tests/excalidraw.test.tsx index 962dabcc0..98d0e9006 100644 --- a/packages/excalidraw/tests/excalidraw.test.tsx +++ b/packages/excalidraw/tests/excalidraw.test.tsx @@ -303,7 +303,7 @@ describe("", () => { }); describe("Test name prop", () => { - it('should allow editing name when the name prop is "undefined"', async () => { + it("should allow editing name", async () => { const { container } = await render(); //open menu toggleMenu(container); @@ -315,7 +315,7 @@ describe("", () => { expect(textInput?.nodeName).toBe("INPUT"); }); - it('should set the name and not allow editing when the name prop is present"', async () => { + it('should set the name when the name prop is present"', async () => { const name = "test"; const { container } = await render(); //open menu @@ -326,7 +326,6 @@ describe("", () => { ) as HTMLInputElement; expect(textInput?.value).toEqual(name); expect(textInput?.nodeName).toBe("INPUT"); - expect(textInput?.disabled).toBe(true); }); }); diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 89b121b2f..1eaa04449 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -247,7 +247,7 @@ export interface AppState { scrollY: number; cursorButton: "up" | "down"; scrolledOutside: boolean; - name: string; + name: string | null; isResizing: boolean; isRotating: boolean; zoom: Zoom; @@ -435,6 +435,7 @@ export interface ExcalidrawProps { objectsSnapModeEnabled?: boolean; libraryReturnUrl?: string; theme?: Theme; + // @TODO come with better API before v0.18.0 name?: string; renderCustomStats?: ( elements: readonly NonDeletedExcalidrawElement[], @@ -577,6 +578,7 @@ export type AppClassProperties = { setOpenDialog: App["setOpenDialog"]; insertEmbeddableElement: App["insertEmbeddableElement"]; onMagicframeToolSelect: App["onMagicframeToolSelect"]; + getName: App["getName"]; }; export type PointerDownState = Readonly<{ @@ -651,10 +653,11 @@ export type ExcalidrawImperativeAPI = { history: { clear: InstanceType["resetHistory"]; }; - scrollToContent: InstanceType["scrollToContent"]; getSceneElements: InstanceType["getSceneElements"]; getAppState: () => InstanceType["state"]; getFiles: () => InstanceType["files"]; + getName: InstanceType["getName"]; + scrollToContent: InstanceType["scrollToContent"]; registerAction: (action: Action) => void; refresh: InstanceType["refresh"]; setToast: InstanceType["setToast"]; From 47f87f4ecbc62058b6e2b6b7d953952bf6f3ecaf Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 16 Feb 2024 11:35:01 +0530 Subject: [PATCH 02/66] fix: remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap (#7663) * fix: remove scene from getElementAbsoluteCoords and dependent functions and use elementsMap * lint * fix * use non deleted elements where possible * use non deleted elements map in actions * pass elementsMap instead of array to elementOverlapsWithFrame * lint * fix * pass elementsMap to getElementsCorners * pass elementsMap to getEligibleElementsForBinding * pass elementsMap in bindOrUnbindSelectedElements and unbindLinearElements * pass elementsMap in elementsAreInFrameBounds,elementOverlapsWithFrame,isCursorInFrame,getElementsInResizingFrame * pass elementsMap in getElementsWithinSelection, getElementsCompletelyInFrame, isElementContainingFrame, getElementsInNewFrame * pass elementsMap to getElementWithTransformHandleType * pass elementsMap to getVisibleGaps, getMaximumGroups,getReferenceSnapPoints,snapDraggedElements * lint * pass elementsMap to bindTextToShapeAfterDuplication,bindLinearElementToElement,getTextBindableContainerAtPosition * revert changes for bindTextToShapeAfterDuplication --- .../excalidraw/actions/actionBoundText.tsx | 18 ++- .../excalidraw/actions/actionFinalize.tsx | 7 +- packages/excalidraw/actions/actionFlip.ts | 2 +- packages/excalidraw/actions/actionGroup.tsx | 9 +- .../excalidraw/actions/actionProperties.tsx | 4 + packages/excalidraw/actions/actionStyles.ts | 6 +- packages/excalidraw/components/App.tsx | 128 +++++++++++++----- packages/excalidraw/data/restore.ts | 1 + packages/excalidraw/data/transform.ts | 10 +- .../element/ElementCanvasButtons.tsx | 9 +- packages/excalidraw/element/Hyperlink.tsx | 49 +++++-- packages/excalidraw/element/binding.ts | 118 +++++++++++++--- packages/excalidraw/element/bounds.test.ts | 17 +-- packages/excalidraw/element/bounds.ts | 18 ++- packages/excalidraw/element/collision.ts | 90 +++++++++--- packages/excalidraw/element/dragElements.ts | 2 +- .../excalidraw/element/linearElementEditor.ts | 83 ++++++++++-- packages/excalidraw/element/newElement.ts | 10 +- packages/excalidraw/element/resizeElements.ts | 29 ++-- packages/excalidraw/element/resizeTest.ts | 6 +- packages/excalidraw/element/textElement.ts | 35 +++-- packages/excalidraw/element/textWysiwyg.tsx | 16 ++- .../excalidraw/element/transformHandles.ts | 5 +- packages/excalidraw/frame.ts | 86 +++++++----- packages/excalidraw/renderer/renderElement.ts | 37 +++-- packages/excalidraw/renderer/renderScene.ts | 53 ++++++-- packages/excalidraw/scene/Fonts.ts | 6 +- packages/excalidraw/scene/export.ts | 3 +- packages/excalidraw/scene/selection.ts | 6 +- packages/excalidraw/snapping.ts | 40 +++--- packages/excalidraw/tests/binding.test.tsx | 9 +- packages/excalidraw/tests/flip.test.tsx | 13 +- packages/excalidraw/tests/helpers/ui.ts | 10 +- .../tests/linearElementEditor.test.tsx | 94 ++++++++++--- packages/excalidraw/tests/move.test.tsx | 4 +- packages/excalidraw/tests/resize.test.tsx | 16 ++- 36 files changed, 779 insertions(+), 270 deletions(-) diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 722ad5111..e0ea95cd4 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -58,7 +58,11 @@ export const actionUnbindText = register({ element.id, ); resetOriginalContainerCache(element.id); - const { x, y } = computeBoundTextPosition(element, boundTextElement); + const { x, y } = computeBoundTextPosition( + element, + boundTextElement, + elementsMap, + ); mutateElement(boundTextElement as ExcalidrawTextElement, { containerId: null, width, @@ -145,7 +149,11 @@ export const actionBindText = register({ }), }); const originalContainerHeight = container.height; - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); // overwritting the cache with original container height so // it can be restored when unbind updateOriginalContainerCache(container.id, originalContainerHeight); @@ -286,7 +294,11 @@ export const actionWrapTextInContainer = register({ }, false, ); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox( + textElement, + container, + app.scene.getNonDeletedElementsMap(), + ); updatedElements = pushContainerBelowText( [...updatedElements, container], diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index a7c34c5ac..623876d58 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -1,6 +1,6 @@ import { KEYS } from "../keys"; import { isInvisiblySmallElement } from "../element"; -import { updateActiveTool } from "../utils"; +import { arrayToMap, updateActiveTool } from "../utils"; import { ToolButton } from "../components/ToolButton"; import { done } from "../components/icons"; import { t } from "../i18n"; @@ -26,6 +26,8 @@ export const actionFinalize = register({ _, { interactiveCanvas, focusContainer, scene }, ) => { + const elementsMap = arrayToMap(elements); + if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; @@ -37,6 +39,7 @@ export const actionFinalize = register({ element, startBindingElement, endBindingElement, + elementsMap, ); } return { @@ -125,12 +128,14 @@ export const actionFinalize = register({ const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( multiPointElement, -1, + arrayToMap(elements), ); maybeBindLinearElement( multiPointElement, appState, Scene.getScene(multiPointElement)!, { x, y }, + elementsMap, ); } } diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index c760af44d..70fbe026d 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -115,7 +115,7 @@ const flipElements = ( (isBindingEnabled(appState) ? bindOrUnbindSelectedElements - : unbindLinearElements)(selectedElements); + : unbindLinearElements)(selectedElements, elementsMap); return selectedElements; }; diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index 44523857a..44e590bc2 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -180,6 +180,8 @@ export const actionUngroup = register({ trackEvent: { category: "element" }, perform: (elements, appState, _, app) => { const groupIds = getSelectedGroupIds(appState); + const elementsMap = arrayToMap(elements); + if (groupIds.length === 0) { return { appState, elements, commitToHistory: false }; } @@ -226,7 +228,12 @@ export const actionUngroup = register({ if (frame) { nextElements = replaceAllElementsInFrame( nextElements, - getElementsInResizingFrame(nextElements, frame, appState), + getElementsInResizingFrame( + nextElements, + frame, + appState, + elementsMap, + ), frame, app, ); diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 79e50aa68..8f2c350d6 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -209,6 +209,7 @@ const changeFontSize = ( redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); newElement = offsetElementAfterFontResize(oldElement, newElement); @@ -730,6 +731,7 @@ export const actionChangeFontFamily = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -829,6 +831,7 @@ export const actionChangeTextAlign = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } @@ -918,6 +921,7 @@ export const actionChangeVerticalAlign = register({ redrawTextBoundingBox( newElement, app.scene.getContainerElement(oldElement), + app.scene.getNonDeletedElementsMap(), ); return newElement; } diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 25a6baf2a..538375031 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -128,7 +128,11 @@ export const actionPasteStyles = register({ element.id === newElement.containerId, ) || null; } - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } if ( diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 3d3838afc..b4410ab2b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1536,6 +1536,7 @@ class App extends React.Component { { isMagicFrameElement(firstSelectedElement) && ( { ?.status === "done" && ( { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); - if ( - !this.state.showWelcomeScreen && - !this.scene.getElementsIncludingDeleted().length - ) { + const elements = this.scene.getElementsIncludingDeleted(); + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + + if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); } @@ -2756,27 +2759,21 @@ class App extends React.Component { LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, + elementsMap, ), ), + elementsMap, ); } - this.history.record(this.state, this.scene.getElementsIncludingDeleted()); + this.history.record(this.state, elements); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during // init, which would trigger onChange with empty elements, which would then // override whatever is in localStorage currently. if (!this.state.isLoading) { - this.props.onChange?.( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); - this.onChangeEmitter.trigger( - this.scene.getElementsIncludingDeleted(), - this.state, - this.files, - ); + this.props.onChange?.(elements, this.state, this.files); + this.onChangeEmitter.trigger(elements, this.state, this.files); } } @@ -3126,7 +3123,11 @@ class App extends React.Component { newElement, this.scene.getElementsMapIncludingDeleted(), ); - redrawTextBoundingBox(newElement, container); + redrawTextBoundingBox( + newElement, + container, + this.scene.getElementsMapIncludingDeleted(), + ); } }); @@ -3836,7 +3837,7 @@ class App extends React.Component { y: element.y + offsetY, }); - updateBoundElements(element, { + updateBoundElements(element, this.scene.getNonDeletedElementsMap(), { simultaneouslyUpdated: selectedElements, }); }); @@ -4010,9 +4011,10 @@ class App extends React.Component { } if (isArrowKey(event.key)) { const selectedElements = this.scene.getSelectedElements(this.state); + const elementsMap = this.scene.getNonDeletedElementsMap(); isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements(selectedElements) - : unbindLinearElements(selectedElements); + ? bindOrUnbindSelectedElements(selectedElements, elementsMap) + : unbindLinearElements(selectedElements, elementsMap); this.setState({ suggestedBindings: [] }); } }); @@ -4193,20 +4195,21 @@ class App extends React.Component { isExistingElement?: boolean; }, ) { + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const updateElement = ( text: string, originalText: string, isDeleted: boolean, ) => { this.scene.replaceAllElements([ + // Not sure why we include deleted elements as well hence using deleted elements map ...this.scene.getElementsIncludingDeleted().map((_element) => { if (_element.id === element.id && isTextElement(_element)) { return updateTextElement( _element, - getContainerElement( - _element, - this.scene.getElementsMapIncludingDeleted(), - ), + getContainerElement(_element, elementsMap), + elementsMap, { text, isDeleted, @@ -4238,7 +4241,7 @@ class App extends React.Component { onChange: withBatchedUpdates((text) => { updateElement(text, text, false); if (isNonDeletedElement(element)) { - updateBoundElements(element); + updateBoundElements(element, elementsMap); } }), onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => { @@ -4377,6 +4380,7 @@ class App extends React.Component { !(isTextElement(element) && element.containerId)), ); + const elementsMap = this.scene.getNonDeletedElementsMap(); return getElementsAtPosition(elements, (element) => hitTest( element, @@ -4384,7 +4388,7 @@ class App extends React.Component { this.frameNameBoundsCache, x, y, - this.scene.getNonDeletedElementsMap(), + elementsMap, ), ).filter((element) => { // hitting a frame's element from outside the frame is not considered a hit @@ -4392,7 +4396,7 @@ class App extends React.Component { return containingFrame && this.state.frameRendering.enabled && this.state.frameRendering.clip - ? isCursorInFrame({ x, y }, containingFrame) + ? isCursorInFrame({ x, y }, containingFrame, elementsMap) : true; }); } @@ -4637,6 +4641,7 @@ class App extends React.Component { this.state, sceneX, sceneY, + this.scene.getNonDeletedElementsMap(), ); if (container) { @@ -4648,6 +4653,7 @@ class App extends React.Component { this.state, this.frameNameBoundsCache, [sceneX, sceneY], + this.scene.getNonDeletedElementsMap(), ) ) { const midPoint = getContainerCenter( @@ -4688,6 +4694,7 @@ class App extends React.Component { index <= hitElementIndex && isPointHittingLink( element, + this.scene.getNonDeletedElementsMap(), this.state, [scenePointer.x, scenePointer.y], this.device.editor.isMobile, @@ -4718,8 +4725,10 @@ class App extends React.Component { this.lastPointerDownEvent!, this.state, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); const lastPointerDownHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerDownCoords.x, lastPointerDownCoords.y], this.device.editor.isMobile, @@ -4730,6 +4739,7 @@ class App extends React.Component { ); const lastPointerUpHittingLinkIcon = isPointHittingLink( this.hitLinkElement, + elementsMap, this.state, [lastPointerUpCoords.x, lastPointerUpCoords.y], this.device.editor.isMobile, @@ -4766,10 +4776,11 @@ class App extends React.Component { x: number; y: number; }) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame), + isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; @@ -4873,6 +4884,7 @@ class App extends React.Component { y: scenePointerY, }, event, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -4912,6 +4924,7 @@ class App extends React.Component { scenePointerX, scenePointerY, this.state, + this.scene.getNonDeletedElementsMap(), ); if ( @@ -5062,6 +5075,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), ); if ( elementWithTransformHandleType && @@ -5109,7 +5123,11 @@ class App extends React.Component { !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - showHyperlinkTooltip(this.hitLinkElement, this.state); + showHyperlinkTooltip( + this.hitLinkElement, + this.state, + this.scene.getNonDeletedElementsMap(), + ); } else { hideHyperlinkToolip(); if ( @@ -5305,10 +5323,12 @@ class App extends React.Component { this.state, this.frameNameBoundsCache, [scenePointerX, scenePointerY], + elementsMap, ) ) { hoverPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, this.state.zoom, scenePointerX, scenePointerY, @@ -5738,10 +5758,12 @@ class App extends React.Component { if ( clicklength < 300 && isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon(this.hitLinkElement, this.state, [ - scenePointer.x, - scenePointer.y, - ]) + !isPointHittingLinkIcon( + this.hitLinkElement, + this.scene.getNonDeletedElementsMap(), + this.state, + [scenePointer.x, scenePointer.y], + ) ) { this.handleEmbeddableCenterClick(this.hitLinkElement); } else { @@ -6039,7 +6061,9 @@ class App extends React.Component { ): boolean => { if (this.state.activeTool.type === "selection") { const elements = this.scene.getNonDeletedElements(); + const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); + if (selectedElements.length === 1 && !this.state.editingLinearElement) { const elementWithTransformHandleType = getElementWithTransformHandleType( @@ -6049,6 +6073,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, + this.scene.getNonDeletedElementsMap(), ); if (elementWithTransformHandleType != null) { this.setState({ @@ -6072,6 +6097,7 @@ class App extends React.Component { getResizeOffsetXY( pointerDownState.resize.handleType, selectedElements, + elementsMap, pointerDownState.origin.x, pointerDownState.origin.y, ), @@ -6352,6 +6378,7 @@ class App extends React.Component { this.state, sceneX, sceneY, + this.scene.getNonDeletedElementsMap(), ); if (hasBoundTextElement(element)) { @@ -6846,6 +6873,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6869,6 +6897,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), selectedElements, this.state, + this.scene.getNonDeletedElementsMap(), ), ); } @@ -6985,6 +7014,7 @@ class App extends React.Component { pointerCoords, this.state, !event[KEYS.CTRL_OR_CMD], + this.scene.getNonDeletedElementsMap(), ); if (!ret) { return; @@ -7143,10 +7173,11 @@ class App extends React.Component { this.maybeCacheReferenceSnapPoints(event, selectedElements); const { snapOffset, snapLines } = snapDraggedElements( - getSelectedElements(originalElements, this.state), + originalElements, dragOffset, this.state, event, + this.scene.getNonDeletedElementsMap(), ); this.setState({ snapLines }); @@ -7330,6 +7361,7 @@ class App extends React.Component { event, this.state, this.setState.bind(this), + this.scene.getNonDeletedElementsMap(), ); // regular box-select } else { @@ -7360,6 +7392,7 @@ class App extends React.Component { const elementsWithinSelection = getElementsWithinSelection( elements, draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.setState((prevState) => { @@ -7491,7 +7524,7 @@ class App extends React.Component { this.setState({ selectedElementsAreBeingDragged: false, }); - + const elementsMap = this.scene.getNonDeletedElementsMap(); // Handle end of dragging a point of a linear element, might close a loop // and sets binding element if (this.state.editingLinearElement) { @@ -7506,6 +7539,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, + elementsMap, ); if (editingLinearElement !== this.state.editingLinearElement) { this.setState({ @@ -7529,6 +7563,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, + elementsMap, ); const { startBindingElement, endBindingElement } = @@ -7539,6 +7574,7 @@ class App extends React.Component { element, startBindingElement, endBindingElement, + elementsMap, ); } @@ -7678,6 +7714,7 @@ class App extends React.Component { this.state, this.scene, pointerCoords, + elementsMap, ); } this.setState({ suggestedBindings: [], startBoundElement: null }); @@ -7748,7 +7785,13 @@ class App extends React.Component { const frame = getContainingFrame(linearElement); if (frame && linearElement) { - if (!elementOverlapsWithFrame(linearElement, frame)) { + if ( + !elementOverlapsWithFrame( + linearElement, + frame, + this.scene.getNonDeletedElementsMap(), + ) + ) { // remove the linear element from all groups // before removing it from the frame as well mutateElement(linearElement, { @@ -7859,6 +7902,7 @@ class App extends React.Component { const elementsInsideFrame = getElementsInNewFrame( this.scene.getElementsIncludingDeleted(), draggingElement, + this.scene.getNonDeletedElementsMap(), ); this.scene.replaceAllElements( @@ -7909,6 +7953,7 @@ class App extends React.Component { this.scene.getElementsIncludingDeleted(), frame, this.state, + elementsMap, ), frame, this, @@ -8189,7 +8234,10 @@ class App extends React.Component { if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { (isBindingEnabled(this.state) ? bindOrUnbindSelectedElements - : unbindLinearElements)(this.scene.getSelectedElements(this.state)); + : unbindLinearElements)( + this.scene.getSelectedElements(this.state), + elementsMap, + ); } if (activeTool.type === "laser") { @@ -8719,7 +8767,10 @@ class App extends React.Component { if (selectedElements.length > 50) { return; } - const suggestedBindings = getEligibleElementsForBinding(selectedElements); + const suggestedBindings = getEligibleElementsForBinding( + selectedElements, + this.scene.getNonDeletedElementsMap(), + ); this.setState({ suggestedBindings }); } @@ -9058,6 +9109,7 @@ class App extends React.Component { x: gridX - pointerDownState.originInGrid.x, y: gridY - pointerDownState.originInGrid.y, }, + this.scene.getNonDeletedElementsMap(), ); gridX += snapOffset.x; @@ -9096,6 +9148,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), draggingElement as ExcalidrawFrameLikeElement, this.state, + this.scene.getNonDeletedElementsMap(), ), }); } @@ -9215,6 +9268,7 @@ class App extends React.Component { this.scene.getNonDeletedElements(), frame, this.state, + this.scene.getNonDeletedElementsMap(), ).forEach((element) => elementsToHighlight.add(element)); }); diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 12e7f1af1..022457f01 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -462,6 +462,7 @@ export const restoreElements = ( refreshTextDimensions( element, getContainerElement(element, restoredElementsMap), + restoredElementsMap, ), ); } diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 8ce842300..8d5b63a19 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -222,7 +222,7 @@ const bindTextToContainer = ( }), }); - redrawTextBoundingBox(textElement, container); + redrawTextBoundingBox(textElement, container, elementsMap); return [container, textElement] as const; }; @@ -231,6 +231,7 @@ const bindLinearElementToElement = ( start: ValidLinearElement["start"], end: ValidLinearElement["end"], elementStore: ElementStore, + elementsMap: ElementsMap, ): { linearElement: ExcalidrawLinearElement; startBoundElement?: ExcalidrawElement; @@ -316,6 +317,7 @@ const bindLinearElementToElement = ( linearElement, startBoundElement as ExcalidrawBindableElement, "start", + elementsMap, ); } } @@ -390,6 +392,7 @@ const bindLinearElementToElement = ( linearElement, endBoundElement as ExcalidrawBindableElement, "end", + elementsMap, ); } } @@ -612,6 +615,7 @@ export const convertToExcalidrawElements = ( } } + const elementsMap = arrayToMap(elementStore.getElements()); // Add labels and arrow bindings for (const [id, element] of elementsWithIds) { const excalidrawElement = elementStore.getElement(id)!; @@ -625,7 +629,7 @@ export const convertToExcalidrawElements = ( let [container, text] = bindTextToContainer( excalidrawElement, element?.label, - arrayToMap(elementStore.getElements()), + elementsMap, ); elementStore.add(container); elementStore.add(text); @@ -653,6 +657,7 @@ export const convertToExcalidrawElements = ( originalStart, originalEnd, elementStore, + elementsMap, ); container = linearElement; elementStore.add(linearElement); @@ -677,6 +682,7 @@ export const convertToExcalidrawElements = ( start, end, elementStore, + elementsMap, ); elementStore.add(linearElement); diff --git a/packages/excalidraw/element/ElementCanvasButtons.tsx b/packages/excalidraw/element/ElementCanvasButtons.tsx index 99d9d55e1..0fc7621fd 100644 --- a/packages/excalidraw/element/ElementCanvasButtons.tsx +++ b/packages/excalidraw/element/ElementCanvasButtons.tsx @@ -1,6 +1,6 @@ import { AppState } from "../types"; import { sceneCoordsToViewportCoords } from "../utils"; -import { NonDeletedExcalidrawElement } from "./types"; +import { ElementsMap, NonDeletedExcalidrawElement } from "./types"; import { getElementAbsoluteCoords } from "."; import { useExcalidrawAppState } from "../components/App"; @@ -11,8 +11,9 @@ const CONTAINER_PADDING = 5; const getContainerCoords = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( { sceneX: x1 + element.width, sceneY: y1 }, appState, @@ -25,9 +26,11 @@ const getContainerCoords = ( export const ElementCanvasButtons = ({ children, element, + elementsMap, }: { children: React.ReactNode; element: NonDeletedExcalidrawElement; + elementsMap: ElementsMap; }) => { const appState = useExcalidrawAppState(); @@ -42,7 +45,7 @@ export const ElementCanvasButtons = ({ return null; } - const { x, y } = getContainerCoords(element, appState); + const { x, y } = getContainerCoords(element, appState, elementsMap); return (
["setState"]; onLinkOpen: ExcalidrawProps["onLinkOpen"]; setToast: ( @@ -182,7 +185,7 @@ export const Hyperlink = ({ if (timeoutId) { clearTimeout(timeoutId); } - const shouldHide = shouldHideLinkPopup(element, appState, [ + const shouldHide = shouldHideLinkPopup(element, elementsMap, appState, [ event.clientX, event.clientY, ]) as boolean; @@ -199,7 +202,7 @@ export const Hyperlink = ({ clearTimeout(timeoutId); } }; - }, [appState, element, isEditing, setAppState]); + }, [appState, element, isEditing, setAppState, elementsMap]); const handleRemove = useCallback(() => { trackEvent("hyperlink", "delete"); @@ -214,7 +217,7 @@ export const Hyperlink = ({ trackEvent("hyperlink", "edit", "popup-ui"); setAppState({ showHyperlinkPopup: "editor" }); }; - const { x, y } = getCoordsForPopover(element, appState); + const { x, y } = getCoordsForPopover(element, appState, elementsMap); if ( appState.contextMenu || appState.draggingElement || @@ -324,8 +327,9 @@ export const Hyperlink = ({ const getCoordsForPopover = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); const { x: viewportX, y: viewportY } = sceneCoordsToViewportCoords( { sceneX: x1 + element.width / 2, sceneY: y1 }, appState, @@ -430,11 +434,12 @@ export const getLinkHandleFromCoords = ( export const isPointHittingLinkIcon = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, [x, y]: Point, ) => { const threshold = 4 / appState.zoom.value; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( [x1, y1, x2, y2], element.angle, @@ -450,6 +455,7 @@ export const isPointHittingLinkIcon = ( export const isPointHittingLink = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, [x, y]: Point, isMobile: boolean, @@ -461,23 +467,30 @@ export const isPointHittingLink = ( if ( !isMobile && appState.viewModeEnabled && - isPointHittingElementBoundingBox(element, [x, y], threshold, null) + isPointHittingElementBoundingBox( + element, + elementsMap, + [x, y], + threshold, + null, + ) ) { return true; } - return isPointHittingLinkIcon(element, appState, [x, y]); + return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); }; let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null; export const showHyperlinkTooltip = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { if (HYPERLINK_TOOLTIP_TIMEOUT_ID) { clearTimeout(HYPERLINK_TOOLTIP_TIMEOUT_ID); } HYPERLINK_TOOLTIP_TIMEOUT_ID = window.setTimeout( - () => renderTooltip(element, appState), + () => renderTooltip(element, appState, elementsMap), HYPERLINK_TOOLTIP_DELAY, ); }; @@ -485,6 +498,7 @@ export const showHyperlinkTooltip = ( const renderTooltip = ( element: NonDeletedExcalidrawElement, appState: AppState, + elementsMap: ElementsMap, ) => { if (!element.link) { return; @@ -496,7 +510,7 @@ const renderTooltip = ( tooltipDiv.style.maxWidth = "20rem"; tooltipDiv.textContent = element.link; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( [x1, y1, x2, y2], @@ -535,6 +549,7 @@ export const hideHyperlinkToolip = () => { export const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, [clientX, clientY]: Point, ): Boolean => { @@ -546,11 +561,17 @@ export const shouldHideLinkPopup = ( const threshold = 15 / appState.zoom.value; // hitbox to prevent hiding when hovered in element bounding box if ( - isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null) + isPointHittingElementBoundingBox( + element, + elementsMap, + [sceneX, sceneY], + threshold, + null, + ) ) { return false; } - const [x1, y1, x2] = getElementAbsoluteCoords(element); + const [x1, y1, x2] = getElementAbsoluteCoords(element, elementsMap); // hit box to prevent hiding when hovered in the vertical area between element and popover if ( sceneX >= x1 && @@ -561,7 +582,11 @@ export const shouldHideLinkPopup = ( return false; } // hit box to prevent hiding when hovered around popover within threshold - const { x: popoverX, y: popoverY } = getCoordsForPopover(element, appState); + const { x: popoverX, y: popoverY } = getCoordsForPopover( + element, + appState, + elementsMap, + ); if ( clientX >= popoverX - threshold && diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index 66d29f3f6..be766e33f 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -5,6 +5,7 @@ import { NonDeletedExcalidrawElement, PointBinding, ExcalidrawElement, + ElementsMap, } from "./types"; import { getElementAtPosition } from "../scene"; import { AppState } from "../types"; @@ -66,6 +67,7 @@ export const bindOrUnbindLinearElement = ( linearElement: NonDeleted, startBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep", + elementsMap: ElementsMap, ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -76,6 +78,7 @@ export const bindOrUnbindLinearElement = ( "start", boundToElementIds, unboundFromElementIds, + elementsMap, ); bindOrUnbindLinearElementEdge( linearElement, @@ -84,6 +87,7 @@ export const bindOrUnbindLinearElement = ( "end", boundToElementIds, unboundFromElementIds, + elementsMap, ); const onlyUnbound = Array.from(unboundFromElementIds).filter( @@ -111,6 +115,7 @@ const bindOrUnbindLinearElementEdge = ( boundToElementIds: Set, // Is mutated unboundFromElementIds: Set, + elementsMap: ElementsMap, ): void => { if (bindableElement !== "keep") { if (bindableElement != null) { @@ -127,7 +132,12 @@ const bindOrUnbindLinearElementEdge = ( : startOrEnd === "start" || otherEdgeBindableElement.id !== bindableElement.id) ) { - bindLinearElement(linearElement, bindableElement, startOrEnd); + bindLinearElement( + linearElement, + bindableElement, + startOrEnd, + elementsMap, + ); boundToElementIds.add(bindableElement.id); } } else { @@ -140,23 +150,34 @@ const bindOrUnbindLinearElementEdge = ( }; export const bindOrUnbindSelectedElements = ( - elements: NonDeleted[], + selectedElements: NonDeleted[], + elementsMap: ElementsMap, ): void => { - elements.forEach((element) => { - if (isBindingElement(element)) { + selectedElements.forEach((selectedElement) => { + if (isBindingElement(selectedElement)) { bindOrUnbindLinearElement( - element, - getElligibleElementForBindingElement(element, "start"), - getElligibleElementForBindingElement(element, "end"), + selectedElement, + getElligibleElementForBindingElement( + selectedElement, + "start", + elementsMap, + ), + getElligibleElementForBindingElement( + selectedElement, + "end", + elementsMap, + ), + elementsMap, ); - } else if (isBindableElement(element)) { - maybeBindBindableElement(element); + } else if (isBindableElement(selectedElement)) { + maybeBindBindableElement(selectedElement, elementsMap); } }); }; const maybeBindBindableElement = ( bindableElement: NonDeleted, + elementsMap: ElementsMap, ): void => { getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( ([linearElement, where]) => @@ -164,6 +185,7 @@ const maybeBindBindableElement = ( linearElement, where === "end" ? "keep" : bindableElement, where === "start" ? "keep" : bindableElement, + elementsMap, ), ); }; @@ -173,9 +195,15 @@ export const maybeBindLinearElement = ( appState: AppState, scene: Scene, pointerCoords: { x: number; y: number }, + elementsMap: ElementsMap, ): void => { if (appState.startBoundElement != null) { - bindLinearElement(linearElement, appState.startBoundElement, "start"); + bindLinearElement( + linearElement, + appState.startBoundElement, + "start", + elementsMap, + ); } const hoveredElement = getHoveredElementForBinding(pointerCoords, scene); if ( @@ -186,7 +214,7 @@ export const maybeBindLinearElement = ( "end", ) ) { - bindLinearElement(linearElement, hoveredElement, "end"); + bindLinearElement(linearElement, hoveredElement, "end", elementsMap); } }; @@ -194,11 +222,17 @@ export const bindLinearElement = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): void => { mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: { elementId: hoveredElement.id, - ...calculateFocusAndGap(linearElement, hoveredElement, startOrEnd), + ...calculateFocusAndGap( + linearElement, + hoveredElement, + startOrEnd, + elementsMap, + ), } as PointBinding, }); @@ -240,10 +274,11 @@ export const isLinearElementSimpleAndAlreadyBound = ( export const unbindLinearElements = ( elements: NonDeleted[], + elementsMap: ElementsMap, ): void => { elements.forEach((element) => { if (isBindingElement(element)) { - bindOrUnbindLinearElement(element, null, null); + bindOrUnbindLinearElement(element, null, null, elementsMap); } }); }; @@ -272,7 +307,11 @@ export const getHoveredElementForBinding = ( scene.getNonDeletedElements(), (element) => isBindableElement(element, false) && - bindingBorderTest(element, pointerCoords), + bindingBorderTest( + element, + pointerCoords, + scene.getNonDeletedElementsMap(), + ), ); return hoveredElement as NonDeleted | null; }; @@ -281,21 +320,33 @@ const calculateFocusAndGap = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): { focus: number; gap: number } => { const direction = startOrEnd === "start" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const adjacentPointIndex = edgePointIndex - direction; + const edgePoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, edgePointIndex, + elementsMap, ); const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, adjacentPointIndex, + elementsMap, ); return { - focus: determineFocusDistance(hoveredElement, adjacentPoint, edgePoint), - gap: Math.max(1, distanceToBindableElement(hoveredElement, edgePoint)), + focus: determineFocusDistance( + hoveredElement, + adjacentPoint, + edgePoint, + elementsMap, + ), + gap: Math.max( + 1, + distanceToBindableElement(hoveredElement, edgePoint, elementsMap), + ), }; }; @@ -306,6 +357,8 @@ const calculateFocusAndGap = ( // in explicitly. export const updateBoundElements = ( changedElement: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + options?: { simultaneouslyUpdated?: readonly ExcalidrawElement[]; newSize?: { width: number; height: number }; @@ -355,12 +408,14 @@ export const updateBoundElements = ( "start", startBinding, changedElement as ExcalidrawBindableElement, + elementsMap, ); updateBoundPoint( element, "end", endBinding, changedElement as ExcalidrawBindableElement, + elementsMap, ); const boundText = getBoundTextElement( element, @@ -393,6 +448,7 @@ const updateBoundPoint = ( startOrEnd: "start" | "end", binding: PointBinding | null | undefined, changedElement: ExcalidrawBindableElement, + elementsMap: ElementsMap, ): void => { if ( binding == null || @@ -414,11 +470,13 @@ const updateBoundPoint = ( const adjacentPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates( linearElement, adjacentPointIndex, + elementsMap, ); const focusPointAbsolute = determineFocusPoint( bindingElement, binding.focus, adjacentPoint, + elementsMap, ); let newEdgePoint; // The linear element was not originally pointing inside the bound shape, @@ -431,6 +489,7 @@ const updateBoundPoint = ( adjacentPoint, focusPointAbsolute, binding.gap, + elementsMap, ); if (intersections.length === 0) { // This should never happen, since focusPoint should always be @@ -449,6 +508,7 @@ const updateBoundPoint = ( point: LinearElementEditor.pointFromAbsoluteCoords( linearElement, newEdgePoint, + elementsMap, ), }, ], @@ -480,12 +540,14 @@ const maybeCalculateNewGapWhenScaling = ( // TODO: this is a bottleneck, optimise export const getEligibleElementsForBinding = ( elements: NonDeleted[], + elementsMap: ElementsMap, ): SuggestedBinding[] => { const includedElementIds = new Set(elements.map(({ id }) => id)); return elements.flatMap((element) => isBindingElement(element, false) ? (getElligibleElementsForBindingElement( element as NonDeleted, + elementsMap, ).filter( (element) => !includedElementIds.has(element.id), ) as SuggestedBinding[]) @@ -499,10 +561,11 @@ export const getEligibleElementsForBinding = ( const getElligibleElementsForBindingElement = ( linearElement: NonDeleted, + elementsMap: ElementsMap, ): NonDeleted[] => { return [ - getElligibleElementForBindingElement(linearElement, "start"), - getElligibleElementForBindingElement(linearElement, "end"), + getElligibleElementForBindingElement(linearElement, "start", elementsMap), + getElligibleElementForBindingElement(linearElement, "end", elementsMap), ].filter( (element): element is NonDeleted => element != null, @@ -512,9 +575,10 @@ const getElligibleElementsForBindingElement = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): NonDeleted | null => { return getHoveredElementForBinding( - getLinearElementEdgeCoors(linearElement, startOrEnd), + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), Scene.getScene(linearElement)!, ); }; @@ -522,17 +586,23 @@ const getElligibleElementForBindingElement = ( const getLinearElementEdgeCoors = ( linearElement: NonDeleted, startOrEnd: "start" | "end", + elementsMap: ElementsMap, ): { x: number; y: number } => { const index = startOrEnd === "start" ? 0 : -1; return tupleToCoors( - LinearElementEditor.getPointAtIndexGlobalCoordinates(linearElement, index), + LinearElementEditor.getPointAtIndexGlobalCoordinates( + linearElement, + index, + elementsMap, + ), ); }; const getElligibleElementsForBindableElementAndWhere = ( bindableElement: NonDeleted, ): SuggestedPointBinding[] => { - return Scene.getScene(bindableElement)! + const scene = Scene.getScene(bindableElement)!; + return scene .getNonDeletedElements() .map((element) => { if (!isBindingElement(element, false)) { @@ -542,11 +612,13 @@ const getElligibleElementsForBindableElementAndWhere = ( element, "start", bindableElement, + scene.getNonDeletedElementsMap(), ); const canBindEnd = isLinearElementEligibleForNewBindingByBindable( element, "end", bindableElement, + scene.getNonDeletedElementsMap(), ); if (!canBindStart && !canBindEnd) { return null; @@ -564,6 +636,7 @@ const isLinearElementEligibleForNewBindingByBindable = ( linearElement: NonDeleted, startOrEnd: "start" | "end", bindableElement: NonDeleted, + elementsMap: ElementsMap, ): boolean => { const existingBinding = linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"]; @@ -576,7 +649,8 @@ const isLinearElementEligibleForNewBindingByBindable = ( ) && bindingBorderTest( bindableElement, - getLinearElementEdgeCoors(linearElement, startOrEnd), + getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), + elementsMap, ) ); }; diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 850c50654..253137b07 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -1,4 +1,5 @@ import { ROUNDNESS } from "../constants"; +import { arrayToMap } from "../utils"; import { getElementAbsoluteCoords, getElementBounds } from "./bounds"; import { ExcalidrawElement, ExcalidrawLinearElement } from "./types"; @@ -35,26 +36,26 @@ const _ce = ({ describe("getElementAbsoluteCoords", () => { it("test x1 coordinate", () => { - const [x1] = getElementAbsoluteCoords(_ce({ x: 10, y: 0, w: 10, h: 0 })); + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [x1] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(x1).toEqual(10); }); it("test x2 coordinate", () => { - const [, , x2] = getElementAbsoluteCoords( - _ce({ x: 10, y: 0, w: 10, h: 0 }), - ); + const element = _ce({ x: 10, y: 20, w: 10, h: 0 }); + const [, , x2] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(x2).toEqual(20); }); it("test y1 coordinate", () => { - const [, y1] = getElementAbsoluteCoords(_ce({ x: 0, y: 10, w: 0, h: 10 })); + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, y1] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(y1).toEqual(10); }); it("test y2 coordinate", () => { - const [, , , y2] = getElementAbsoluteCoords( - _ce({ x: 0, y: 10, w: 0, h: 10 }), - ); + const element = _ce({ x: 0, y: 10, w: 0, h: 10 }); + const [, , , y2] = getElementAbsoluteCoords(element, arrayToMap([element])); expect(y2).toEqual(20); }); }); diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index f892089f7..7eb7fa48a 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -102,8 +102,10 @@ export class ElementBounds { ): Bounds { let bounds: Bounds; - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); - + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); if (isFreeDrawElement(element)) { const [minX, minY, maxX, maxY] = getBoundsFromPoints( element.points.map(([x, y]) => @@ -159,10 +161,9 @@ export class ElementBounds { // This set of functions retrieves the absolute position of the 4 points. export const getElementAbsoluteCoords = ( element: ExcalidrawElement, + elementsMap: ElementsMap, includeBoundText: boolean = false, ): [number, number, number, number, number, number] => { - const elementsMap = - Scene.getScene(element)?.getElementsMapIncludingDeleted() || new Map(); if (isFreeDrawElement(element)) { return getFreeDrawElementAbsoluteCoords(element); } else if (isLinearElement(element)) { @@ -179,6 +180,7 @@ export const getElementAbsoluteCoords = ( const coords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); return [ coords.x, @@ -207,8 +209,12 @@ export const getElementAbsoluteCoords = ( */ export const getElementLineSegments = ( element: ExcalidrawElement, + elementsMap: ElementsMap, ): [Point, Point][] => { - const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); const center: Point = [cx, cy]; @@ -703,6 +709,7 @@ const getLinearElementRotatedBounds = ( if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, [x, y, x, y], boundTextElement, ); @@ -727,6 +734,7 @@ const getLinearElementRotatedBounds = ( if (boundTextElement) { const coordsWithBoundText = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, coords, boundTextElement, ); diff --git a/packages/excalidraw/element/collision.ts b/packages/excalidraw/element/collision.ts index b8c07e3ab..ff5a139de 100644 --- a/packages/excalidraw/element/collision.ts +++ b/packages/excalidraw/element/collision.ts @@ -91,6 +91,7 @@ export const hitTest = ( ) { return isPointHittingElementBoundingBox( element, + elementsMap, point, threshold, frameNameBoundsCache, @@ -116,6 +117,7 @@ export const hitTest = ( appState, frameNameBoundsCache, point, + elementsMap, ); }; @@ -145,9 +147,11 @@ export const isHittingElementBoundingBoxWithoutHittingElement = ( appState, frameNameBoundsCache, [x, y], + elementsMap, ) && isPointHittingElementBoundingBox( element, + elementsMap, [x, y], threshold, frameNameBoundsCache, @@ -160,6 +164,7 @@ export const isHittingElementNotConsideringBoundingBox = ( appState: AppState, frameNameBoundsCache: FrameNameBoundsCache | null, point: Point, + elementsMap: ElementsMap, ): boolean => { const threshold = 10 / appState.zoom.value; const check = isTextElement(element) @@ -169,6 +174,7 @@ export const isHittingElementNotConsideringBoundingBox = ( : isNearCheck; return hitTestPointAgainstElement({ element, + elementsMap, point, threshold, check, @@ -183,6 +189,7 @@ const isElementSelected = ( export const isPointHittingElementBoundingBox = ( element: NonDeleted, + elementsMap: ElementsMap, [x, y]: Point, threshold: number, frameNameBoundsCache: FrameNameBoundsCache | null, @@ -194,6 +201,7 @@ export const isPointHittingElementBoundingBox = ( if (isFrameLikeElement(element)) { return hitTestPointAgainstElement({ element, + elementsMap, point: [x, y], threshold, check: isInsideCheck, @@ -201,7 +209,7 @@ export const isPointHittingElementBoundingBox = ( }); } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const elementCenterX = (x1 + x2) / 2; const elementCenterY = (y1 + y2) / 2; // reverse rotate to take element's angle into account. @@ -224,12 +232,14 @@ export const isPointHittingElementBoundingBox = ( export const bindingBorderTest = ( element: NonDeleted, { x, y }: { x: number; y: number }, + elementsMap: ElementsMap, ): boolean => { const threshold = maxBindingGap(element, element.width, element.height); const check = isOutsideCheck; const point: Point = [x, y]; return hitTestPointAgainstElement({ element, + elementsMap, point, threshold, check, @@ -251,6 +261,7 @@ export const maxBindingGap = ( type HitTestArgs = { element: NonDeletedExcalidrawElement; + elementsMap: ElementsMap; point: Point; threshold: number; check: (distance: number, threshold: number) => boolean; @@ -266,19 +277,28 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { case "text": case "diamond": case "ellipse": - const distance = distanceToBindableElement(args.element, args.point); + const distance = distanceToBindableElement( + args.element, + args.point, + args.elementsMap, + ); return args.check(distance, args.threshold); case "freedraw": { if ( !args.check( - distanceToRectangle(args.element, args.point), + distanceToRectangle(args.element, args.point, args.elementsMap), args.threshold, ) ) { return false; } - return hitTestFreeDrawElement(args.element, args.point, args.threshold); + return hitTestFreeDrawElement( + args.element, + args.point, + args.threshold, + args.elementsMap, + ); } case "arrow": case "line": @@ -293,7 +313,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { // check distance to frame element first if ( args.check( - distanceToBindableElement(args.element, args.point), + distanceToBindableElement(args.element, args.point, args.elementsMap), args.threshold, ) ) { @@ -316,6 +336,7 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => { export const distanceToBindableElement = ( element: ExcalidrawBindableElement, point: Point, + elementsMap: ElementsMap, ): number => { switch (element.type) { case "rectangle": @@ -325,11 +346,11 @@ export const distanceToBindableElement = ( case "embeddable": case "frame": case "magicframe": - return distanceToRectangle(element, point); + return distanceToRectangle(element, point, elementsMap); case "diamond": - return distanceToDiamond(element, point); + return distanceToDiamond(element, point, elementsMap); case "ellipse": - return distanceToEllipse(element, point); + return distanceToEllipse(element, point, elementsMap); } }; @@ -358,8 +379,13 @@ const distanceToRectangle = ( | ExcalidrawIframeLikeElement | ExcalidrawFrameLikeElement, point: Point, + elementsMap: ElementsMap, ): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); return Math.max( GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)), GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)), @@ -377,8 +403,13 @@ const distanceToRectangleBox = (box: RectangleBox, point: Point): number => { const distanceToDiamond = ( element: ExcalidrawDiamondElement, point: Point, + elementsMap: ElementsMap, ): number => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); const side = GALine.equation(hheight, hwidth, -hheight * hwidth); return GAPoint.distanceToLine(pointRel, side); }; @@ -386,16 +417,22 @@ const distanceToDiamond = ( const distanceToEllipse = ( element: ExcalidrawEllipseElement, point: Point, + elementsMap: ElementsMap, ): number => { - const [pointRel, tangent] = ellipseParamsForTest(element, point); + const [pointRel, tangent] = ellipseParamsForTest(element, point, elementsMap); return -GALine.sign(tangent) * GAPoint.distanceToLine(pointRel, tangent); }; const ellipseParamsForTest = ( element: ExcalidrawEllipseElement, point: Point, + elementsMap: ElementsMap, ): [GA.Point, GA.Line] => { - const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point); + const [, pointRel, hwidth, hheight] = pointRelativeToElement( + element, + point, + elementsMap, + ); const [px, py] = GAPoint.toTuple(pointRel); // We're working in positive quadrant, so start with `t = 45deg`, `tx=cos(t)` @@ -440,6 +477,7 @@ const hitTestFreeDrawElement = ( element: ExcalidrawFreeDrawElement, point: Point, threshold: number, + elementsMap: ElementsMap, ): boolean => { // Check point-distance-to-line-segment for every segment in the // element's points (its input points, not its outline points). @@ -454,7 +492,10 @@ const hitTestFreeDrawElement = ( y = point[1] - element.y; } else { // Counter-rotate the point around center before testing - const [minX, minY, maxX, maxY] = getElementAbsoluteCoords(element); + const [minX, minY, maxX, maxY] = getElementAbsoluteCoords( + element, + elementsMap, + ); const rotatedPoint = rotatePoint( point, [minX + (maxX - minX) / 2, minY + (maxY - minY) / 2], @@ -520,6 +561,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => { const [point, pointAbs, hwidth, hheight] = pointRelativeToElement( args.element, args.point, + args.elementsMap, ); const side1 = GALine.equation(0, 1, -hheight); const side2 = GALine.equation(1, 0, -hwidth); @@ -572,9 +614,10 @@ const hitTestLinear = (args: HitTestArgs): boolean => { const pointRelativeToElement = ( element: ExcalidrawElement, pointTuple: Point, + elementsMap: ElementsMap, ): [GA.Point, GA.Point, number, number] => { const point = GAPoint.from(pointTuple); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); // GA has angle orientation opposite to `rotate` const rotate = GATransform.rotation(center, element.angle); @@ -609,11 +652,12 @@ const pointRelativeToDivElement = ( // Returns point in absolute coordinates export const pointInAbsoluteCoords = ( element: ExcalidrawElement, + elementsMap: ElementsMap, // Point relative to the element position point: Point, ): Point => { const [x, y] = point; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x2 - x1) / 2; const cy = (y2 - y1) / 2; const [rotatedX, rotatedY] = rotate(x, y, cx, cy, element.angle); @@ -622,8 +666,9 @@ export const pointInAbsoluteCoords = ( const relativizationToElementCenter = ( element: ExcalidrawElement, + elementsMap: ElementsMap, ): GA.Transform => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); // GA has angle orientation opposite to `rotate` const rotate = GATransform.rotation(center, element.angle); @@ -649,12 +694,14 @@ const coordsCenter = ( // of the element. export const determineFocusDistance = ( element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates a: Point, // Another point on the line, in absolute coordinates (closer to element) b: Point, + elementsMap: ElementsMap, ): number => { - const relateToCenter = relativizationToElementCenter(element); + const relateToCenter = relativizationToElementCenter(element, elementsMap); const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); const line = GALine.through(aRel, bRel); @@ -693,13 +740,14 @@ export const determineFocusPoint = ( // returned focusPoint focus: number, adjecentPoint: Point, + elementsMap: ElementsMap, ): Point => { if (focus === 0) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const center = coordsCenter(x1, y1, x2, y2); return GAPoint.toTuple(center); } - const relateToCenter = relativizationToElementCenter(element); + const relateToCenter = relativizationToElementCenter(element, elementsMap); const adjecentPointRel = GATransform.apply( relateToCenter, GAPoint.from(adjecentPoint), @@ -728,14 +776,16 @@ export const determineFocusPoint = ( // and the `element`, in ascending order of distance from `a`. export const intersectElementWithLine = ( element: ExcalidrawBindableElement, + // Point on the line, in absolute coordinates a: Point, // Another point on the line, in absolute coordinates b: Point, // If given, the element is inflated by this value gap: number = 0, + elementsMap: ElementsMap, ): Point[] => { - const relateToCenter = relativizationToElementCenter(element); + const relateToCenter = relativizationToElementCenter(element, elementsMap); const aRel = GATransform.apply(relateToCenter, GAPoint.from(a)); const bRel = GATransform.apply(relateToCenter, GAPoint.from(b)); const line = GALine.through(aRel, bRel); diff --git a/packages/excalidraw/element/dragElements.ts b/packages/excalidraw/element/dragElements.ts index 0144f55a4..5121f52bd 100644 --- a/packages/excalidraw/element/dragElements.ts +++ b/packages/excalidraw/element/dragElements.ts @@ -65,7 +65,7 @@ export const dragSelectedElements = ( updateElementCoords(pointerDownState, textElement, adjustedOffset); } } - updateBoundElements(element, { + updateBoundElements(element, scene.getElementsMapIncludingDeleted(), { simultaneouslyUpdated: Array.from(elementsToUpdate), }); }); diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 5c3c6acaa..85483b3d7 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -135,6 +135,7 @@ export class LinearElementEditor { event: PointerEvent, appState: AppState, setState: React.Component["setState"], + elementsMap: ElementsMap, ) { if ( !appState.editingLinearElement || @@ -151,10 +152,12 @@ export class LinearElementEditor { } const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(appState.draggingElement); + getElementAbsoluteCoords(appState.draggingElement, elementsMap); - const pointsSceneCoords = - LinearElementEditor.getPointsGlobalCoordinates(element); + const pointsSceneCoords = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); const nextSelectedPoints = pointsSceneCoords.reduce( (acc: number[], point, index) => { @@ -222,6 +225,7 @@ export class LinearElementEditor { const [width, height] = LinearElementEditor._getShiftLockedDelta( element, + elementsMap, referencePoint, [scenePointerX, scenePointerY], event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -239,6 +243,7 @@ export class LinearElementEditor { } else { const newDraggingPointPosition = LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -255,6 +260,7 @@ export class LinearElementEditor { linearElementEditor.pointerDownState.lastClickedPoint ? LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - linearElementEditor.pointerOffset.x, scenePointerY - linearElementEditor.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -290,6 +296,7 @@ export class LinearElementEditor { LinearElementEditor.getPointGlobalCoordinates( element, element.points[0], + elementsMap, ), ), ); @@ -303,6 +310,7 @@ export class LinearElementEditor { LinearElementEditor.getPointGlobalCoordinates( element, element.points[lastSelectedIndex], + elementsMap, ), ), ); @@ -323,6 +331,7 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, + elementsMap: ElementsMap, ): LinearElementEditor { const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; @@ -364,6 +373,7 @@ export class LinearElementEditor { LinearElementEditor.getPointAtIndexGlobalCoordinates( element, selectedPoint!, + elementsMap, ), ), Scene.getScene(element)!, @@ -425,15 +435,23 @@ export class LinearElementEditor { ) { return editorMidPointsCache.points; } - LinearElementEditor.updateEditorMidPointsCache(element, appState); + LinearElementEditor.updateEditorMidPointsCache( + element, + elementsMap, + appState, + ); return editorMidPointsCache.points!; }; static updateEditorMidPointsCache = ( element: NonDeleted, + elementsMap: ElementsMap, appState: InteractiveCanvasAppState, ) => { - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); let index = 0; const midpoints: (Point | null)[] = []; @@ -455,6 +473,7 @@ export class LinearElementEditor { points[index], points[index + 1], index + 1, + elementsMap, ); midpoints.push(segmentMidPoint); index++; @@ -477,6 +496,7 @@ export class LinearElementEditor { } const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, appState.zoom, scenePointer.x, scenePointer.y, @@ -484,7 +504,10 @@ export class LinearElementEditor { if (clickedPointIndex >= 0) { return null; } - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); if (points.length >= 3 && !appState.editingLinearElement) { return null; } @@ -550,6 +573,7 @@ export class LinearElementEditor { startPoint: Point, endPoint: Point, endPointIndex: number, + elementsMap: ElementsMap, ) { let segmentMidPoint = centerPoint(startPoint, endPoint); if (element.points.length > 2 && element.roundness) { @@ -574,6 +598,7 @@ export class LinearElementEditor { segmentMidPoint = LinearElementEditor.getPointGlobalCoordinates( element, [tx, ty], + elementsMap, ); } } @@ -658,6 +683,7 @@ export class LinearElementEditor { ...element.points, LinearElementEditor.createPointAt( element, + elementsMap, scenePointer.x, scenePointer.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -693,6 +719,7 @@ export class LinearElementEditor { const clickedPointIndex = LinearElementEditor.getPointIndexUnderCursor( element, + elementsMap, appState.zoom, scenePointer.x, scenePointer.y, @@ -713,11 +740,12 @@ export class LinearElementEditor { element, startBindingElement, endBindingElement, + elementsMap, ); } } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const targetPoint = @@ -779,6 +807,7 @@ export class LinearElementEditor { scenePointerX: number, scenePointerY: number, appState: AppState, + elementsMap: ElementsMap, ): LinearElementEditor | null { if (!appState.editingLinearElement) { return null; @@ -809,6 +838,7 @@ export class LinearElementEditor { const [width, height] = LinearElementEditor._getShiftLockedDelta( element, + elementsMap, lastCommittedPoint, [scenePointerX, scenePointerY], event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -821,6 +851,7 @@ export class LinearElementEditor { } else { newPoint = LinearElementEditor.createPointAt( element, + elementsMap, scenePointerX - appState.editingLinearElement.pointerOffset.x, scenePointerY - appState.editingLinearElement.pointerOffset.y, event[KEYS.CTRL_OR_CMD] ? null : appState.gridSize, @@ -847,8 +878,9 @@ export class LinearElementEditor { static getPointGlobalCoordinates( element: NonDeleted, point: Point, + elementsMap: ElementsMap, ) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -860,8 +892,9 @@ export class LinearElementEditor { /** scene coords */ static getPointsGlobalCoordinates( element: NonDeleted, + elementsMap: ElementsMap, ): Point[] { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; return element.points.map((point) => { @@ -873,13 +906,15 @@ export class LinearElementEditor { static getPointAtIndexGlobalCoordinates( element: NonDeleted, + indexMaybeFromEnd: number, // -1 for last element + elementsMap: ElementsMap, ): Point { const index = indexMaybeFromEnd < 0 ? element.points.length + indexMaybeFromEnd : indexMaybeFromEnd; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; @@ -893,8 +928,9 @@ export class LinearElementEditor { static pointFromAbsoluteCoords( element: NonDeleted, absoluteCoords: Point, + elementsMap: ElementsMap, ): Point { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const [x, y] = rotate( @@ -909,12 +945,15 @@ export class LinearElementEditor { static getPointIndexUnderCursor( element: NonDeleted, + elementsMap: ElementsMap, zoom: AppState["zoom"], x: number, y: number, ) { - const pointHandles = - LinearElementEditor.getPointsGlobalCoordinates(element); + const pointHandles = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); let idx = pointHandles.length; // loop from right to left because points on the right are rendered over // points on the left, thus should take precedence when clicking, if they @@ -934,12 +973,13 @@ export class LinearElementEditor { static createPointAt( element: NonDeleted, + elementsMap: ElementsMap, scenePointerX: number, scenePointerY: number, gridSize: number | null, ): Point { const pointerOnGrid = getGridPoint(scenePointerX, scenePointerY, gridSize); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const [rotatedX, rotatedY] = rotate( @@ -1190,6 +1230,7 @@ export class LinearElementEditor { pointerCoords: PointerCoords, appState: AppState, snapToGrid: boolean, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, @@ -1208,6 +1249,7 @@ export class LinearElementEditor { const midpoint = LinearElementEditor.createPointAt( element, + elementsMap, pointerCoords.x, pointerCoords.y, snapToGrid ? appState.gridSize : null, @@ -1260,6 +1302,7 @@ export class LinearElementEditor { private static _getShiftLockedDelta( element: NonDeleted, + elementsMap: ElementsMap, referencePoint: Point, scenePointer: Point, gridSize: number | null, @@ -1267,6 +1310,7 @@ export class LinearElementEditor { const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates( element, referencePoint, + elementsMap, ); const [gridX, gridY] = getGridPoint( @@ -1288,8 +1332,12 @@ export class LinearElementEditor { static getBoundTextElementPosition = ( element: ExcalidrawLinearElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ): { x: number; y: number } => { - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); if (points.length < 2) { mutateElement(boundTextElement, { isDeleted: true }); } @@ -1300,6 +1348,7 @@ export class LinearElementEditor { const midPoint = LinearElementEditor.getPointGlobalCoordinates( element, element.points[index], + elementsMap, ); x = midPoint[0] - boundTextElement.width / 2; y = midPoint[1] - boundTextElement.height / 2; @@ -1319,6 +1368,7 @@ export class LinearElementEditor { points[index], points[index + 1], index + 1, + elementsMap, ); } x = midSegmentMidpoint[0] - boundTextElement.width / 2; @@ -1329,6 +1379,7 @@ export class LinearElementEditor { static getMinMaxXYWithBoundText = ( element: ExcalidrawLinearElement, + elementsMap: ElementsMap, elementBounds: Bounds, boundTextElement: ExcalidrawTextElementWithContainer, ): [number, number, number, number, number, number] => { @@ -1339,6 +1390,7 @@ export class LinearElementEditor { LinearElementEditor.getBoundTextElementPosition( element, boundTextElement, + elementsMap, ); const boundTextX2 = boundTextX1 + boundTextElement.width; const boundTextY2 = boundTextY1 + boundTextElement.height; @@ -1479,6 +1531,7 @@ export class LinearElementEditor { if (boundTextElement) { coords = LinearElementEditor.getMinMaxXYWithBoundText( element, + elementsMap, [x1, y1, x2, y2], boundTextElement, ); diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index f1e0d8093..076f64722 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -16,6 +16,7 @@ import { ExcalidrawEmbeddableElement, ExcalidrawMagicFrameElement, ExcalidrawIframeElement, + ElementsMap, } from "./types"; import { arrayToMap, @@ -260,6 +261,7 @@ export const newTextElement = ( const getAdjustedDimensions = ( element: ExcalidrawTextElement, + elementsMap: ElementsMap, nextText: string, ): { x: number; @@ -294,7 +296,7 @@ const getAdjustedDimensions = ( x = element.x - offsets.x; y = element.y - offsets.y; } else { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [nextX1, nextY1, nextX2, nextY2] = getResizedElementAbsoluteCoords( element, @@ -335,6 +337,7 @@ const getAdjustedDimensions = ( export const refreshTextDimensions = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, text = textElement.text, ) => { if (textElement.isDeleted) { @@ -347,13 +350,14 @@ export const refreshTextDimensions = ( getBoundTextMaxWidth(container, textElement), ); } - const dimensions = getAdjustedDimensions(textElement, text); + const dimensions = getAdjustedDimensions(textElement, elementsMap, text); return { text, ...dimensions }; }; export const updateTextElement = ( textElement: ExcalidrawTextElement, container: ExcalidrawTextContainer | null, + elementsMap: ElementsMap, { text, isDeleted, @@ -367,7 +371,7 @@ export const updateTextElement = ( return newElementWith(textElement, { originalText, isDeleted: isDeleted ?? textElement.isDeleted, - ...refreshTextDimensions(textElement, container, originalText), + ...refreshTextDimensions(textElement, container, elementsMap, originalText), }); }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index deb5fead3..49724d9eb 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -86,11 +86,12 @@ export const transformElements = ( if (transformHandleType === "rotation") { rotateSingleElement( element, + elementsMap, pointerX, pointerY, shouldRotateWithDiscreteAngle, ); - updateBoundElements(element); + updateBoundElements(element, elementsMap); } else if ( isTextElement(element) && (transformHandleType === "nw" || @@ -106,7 +107,7 @@ export const transformElements = ( pointerX, pointerY, ); - updateBoundElements(element); + updateBoundElements(element, elementsMap); } else if (transformHandleType) { resizeSingleElement( originalElements, @@ -157,11 +158,12 @@ export const transformElements = ( const rotateSingleElement = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, pointerX: number, pointerY: number, shouldRotateWithDiscreteAngle: boolean, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; let angle: number; @@ -266,7 +268,7 @@ const resizeSingleTextElement = ( pointerX: number, pointerY: number, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; // rotation pointer with reverse angle @@ -629,7 +631,7 @@ export const resizeSingleElement = ( ) { mutateElement(element, resizedElement); - updateBoundElements(element, { + updateBoundElements(element, elementsMap, { newSize: { width: resizedElement.width, height: resizedElement.height }, }); @@ -696,7 +698,11 @@ export const resizeMultipleElements = ( if (!isBoundToContainer(text)) { return acc; } - const xy = LinearElementEditor.getBoundTextElementPosition(orig, text); + const xy = LinearElementEditor.getBoundTextElementPosition( + orig, + text, + elementsMap, + ); return [...acc, { ...text, ...xy }]; }, [] as ExcalidrawTextElementWithContainer[]); @@ -879,7 +885,7 @@ export const resizeMultipleElements = ( mutateElement(element, update, false); - updateBoundElements(element, { + updateBoundElements(element, elementsMap, { simultaneouslyUpdated: elementsToUpdate, newSize: { width, height }, }); @@ -921,7 +927,7 @@ const rotateMultipleElements = ( elements .filter((element) => !isFrameLikeElement(element)) .forEach((element) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; const origAngle = @@ -942,7 +948,9 @@ const rotateMultipleElements = ( }, false, ); - updateBoundElements(element, { simultaneouslyUpdated: elements }); + updateBoundElements(element, elementsMap, { + simultaneouslyUpdated: elements, + }); const boundText = getBoundTextElement(element, elementsMap); if (boundText && !isArrowElement(element)) { @@ -964,12 +972,13 @@ const rotateMultipleElements = ( export const getResizeOffsetXY = ( transformHandleType: MaybeTransformHandleType, selectedElements: NonDeletedExcalidrawElement[], + elementsMap: ElementsMap, x: number, y: number, ): [number, number] => { const [x1, y1, x2, y2] = selectedElements.length === 1 - ? getElementAbsoluteCoords(selectedElements[0]) + ? getElementAbsoluteCoords(selectedElements[0], elementsMap) : getCommonBounds(selectedElements); const cx = (x1 + x2) / 2; const cy = (y1 + y2) / 2; diff --git a/packages/excalidraw/element/resizeTest.ts b/packages/excalidraw/element/resizeTest.ts index 9947a6082..2e01f94d9 100644 --- a/packages/excalidraw/element/resizeTest.ts +++ b/packages/excalidraw/element/resizeTest.ts @@ -2,6 +2,7 @@ import { ExcalidrawElement, PointerType, NonDeletedExcalidrawElement, + ElementsMap, } from "./types"; import { @@ -27,6 +28,7 @@ const isInsideTransformHandle = ( export const resizeTest = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, appState: AppState, x: number, y: number, @@ -38,7 +40,7 @@ export const resizeTest = ( } const { rotation: rotationTransformHandle, ...transformHandles } = - getTransformHandles(element, zoom, pointerType); + getTransformHandles(element, zoom, elementsMap, pointerType); if ( rotationTransformHandle && @@ -70,6 +72,7 @@ export const getElementWithTransformHandleType = ( scenePointerY: number, zoom: Zoom, pointerType: PointerType, + elementsMap: ElementsMap, ) => { return elements.reduce((result, element) => { if (result) { @@ -77,6 +80,7 @@ export const getElementWithTransformHandleType = ( } const transformHandleType = resizeTest( element, + elementsMap, appState, scenePointerX, scenePointerY, diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index fc4c15f2d..4aa0868d7 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -53,6 +53,7 @@ const splitIntoLines = (text: string) => { export const redrawTextBoundingBox = ( textElement: ExcalidrawTextElement, container: ExcalidrawElement | null, + elementsMap: ElementsMap, ) => { let maxWidth = undefined; const boundTextUpdates = { @@ -110,7 +111,11 @@ export const redrawTextBoundingBox = ( ...textElement, ...boundTextUpdates, } as ExcalidrawTextElementWithContainer; - const { x, y } = computeBoundTextPosition(container, updatedTextElement); + const { x, y } = computeBoundTextPosition( + container, + updatedTextElement, + elementsMap, + ); boundTextUpdates.x = x; boundTextUpdates.y = y; } @@ -119,11 +124,11 @@ export const redrawTextBoundingBox = ( }; export const bindTextToShapeAfterDuplication = ( - sceneElements: ExcalidrawElement[], + newElements: ExcalidrawElement[], oldElements: ExcalidrawElement[], oldIdToDuplicatedId: Map, ): void => { - const sceneElementMap = arrayToMap(sceneElements) as Map< + const newElementsMap = arrayToMap(newElements) as Map< ExcalidrawElement["id"], ExcalidrawElement >; @@ -134,7 +139,7 @@ export const bindTextToShapeAfterDuplication = ( if (boundTextElementId) { const newTextElementId = oldIdToDuplicatedId.get(boundTextElementId); if (newTextElementId) { - const newContainer = sceneElementMap.get(newElementId); + const newContainer = newElementsMap.get(newElementId); if (newContainer) { mutateElement(newContainer, { boundElements: (element.boundElements || []) @@ -149,7 +154,7 @@ export const bindTextToShapeAfterDuplication = ( }), }); } - const newTextElement = sceneElementMap.get(newTextElementId); + const newTextElement = newElementsMap.get(newTextElementId); if (newTextElement && isTextElement(newTextElement)) { mutateElement(newTextElement, { containerId: newContainer ? newElementId : null, @@ -236,7 +241,7 @@ export const handleBindTextResize = ( if (!isArrowElement(container)) { mutateElement( textElement, - computeBoundTextPosition(container, textElement), + computeBoundTextPosition(container, textElement, elementsMap), ); } } @@ -245,11 +250,13 @@ export const handleBindTextResize = ( export const computeBoundTextPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ) => { if (isArrowElement(container)) { return LinearElementEditor.getBoundTextElementPosition( container, boundTextElement, + elementsMap, ); } const containerCoords = getContainerCoords(container); @@ -698,12 +705,16 @@ export const getContainerCenter = ( y: container.y + container.height / 2, }; } - const points = LinearElementEditor.getPointsGlobalCoordinates(container); + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); if (points.length % 2 === 1) { const index = Math.floor(container.points.length / 2); const midPoint = LinearElementEditor.getPointGlobalCoordinates( container, container.points[index], + elementsMap, ); return { x: midPoint[0], y: midPoint[1] }; } @@ -719,6 +730,7 @@ export const getContainerCenter = ( points[index], points[index + 1], index + 1, + elementsMap, ); } return { x: midSegmentMidpoint[0], y: midSegmentMidpoint[1] }; @@ -757,11 +769,13 @@ export const getTextElementAngle = ( export const getBoundTextElementPosition = ( container: ExcalidrawElement, boundTextElement: ExcalidrawTextElementWithContainer, + elementsMap: ElementsMap, ) => { if (isArrowElement(container)) { return LinearElementEditor.getBoundTextElementPosition( container, boundTextElement, + elementsMap, ); } }; @@ -804,6 +818,7 @@ export const getTextBindableContainerAtPosition = ( appState: AppState, x: number, y: number, + elementsMap: ElementsMap, ): ExcalidrawTextContainer | null => { const selectedElements = getSelectedElements(elements, appState); if (selectedElements.length === 1) { @@ -817,7 +832,10 @@ export const getTextBindableContainerAtPosition = ( if (elements[index].isDeleted) { continue; } - const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]); + const [x1, y1, x2, y2] = getElementAbsoluteCoords( + elements[index], + elementsMap, + ); if ( isArrowElement(elements[index]) && isHittingElementNotConsideringBoundingBox( @@ -825,6 +843,7 @@ export const getTextBindableContainerAtPosition = ( appState, null, [x, y], + elementsMap, ) ) { hitElement = elements[index]; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index 1a628dd46..ae30be4e9 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -121,13 +121,13 @@ export const textWysiwyg = ({ return; } const { textAlign, verticalAlign } = updatedTextElement; - + const elementsMap = app.scene.getNonDeletedElementsMap(); if (updatedTextElement && isTextElement(updatedTextElement)) { let coordX = updatedTextElement.x; let coordY = updatedTextElement.y; const container = getContainerElement( updatedTextElement, - app.scene.getElementsMapIncludingDeleted(), + app.scene.getNonDeletedElementsMap(), ); let maxWidth = updatedTextElement.width; @@ -143,6 +143,7 @@ export const textWysiwyg = ({ LinearElementEditor.getBoundTextElementPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, ); coordX = boundTextCoords.x; coordY = boundTextCoords.y; @@ -200,6 +201,7 @@ export const textWysiwyg = ({ const { y } = computeBoundTextPosition( container, updatedTextElement as ExcalidrawTextElementWithContainer, + elementsMap, ); coordY = y; } @@ -326,7 +328,7 @@ export const textWysiwyg = ({ } const container = getContainerElement( element, - app.scene.getElementsMapIncludingDeleted(), + app.scene.getNonDeletedElementsMap(), ); const font = getFontString({ @@ -513,7 +515,7 @@ export const textWysiwyg = ({ let text = editable.value; const container = getContainerElement( updateElement, - app.scene.getElementsMapIncludingDeleted(), + app.scene.getNonDeletedElementsMap(), ); if (container) { @@ -541,7 +543,11 @@ export const textWysiwyg = ({ ), }); } - redrawTextBoundingBox(updateElement, container); + redrawTextBoundingBox( + updateElement, + container, + app.scene.getNonDeletedElementsMap(), + ); } onSubmit({ diff --git a/packages/excalidraw/element/transformHandles.ts b/packages/excalidraw/element/transformHandles.ts index 19c60a93f..aee745530 100644 --- a/packages/excalidraw/element/transformHandles.ts +++ b/packages/excalidraw/element/transformHandles.ts @@ -1,4 +1,5 @@ import { + ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, PointerType, @@ -230,6 +231,8 @@ export const getTransformHandlesFromCoords = ( export const getTransformHandles = ( element: ExcalidrawElement, zoom: Zoom, + elementsMap: ElementsMap, + pointerType: PointerType = "mouse", ): TransformHandles => { // so that when locked element is selected (especially when you toggle lock @@ -267,7 +270,7 @@ export const getTransformHandles = ( ? DEFAULT_TRANSFORM_HANDLE_SPACING + 8 : DEFAULT_TRANSFORM_HANDLE_SPACING; return getTransformHandlesFromCoords( - getElementAbsoluteCoords(element, true), + getElementAbsoluteCoords(element, elementsMap, true), element.angle, zoom, pointerType, diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index c4a5a259d..8f550e86a 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -65,10 +65,11 @@ export const bindElementsToFramesAfterDuplication = ( export function isElementIntersectingFrame( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) { - const frameLineSegments = getElementLineSegments(frame); + const frameLineSegments = getElementLineSegments(frame, elementsMap); - const elementLineSegments = getElementLineSegments(element); + const elementLineSegments = getElementLineSegments(element, elementsMap); const intersecting = frameLineSegments.some((frameLineSegment) => elementLineSegments.some((elementLineSegment) => @@ -82,9 +83,10 @@ export function isElementIntersectingFrame( export const getElementsCompletelyInFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => omitGroupsContainingFrameLikes( - getElementsWithinSelection(elements, frame, false), + getElementsWithinSelection(elements, frame, elementsMap, false), ).filter( (element) => (!isFrameLikeElement(element) && !element.frameId) || @@ -95,8 +97,9 @@ export const isElementContainingFrame = ( elements: readonly ExcalidrawElement[], element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - return getElementsWithinSelection(elements, element).some( + return getElementsWithinSelection(elements, element, elementsMap).some( (e) => e.id === frame.id, ); }; @@ -104,13 +107,22 @@ export const isElementContainingFrame = ( export const getElementsIntersectingFrame = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, -) => elements.filter((element) => isElementIntersectingFrame(element, frame)); +) => { + const elementsMap = arrayToMap(elements); + return elements.filter((element) => + isElementIntersectingFrame(element, frame, elementsMap), + ); +}; export const elementsAreInFrameBounds = ( elements: readonly ExcalidrawElement[], frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { - const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords(frame); + const [frameX1, frameY1, frameX2, frameY2] = getElementAbsoluteCoords( + frame, + elementsMap, + ); const [elementX1, elementY1, elementX2, elementY2] = getCommonBounds(elements); @@ -126,11 +138,12 @@ export const elementsAreInFrameBounds = ( export const elementOverlapsWithFrame = ( element: ExcalidrawElement, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { return ( - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame) || - isElementContainingFrame([frame], element, frame) + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap) || + isElementContainingFrame([frame], element, frame, elementsMap) ); }; @@ -140,8 +153,9 @@ export const isCursorInFrame = ( y: number; }, frame: NonDeleted, + elementsMap: ElementsMap, ) => { - const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame); + const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame, elementsMap); return isPointWithinBounds( [fx1, fy1], @@ -155,6 +169,7 @@ export const groupsAreAtLeastIntersectingTheFrame = ( groupIds: readonly string[], frame: ExcalidrawFrameLikeElement, ) => { + const elementsMap = arrayToMap(elements); const elementsInGroup = groupIds.flatMap((groupId) => getElementsInGroup(elements, groupId), ); @@ -165,8 +180,8 @@ export const groupsAreAtLeastIntersectingTheFrame = ( return !!elementsInGroup.find( (element) => - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame), + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap), ); }; @@ -175,6 +190,7 @@ export const groupsAreCompletelyOutOfFrame = ( groupIds: readonly string[], frame: ExcalidrawFrameLikeElement, ) => { + const elementsMap = arrayToMap(elements); const elementsInGroup = groupIds.flatMap((groupId) => getElementsInGroup(elements, groupId), ); @@ -186,8 +202,8 @@ export const groupsAreCompletelyOutOfFrame = ( return ( elementsInGroup.find( (element) => - elementsAreInFrameBounds([element], frame) || - isElementIntersectingFrame(element, frame), + elementsAreInFrameBounds([element], frame, elementsMap) || + isElementIntersectingFrame(element, frame, elementsMap), ) === undefined ); }; @@ -258,14 +274,15 @@ export const getElementsInResizingFrame = ( allElements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameLikeElement, appState: AppState, + elementsMap: ElementsMap, ): ExcalidrawElement[] => { const prevElementsInFrame = getFrameChildren(allElements, frame.id); const nextElementsInFrame = new Set(prevElementsInFrame); const elementsCompletelyInFrame = new Set([ - ...getElementsCompletelyInFrame(allElements, frame), + ...getElementsCompletelyInFrame(allElements, frame, elementsMap), ...prevElementsInFrame.filter((element) => - isElementContainingFrame(allElements, element, frame), + isElementContainingFrame(allElements, element, frame, elementsMap), ), ]); @@ -283,7 +300,7 @@ export const getElementsInResizingFrame = ( ); for (const element of elementsNotCompletelyInFrame) { - if (!isElementIntersectingFrame(element, frame)) { + if (!isElementIntersectingFrame(element, frame, elementsMap)) { if (element.groupIds.length === 0) { nextElementsInFrame.delete(element); } @@ -334,7 +351,7 @@ export const getElementsInResizingFrame = ( if (isSelected) { const elementsInGroup = getElementsInGroup(allElements, id); - if (elementsAreInFrameBounds(elementsInGroup, frame)) { + if (elementsAreInFrameBounds(elementsInGroup, frame, elementsMap)) { for (const element of elementsInGroup) { nextElementsInFrame.add(element); } @@ -348,12 +365,13 @@ export const getElementsInResizingFrame = ( }; export const getElementsInNewFrame = ( - allElements: ExcalidrawElementsIncludingDeleted, + elements: ExcalidrawElementsIncludingDeleted, frame: ExcalidrawFrameLikeElement, + elementsMap: ElementsMap, ) => { return omitGroupsContainingFrameLikes( - allElements, - getElementsCompletelyInFrame(allElements, frame), + elements, + getElementsCompletelyInFrame(elements, frame, elementsMap), ); }; @@ -388,7 +406,7 @@ export const filterElementsEligibleAsFrameChildren = ( frame: ExcalidrawFrameLikeElement, ) => { const otherFrames = new Set(); - + const elementsMap = arrayToMap(elements); elements = omitGroupsContainingFrameLikes(elements); for (const element of elements) { @@ -415,14 +433,18 @@ export const filterElementsEligibleAsFrameChildren = ( if (!processedGroups.has(shallowestGroupId)) { processedGroups.add(shallowestGroupId); const groupElements = getElementsInGroup(elements, shallowestGroupId); - if (groupElements.some((el) => elementOverlapsWithFrame(el, frame))) { + if ( + groupElements.some((el) => + elementOverlapsWithFrame(el, frame, elementsMap), + ) + ) { for (const child of groupElements) { eligibleElements.push(child); } } } } else { - const overlaps = elementOverlapsWithFrame(element, frame); + const overlaps = elementOverlapsWithFrame(element, frame, elementsMap); if (overlaps) { eligibleElements.push(element); } @@ -682,12 +704,12 @@ export const getTargetFrame = ( // given an element, return if the element is in some frame export const isElementInFrame = ( element: ExcalidrawElement, - allElements: ElementsMap, + allElementsMap: ElementsMap, appState: StaticCanvasAppState, ) => { - const frame = getTargetFrame(element, allElements, appState); + const frame = getTargetFrame(element, allElementsMap, appState); const _element = isTextElement(element) - ? getContainerElement(element, allElements) || element + ? getContainerElement(element, allElementsMap) || element : element; if (frame) { @@ -703,16 +725,18 @@ export const isElementInFrame = ( } if (_element.groupIds.length === 0) { - return elementOverlapsWithFrame(_element, frame); + return elementOverlapsWithFrame(_element, frame, allElementsMap); } const allElementsInGroup = new Set( - _element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)), + _element.groupIds.flatMap((gid) => + getElementsInGroup(allElementsMap, gid), + ), ); if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) { const selectedElements = new Set( - getSelectedElements(allElements, appState), + getSelectedElements(allElementsMap, appState), ); const editingGroupOverlapsFrame = appState.frameToHighlight !== null; @@ -733,7 +757,7 @@ export const isElementInFrame = ( } for (const elementInGroup of allElementsInGroup) { - if (elementOverlapsWithFrame(elementInGroup, frame)) { + if (elementOverlapsWithFrame(elementInGroup, frame, allElementsMap)) { return true; } } diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index de4bcfe53..a0b8228c9 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -7,6 +7,7 @@ import { ExcalidrawTextElementWithContainer, ExcalidrawFrameLikeElement, NonDeletedSceneElementsMap, + ElementsMap, } from "../element/types"; import { isTextElement, @@ -137,6 +138,7 @@ export interface ExcalidrawElementWithCanvas { const cappedElementCanvasSize = ( element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, zoom: Zoom, ): { width: number; @@ -155,7 +157,7 @@ const cappedElementCanvasSize = ( const padding = getCanvasPadding(element); - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const elementWidth = isLinearElement(element) || isFreeDrawElement(element) ? distance(x1, x2) @@ -200,7 +202,11 @@ const generateElementCanvas = ( const context = canvas.getContext("2d")!; const padding = getCanvasPadding(element); - const { width, height, scale } = cappedElementCanvasSize(element, zoom); + const { width, height, scale } = cappedElementCanvasSize( + element, + elementsMap, + zoom, + ); canvas.width = width; canvas.height = height; @@ -209,7 +215,7 @@ const generateElementCanvas = ( let canvasOffsetY = 0; if (isLinearElement(element) || isFreeDrawElement(element)) { - const [x1, y1] = getElementAbsoluteCoords(element); + const [x1, y1] = getElementAbsoluteCoords(element, elementsMap); canvasOffsetX = element.x > x1 @@ -468,7 +474,7 @@ const drawElementFromCanvas = ( const element = elementWithCanvas.element; const padding = getCanvasPadding(element); const zoom = elementWithCanvas.scale; - let [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + let [x1, y1, x2, y2] = getElementAbsoluteCoords(element, allElementsMap); // Free draw elements will otherwise "shuffle" as the min x and y change if (isFreeDrawElement(element)) { @@ -513,8 +519,10 @@ const drawElementFromCanvas = ( elementWithCanvas.canvas.height, ); - const [, , , , boundTextCx, boundTextCy] = - getElementAbsoluteCoords(boundTextElement); + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + allElementsMap, + ); tempCanvasContext.rotate(-element.angle); @@ -694,7 +702,7 @@ export const renderElement = ( ShapeCache.generateElementShape(element, null); if (renderConfig.isExporting) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; const shiftX = (x2 - x1) / 2 - (element.x - x1); @@ -737,7 +745,7 @@ export const renderElement = ( // rely on existing shapes ShapeCache.generateElementShape(element, renderConfig); if (renderConfig.isExporting) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const cx = (x1 + x2) / 2 + appState.scrollX; const cy = (y1 + y2) / 2 + appState.scrollY; let shiftX = (x2 - x1) / 2 - (element.x - x1); @@ -749,6 +757,7 @@ export const renderElement = ( LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); shiftX = (x2 - x1) / 2 - (boundTextCoords.x - x1); shiftY = (y2 - y1) / 2 - (boundTextCoords.y - y1); @@ -804,8 +813,10 @@ export const renderElement = ( tempCanvasContext.rotate(-element.angle); // Shift the canvas to center of bound text - const [, , , , boundTextCx, boundTextCy] = - getElementAbsoluteCoords(boundTextElement); + const [, , , , boundTextCx, boundTextCy] = getElementAbsoluteCoords( + boundTextElement, + elementsMap, + ); const boundTextShiftX = (x1 + x2) / 2 - boundTextCx; const boundTextShiftY = (y1 + y2) / 2 - boundTextCy; tempCanvasContext.translate(-boundTextShiftX, -boundTextShiftY); @@ -939,17 +950,18 @@ export const renderElementToSvg = ( renderConfig: SVGRenderConfig, ) => { const offset = { x: offsetX, y: offsetY }; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); let cx = (x2 - x1) / 2 - (element.x - x1); let cy = (y2 - y1) / 2 - (element.y - y1); if (isTextElement(element)) { const container = getContainerElement(element, elementsMap); if (isArrowElement(container)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(container); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( container, element as ExcalidrawTextElementWithContainer, + elementsMap, ); cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); @@ -1151,6 +1163,7 @@ export const renderElementToSvg = ( const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( element, boundText, + elementsMap, ); const maskX = offsetX + boundTextCoords.x - element.x; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index d31d69650..d80540fd0 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -17,6 +17,7 @@ import { GroupId, ExcalidrawBindableElement, ExcalidrawFrameLikeElement, + ElementsMap, } from "../element/types"; import { getElementAbsoluteCoords, @@ -256,7 +257,10 @@ const renderLinearPointHandles = ( context.save(); context.translate(appState.scrollX, appState.scrollY); context.lineWidth = 1 / appState.zoom.value; - const points = LinearElementEditor.getPointsGlobalCoordinates(element); + const points = LinearElementEditor.getPointsGlobalCoordinates( + element, + elementsMap, + ); const { POINT_HANDLE_SIZE } = LinearElementEditor; const radius = appState.editingLinearElement @@ -340,6 +344,7 @@ const highlightPoint = ( const renderLinearElementPointHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, ) => { const { elementId, hoverPointIndex } = appState.selectedLinearElement!; if ( @@ -356,6 +361,7 @@ const renderLinearElementPointHighlight = ( const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( element, hoverPointIndex, + elementsMap, ); context.save(); context.translate(appState.scrollX, appState.scrollY); @@ -510,12 +516,22 @@ const _renderInteractiveScene = ({ appState.suggestedBindings .filter((binding) => binding != null) .forEach((suggestedBinding) => { - renderBindingHighlight(context, appState, suggestedBinding!); + renderBindingHighlight( + context, + appState, + suggestedBinding!, + elementsMap, + ); }); } if (appState.frameToHighlight) { - renderFrameHighlight(context, appState, appState.frameToHighlight); + renderFrameHighlight( + context, + appState, + appState.frameToHighlight, + elementsMap, + ); } if (appState.elementsToHighlight) { @@ -545,7 +561,7 @@ const _renderInteractiveScene = ({ appState.selectedLinearElement && appState.selectedLinearElement.hoverPointIndex >= 0 ) { - renderLinearElementPointHighlight(context, appState); + renderLinearElementPointHighlight(context, appState, elementsMap); } // Paint selected elements if (!appState.multiElement && !appState.editingLinearElement) { @@ -608,7 +624,7 @@ const _renderInteractiveScene = ({ if (selectionColors.length) { const [elementX1, elementY1, elementX2, elementY2, cx, cy] = - getElementAbsoluteCoords(element, true); + getElementAbsoluteCoords(element, elementsMap, true); selections.push({ angle: element.angle, elementX1, @@ -666,7 +682,8 @@ const _renderInteractiveScene = ({ const transformHandles = getTransformHandles( selectedElements[0], appState.zoom, - "mouse", // when we render we don't know which pointer type so use mouse + elementsMap, + "mouse", // when we render we don't know which pointer type so use mouse, ); if (!appState.viewModeEnabled && showBoundingBox) { renderTransformHandles( @@ -953,7 +970,11 @@ const _renderStaticScene = ({ element.groupIds.length > 0 && appState.frameToHighlight && appState.selectedElementIds[element.id] && - (elementOverlapsWithFrame(element, appState.frameToHighlight) || + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) ) { element.groupIds.forEach((groupId) => @@ -1004,7 +1025,7 @@ const _renderStaticScene = ({ ); } if (!isExporting) { - renderLinkIcon(element, context, appState); + renderLinkIcon(element, context, appState, elementsMap); } } catch (error: any) { console.error(error); @@ -1048,7 +1069,7 @@ const _renderStaticScene = ({ ); } if (!isExporting) { - renderLinkIcon(element, context, appState); + renderLinkIcon(element, context, appState, elementsMap); } }; // - when exporting the whole canvas, we DO NOT apply clipping @@ -1247,6 +1268,7 @@ const renderBindingHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, ) => { const renderHighlight = Array.isArray(suggestedBinding) ? renderBindingHighlightForSuggestedPointBinding @@ -1254,7 +1276,7 @@ const renderBindingHighlight = ( context.save(); context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any); + renderHighlight(context, suggestedBinding as any, elementsMap); context.restore(); }; @@ -1262,8 +1284,9 @@ const renderBindingHighlight = ( const renderBindingHighlightForBindableElement = ( context: CanvasRenderingContext2D, element: ExcalidrawBindableElement, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const width = x2 - x1; const height = y2 - y1; const threshold = maxBindingGap(element, width, height); @@ -1323,8 +1346,9 @@ const renderFrameHighlight = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, frame: NonDeleted, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const width = x2 - x1; const height = y2 - y1; @@ -1398,6 +1422,7 @@ const renderElementsBoxHighlight = ( const renderBindingHighlightForSuggestedPointBinding = ( context: CanvasRenderingContext2D, suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, ) => { const [element, startOrEnd, bindableElement] = suggestedBinding; @@ -1416,6 +1441,7 @@ const renderBindingHighlightForSuggestedPointBinding = ( const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( element, index, + elementsMap, ); fillCircle(context, x, y, threshold); }); @@ -1426,9 +1452,10 @@ const renderLinkIcon = ( element: NonDeletedExcalidrawElement, context: CanvasRenderingContext2D, appState: StaticCanvasAppState, + elementsMap: ElementsMap, ) => { if (element.link && !appState.selectedElementIds[element.id]) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); const [x, y, width, height] = getLinkHandleFromCoords( [x1, y1, x2, y2], element.angle, diff --git a/packages/excalidraw/scene/Fonts.ts b/packages/excalidraw/scene/Fonts.ts index 1a97c06e0..6691e90be 100644 --- a/packages/excalidraw/scene/Fonts.ts +++ b/packages/excalidraw/scene/Fonts.ts @@ -60,10 +60,8 @@ export class Fonts { return newElementWith(element, { ...refreshTextDimensions( element, - getContainerElement( - element, - this.scene.getElementsMapIncludingDeleted(), - ), + getContainerElement(element, this.scene.getNonDeletedElementsMap()), + this.scene.getNonDeletedElementsMap(), ), }); } diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index d463e2597..a8d08c900 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -392,8 +392,9 @@ export const exportToSvg = async ( const frameElements = getFrameLikeElements(elements); let exportingFrameClipPath = ""; + const elementsMap = arrayToMap(elements); for (const frame of frameElements) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); const cx = (x2 - x1) / 2 - (frame.x - x1); const cy = (y2 - y1) / 2 - (frame.y - y1); diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index ae021f6aa..27d4db1c9 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -1,4 +1,5 @@ import { + ElementsMap, ElementsMapOrArray, ExcalidrawElement, NonDeletedExcalidrawElement, @@ -44,10 +45,11 @@ export const excludeElementsInFramesFromSelection = < export const getElementsWithinSelection = ( elements: readonly NonDeletedExcalidrawElement[], selection: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, excludeElementsInFrames: boolean = true, ) => { const [selectionX1, selectionY1, selectionX2, selectionY2] = - getElementAbsoluteCoords(selection); + getElementAbsoluteCoords(selection, elementsMap); let elementsInSelection = elements.filter((element) => { let [elementX1, elementY1, elementX2, elementY2] = @@ -82,7 +84,7 @@ export const getElementsWithinSelection = ( const containingFrame = getContainingFrame(element); if (containingFrame) { - return elementOverlapsWithFrame(element, containingFrame); + return elementOverlapsWithFrame(element, containingFrame, elementsMap); } return true; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 7557145ae..3061c02d4 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -8,15 +8,18 @@ import { import { MaybeTransformHandleType } from "./element/transformHandles"; import { isBoundToContainer, isFrameLikeElement } from "./element/typeChecks"; import { + ElementsMap, ExcalidrawElement, NonDeletedExcalidrawElement, } from "./element/types"; import { getMaximumGroups } from "./groups"; import { KEYS } from "./keys"; import { rangeIntersection, rangesOverlap, rotatePoint } from "./math"; -import { getVisibleAndNonSelectedElements } from "./scene/selection"; +import { + getSelectedElements, + getVisibleAndNonSelectedElements, +} from "./scene/selection"; import { AppState, KeyboardModifiersObject, Point } from "./types"; -import { arrayToMap } from "./utils"; const SNAP_DISTANCE = 8; @@ -167,6 +170,7 @@ export const areRoughlyEqual = (a: number, b: number, precision = 0.01) => { export const getElementsCorners = ( elements: ExcalidrawElement[], + elementsMap: ElementsMap, { omitCenter, boundingBoxCorners, @@ -185,7 +189,10 @@ export const getElementsCorners = ( if (elements.length === 1) { const element = elements[0]; - let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element); + let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + ); if (dragOffset) { x1 += dragOffset.x; @@ -280,6 +287,7 @@ export const getVisibleGaps = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: ExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const referenceElements: ExcalidrawElement[] = getReferenceElements( elements, @@ -287,10 +295,7 @@ export const getVisibleGaps = ( appState, ); - const referenceBounds = getMaximumGroups( - referenceElements, - arrayToMap(elements), - ) + const referenceBounds = getMaximumGroups(referenceElements, elementsMap) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), @@ -569,19 +574,19 @@ export const getReferenceSnapPoints = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: ExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const referenceElements = getReferenceElements( elements, selectedElements, appState, ); - - return getMaximumGroups(referenceElements, arrayToMap(elements)) + return getMaximumGroups(referenceElements, elementsMap) .filter( (elementsGroup) => !(elementsGroup.length === 1 && isBoundToContainer(elementsGroup[0])), ) - .flatMap((elementGroup) => getElementsCorners(elementGroup)); + .flatMap((elementGroup) => getElementsCorners(elementGroup, elementsMap)); }; const getPointSnaps = ( @@ -641,11 +646,13 @@ const getPointSnaps = ( }; export const snapDraggedElements = ( - selectedElements: ExcalidrawElement[], + elements: ExcalidrawElement[], dragOffset: Vector2D, appState: AppState, event: KeyboardModifiersObject, + elementsMap: ElementsMap, ) => { + const selectedElements = getSelectedElements(elements, appState); if ( !isSnappingEnabled({ appState, event, selectedElements }) || selectedElements.length === 0 @@ -658,7 +665,6 @@ export const snapDraggedElements = ( snapLines: [], }; } - dragOffset.x = round(dragOffset.x); dragOffset.y = round(dragOffset.y); const nearestSnapsX: Snaps = []; @@ -669,7 +675,7 @@ export const snapDraggedElements = ( y: snapDistance, }; - const selectionPoints = getElementsCorners(selectedElements, { + const selectionPoints = getElementsCorners(selectedElements, elementsMap, { dragOffset, }); @@ -719,7 +725,7 @@ export const snapDraggedElements = ( getPointSnaps( selectedElements, - getElementsCorners(selectedElements, { + getElementsCorners(selectedElements, elementsMap, { dragOffset: newDragOffset, }), appState, @@ -1204,6 +1210,7 @@ export const snapNewElement = ( event: KeyboardModifiersObject, origin: Vector2D, dragOffset: Vector2D, + elementsMap: ElementsMap, ) => { if ( !isSnappingEnabled({ event, selectedElements: [draggingElement], appState }) @@ -1248,7 +1255,7 @@ export const snapNewElement = ( nearestSnapsX.length = 0; nearestSnapsY.length = 0; - const corners = getElementsCorners([draggingElement], { + const corners = getElementsCorners([draggingElement], elementsMap, { boundingBoxCorners: true, omitCenter: true, }); @@ -1276,6 +1283,7 @@ export const getSnapLinesAtPointer = ( appState: AppState, pointer: Vector2D, event: KeyboardModifiersObject, + elementsMap: ElementsMap, ) => { if (!isSnappingEnabled({ event, selectedElements: [], appState })) { return { @@ -1301,7 +1309,7 @@ export const getSnapLinesAtPointer = ( const verticalSnapLines: PointerSnapLine[] = []; for (const referenceElement of referenceElements) { - const corners = getElementsCorners([referenceElement]); + const corners = getElementsCorners([referenceElement], elementsMap); for (const corner of corners) { const offsetX = corner[0] - pointer.x; diff --git a/packages/excalidraw/tests/binding.test.tsx b/packages/excalidraw/tests/binding.test.tsx index cb2c4b340..9e074c2e5 100644 --- a/packages/excalidraw/tests/binding.test.tsx +++ b/packages/excalidraw/tests/binding.test.tsx @@ -5,6 +5,7 @@ import { getTransformHandles } from "../element/transformHandles"; import { API } from "./helpers/api"; import { KEYS } from "../keys"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; +import { arrayToMap } from "../utils"; const { h } = window; @@ -91,8 +92,12 @@ describe("element binding", () => { expect(arrow.startBinding?.elementId).toBe(rectLeft.id); expect(arrow.endBinding?.elementId).toBe(rectRight.id); - const rotation = getTransformHandles(arrow, h.state.zoom, "mouse") - .rotation!; + const rotation = getTransformHandles( + arrow, + h.state.zoom, + arrayToMap(h.elements), + "mouse", + ).rotation!; const rotationHandleX = rotation[0] + rotation[2] / 2; const rotationHandleY = rotation[1] + rotation[3] / 2; mouse.down(rotationHandleX, rotationHandleY); diff --git a/packages/excalidraw/tests/flip.test.tsx b/packages/excalidraw/tests/flip.test.tsx index 875e87752..bd141f6be 100644 --- a/packages/excalidraw/tests/flip.test.tsx +++ b/packages/excalidraw/tests/flip.test.tsx @@ -27,7 +27,7 @@ import * as blob from "../data/blob"; import { KEYS } from "../keys"; import { getBoundTextElementPosition } from "../element/textElement"; import { createPasteEvent } from "../clipboard"; -import { cloneJSON } from "../utils"; +import { arrayToMap, cloneJSON } from "../utils"; const { h } = window; const mouse = new Pointer("mouse"); @@ -194,9 +194,10 @@ const checkElementsBoundingBox = async ( element2: ExcalidrawElement, toleranceInPx: number = 0, ) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1); + const elementsMap = arrayToMap([element1, element2]); + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element1, elementsMap); - const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2); + const [x12, y12, x22, y22] = getElementAbsoluteCoords(element2, elementsMap); await waitFor(() => { // Check if width and height did not change @@ -853,7 +854,11 @@ describe("mutliple elements", () => { h.app.actionManager.executeAction(actionFlipVertical); const arrowText = h.elements[1] as ExcalidrawTextElementWithContainer; - const arrowTextPos = getBoundTextElementPosition(arrow.get(), arrowText)!; + const arrowTextPos = getBoundTextElementPosition( + arrow.get(), + arrowText, + arrayToMap(h.elements), + )!; const rectText = h.elements[3] as ExcalidrawTextElementWithContainer; expect(arrow.x).toBeCloseTo(180); diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 58579fe93..42685b866 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -32,6 +32,7 @@ import { import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; +import { arrayToMap } from "../../utils"; const { h } = window; @@ -286,9 +287,12 @@ const transform = ( let handleCoords: TransformHandle | undefined; if (elements.length === 1) { - handleCoords = getTransformHandles(elements[0], h.state.zoom, "mouse")[ - handle - ]; + handleCoords = getTransformHandles( + elements[0], + h.state.zoom, + arrayToMap(h.elements), + "mouse", + )[handle]; } else { const [x1, y1, x2, y2] = getCommonBounds(elements); const isFrameSelected = elements.some(isFrameLikeElement); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index ce0e1c856..6c01987c9 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -343,6 +343,8 @@ describe("Test Linear Elements", () => { }); it("should update all the midpoints when element position changed", async () => { + const elementsMap = arrayToMap(h.elements); + createThreePointerLinearElement("line", { type: ROUNDNESS.PROPORTIONAL_RADIUS, }); @@ -351,7 +353,10 @@ describe("Test Linear Elements", () => { expect(line.points.length).toEqual(3); enterLineEditingMode(line); - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([line.x, line.y]).toEqual(points[0]); const midPoints = LinearElementEditor.getEditorMidPoints( @@ -465,7 +470,11 @@ describe("Test Linear Elements", () => { }); it("should update only the first segment midpoint when its point is dragged", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -482,7 +491,10 @@ describe("Test Linear Elements", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] - delta, points[0][1] - delta, @@ -499,7 +511,11 @@ describe("Test Linear Elements", () => { }); it("should hide midpoints in the segment when points moved close", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -516,7 +532,10 @@ describe("Test Linear Elements", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] + delta, points[0][1] + delta, @@ -535,7 +554,10 @@ describe("Test Linear Elements", () => { it("should remove the midpoint when one of the points in the segment is deleted", async () => { const line = h.elements[0] as ExcalidrawLinearElement; enterLineEditingMode(line); - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + arrayToMap(h.elements), + ); // dragging line from last segment midpoint drag(lastSegmentMidpoint, [ @@ -637,7 +659,11 @@ describe("Test Linear Elements", () => { }); it("should update all the midpoints when its point is dragged", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -649,7 +675,10 @@ describe("Test Linear Elements", () => { // Drag from first point drag(hitCoords, [hitCoords[0] - delta, hitCoords[1] - delta]); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] - delta, points[0][1] - delta, @@ -678,7 +707,11 @@ describe("Test Linear Elements", () => { }); it("should hide midpoints in the segment when points moved close", async () => { - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const elementsMap = arrayToMap(h.elements); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); const midPoints = LinearElementEditor.getEditorMidPoints( line, h.app.scene.getNonDeletedElementsMap(), @@ -695,7 +728,10 @@ describe("Test Linear Elements", () => { ); expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`); - const newPoints = LinearElementEditor.getPointsGlobalCoordinates(line); + const newPoints = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); expect([newPoints[0][0], newPoints[0][1]]).toEqual([ points[0][0] + delta, points[0][1] + delta, @@ -712,6 +748,8 @@ describe("Test Linear Elements", () => { }); it("should update all the midpoints when a point is deleted", async () => { + const elementsMap = arrayToMap(h.elements); + drag(lastSegmentMidpoint, [ lastSegmentMidpoint[0] + delta, lastSegmentMidpoint[1] + delta, @@ -723,7 +761,10 @@ describe("Test Linear Elements", () => { h.app.scene.getNonDeletedElementsMap(), h.state, ); - const points = LinearElementEditor.getPointsGlobalCoordinates(line); + const points = LinearElementEditor.getPointsGlobalCoordinates( + line, + elementsMap, + ); // delete 3rd point deletePoint(points[2]); @@ -837,6 +878,7 @@ describe("Test Linear Elements", () => { const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, + arrayToMap(h.elements), ); expect(position).toMatchInlineSnapshot(` { @@ -859,6 +901,7 @@ describe("Test Linear Elements", () => { const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, + arrayToMap(h.elements), ); expect(position).toMatchInlineSnapshot(` { @@ -893,6 +936,7 @@ describe("Test Linear Elements", () => { const position = LinearElementEditor.getBoundTextElementPosition( container, textElement, + arrayToMap(h.elements), ); expect(position).toMatchInlineSnapshot(` { @@ -1012,8 +1056,13 @@ describe("Test Linear Elements", () => { ); expect(container.width).toBe(70); expect(container.height).toBe(50); - expect(getBoundTextElementPosition(container, textElement)) - .toMatchInlineSnapshot(` + expect( + getBoundTextElementPosition( + container, + textElement, + arrayToMap(h.elements), + ), + ).toMatchInlineSnapshot(` { "x": 75, "y": 60, @@ -1051,8 +1100,13 @@ describe("Test Linear Elements", () => { } `); - expect(getBoundTextElementPosition(container, textElement)) - .toMatchInlineSnapshot(` + expect( + getBoundTextElementPosition( + container, + textElement, + arrayToMap(h.elements), + ), + ).toMatchInlineSnapshot(` { "x": 271.11716195150507, "y": 45, @@ -1090,7 +1144,8 @@ describe("Test Linear Elements", () => { arrow, ); expect(container.width).toBe(40); - expect(getBoundTextElementPosition(container, textElement)) + const elementsMap = arrayToMap(h.elements); + expect(getBoundTextElementPosition(container, textElement, elementsMap)) .toMatchInlineSnapshot(` { "x": 25, @@ -1102,7 +1157,10 @@ describe("Test Linear Elements", () => { collaboration made easy" `); - const points = LinearElementEditor.getPointsGlobalCoordinates(container); + const points = LinearElementEditor.getPointsGlobalCoordinates( + container, + elementsMap, + ); // Drag from last point drag(points[1], [points[1][0] + 300, points[1][1]]); @@ -1115,7 +1173,7 @@ describe("Test Linear Elements", () => { } `); - expect(getBoundTextElementPosition(container, textElement)) + expect(getBoundTextElementPosition(container, textElement, elementsMap)) .toMatchInlineSnapshot(` { "x": 75, diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 22d828ee9..625175700 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -13,6 +13,7 @@ import { import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; import { vi } from "vitest"; +import { arrayToMap } from "../utils"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -75,12 +76,13 @@ describe("move element", () => { const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const line = UI.createElement("line", { x: 110, y: 50, size: 80 }); - + const elementsMap = arrayToMap(h.elements); // bind line to two rectangles bindOrUnbindLinearElement( line.get() as NonDeleted, rectA.get() as ExcalidrawRectangleElement, rectB.get() as ExcalidrawRectangleElement, + elementsMap, ); // select the second rectangles diff --git a/packages/excalidraw/tests/resize.test.tsx b/packages/excalidraw/tests/resize.test.tsx index 43e84d0ce..d4d1e7673 100644 --- a/packages/excalidraw/tests/resize.test.tsx +++ b/packages/excalidraw/tests/resize.test.tsx @@ -13,6 +13,7 @@ import { API } from "./helpers/api"; import { KEYS } from "../keys"; import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; +import { arrayToMap } from "../utils"; ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -301,10 +302,12 @@ describe("arrow element", () => { ], }); const label = await UI.editText(arrow, "Hello"); + const elementsMap = arrayToMap(h.elements); UI.resize(arrow, "se", [50, 30]); let labelPos = LinearElementEditor.getBoundTextElementPosition( arrow, label, + elementsMap, ); expect(labelPos.x + label.width / 2).toBeCloseTo( @@ -317,7 +320,11 @@ describe("arrow element", () => { expect(label.fontSize).toEqual(20); UI.resize(arrow, "w", [20, 0]); - labelPos = LinearElementEditor.getBoundTextElementPosition(arrow, label); + labelPos = LinearElementEditor.getBoundTextElementPosition( + arrow, + label, + elementsMap, + ); expect(labelPos.x + label.width / 2).toBeCloseTo( arrow.x + arrow.points[2][0], @@ -743,15 +750,17 @@ describe("multiple selection", () => { const selectionTop = 20 - topArrowLabel.height / 2; const move = [80, 0] as [number, number]; const scale = move[0] / selectionWidth + 1; - + const elementsMap = arrayToMap(h.elements); UI.resize([topArrow.get(), bottomArrow.get()], "se", move); const topArrowLabelPos = LinearElementEditor.getBoundTextElementPosition( topArrow, topArrowLabel, + elementsMap, ); const bottomArrowLabelPos = LinearElementEditor.getBoundTextElementPosition( bottomArrow, bottomArrowLabel, + elementsMap, ); expect(topArrow.x).toBeCloseTo(0); @@ -944,12 +953,13 @@ describe("multiple selection", () => { const scaleX = move[0] / selectionWidth + 1; const scaleY = -scaleX; const lineOrigBounds = getBoundsFromPoints(line); - + const elementsMap = arrayToMap(h.elements); UI.resize([line, image, rectangle, boundArrow], "se", move); const lineNewBounds = getBoundsFromPoints(line); const arrowLabelPos = LinearElementEditor.getBoundTextElementPosition( boundArrow, arrowLabel, + elementsMap, ); expect(line.x).toBeCloseTo(60 * scaleX); From 9013c84524448202ace9038d006bcecb24cd16ff Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 19 Feb 2024 11:49:01 +0530 Subject: [PATCH 03/66] fix: make LinearElementEditor independent of scene (#7670) * fix: make LinearElementEditor independent of scene * more fixes * pass elements and elementsMap to maybeBindBindableElement,getHoveredElementForBinding,bindingBorderTest,getElligibleElementsForBindableElementAndWhere,isLinearElementEligibleForNewBindingByBindable * replace `ElementsMap` with `NonDeletedSceneElementsMap` & remove unused params * fix lint --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- .../excalidraw/actions/actionClipboard.tsx | 2 +- .../actions/actionDeleteSelected.tsx | 5 +- .../actions/actionDuplicateSelection.tsx | 8 +- .../excalidraw/actions/actionFinalize.tsx | 6 +- packages/excalidraw/actions/actionFlip.ts | 13 ++- .../excalidraw/actions/actionLinearEditor.ts | 2 +- .../excalidraw/actions/actionSelectAll.ts | 2 +- packages/excalidraw/components/App.tsx | 80 +++++++------ packages/excalidraw/data/transform.ts | 11 +- packages/excalidraw/element/binding.ts | 110 +++++++++++------- .../excalidraw/element/linearElementEditor.ts | 49 +++++--- packages/excalidraw/renderer/renderScene.ts | 3 +- packages/excalidraw/tests/move.test.tsx | 4 +- 13 files changed, 172 insertions(+), 123 deletions(-) diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index b2457341d..b9634886b 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -107,7 +107,7 @@ export const actionCut = register({ trackEvent: { category: "element" }, perform: (elements, appState, event: ClipboardEvent | null, app) => { actionCopy.perform(elements, appState, event, app); - return actionDeleteSelected.perform(elements, appState); + return actionDeleteSelected.perform(elements, appState, null, app); }, contextItemLabel: "labels.cut", keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index de25ed898..65f751d93 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -73,7 +73,7 @@ const handleGroupEditingState = ( export const actionDeleteSelected = register({ name: "deleteSelectedElements", trackEvent: { category: "element", action: "delete" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { if (appState.editingLinearElement) { const { elementId, @@ -81,7 +81,8 @@ export const actionDeleteSelected = register({ startBindingElement, endBindingElement, } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const elementsMap = app.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 7126f549e..86391f9e3 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -35,10 +35,14 @@ import { export const actionDuplicateSelection = register({ name: "duplicateSelection", trackEvent: { category: "element" }, - perform: (elements, appState) => { + perform: (elements, appState, formData, app) => { + const elementsMap = app.scene.getNonDeletedElementsMap(); // duplicate selected point(s) if editing a line if (appState.editingLinearElement) { - const ret = LinearElementEditor.duplicateSelectedPoints(appState); + const ret = LinearElementEditor.duplicateSelectedPoints( + appState, + elementsMap, + ); if (!ret) { return false; diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 623876d58..9dad4ef91 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -26,12 +26,12 @@ export const actionFinalize = register({ _, { interactiveCanvas, focusContainer, scene }, ) => { - const elementsMap = arrayToMap(elements); + const elementsMap = scene.getNonDeletedElementsMap(); if (appState.editingLinearElement) { const { elementId, startBindingElement, endBindingElement } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (element) { if (isBindingElement(element)) { @@ -191,7 +191,7 @@ export const actionFinalize = register({ // To select the linear element when user has finished mutipoint editing selectedLinearElement: multiPointElement && isLinearElement(multiPointElement) - ? new LinearElementEditor(multiPointElement, scene) + ? new LinearElementEditor(multiPointElement) : appState.selectedLinearElement, pendingImageElementId: null, }, diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index 70fbe026d..ee4a6f0f5 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -4,7 +4,6 @@ import { getNonDeletedElements } from "../element"; import { ExcalidrawElement, NonDeleted, - NonDeletedElementsMap, NonDeletedSceneElementsMap, } from "../element/types"; import { resizeMultipleElements } from "../element/resizeElements"; @@ -68,7 +67,7 @@ export const actionFlipVertical = register({ const flipSelectedElements = ( elements: readonly ExcalidrawElement[], - elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, + elementsMap: NonDeletedSceneElementsMap, appState: Readonly, flipDirection: "horizontal" | "vertical", ) => { @@ -83,6 +82,7 @@ const flipSelectedElements = ( const updatedElements = flipElements( selectedElements, + elements, elementsMap, appState, flipDirection, @@ -97,7 +97,8 @@ const flipSelectedElements = ( const flipElements = ( selectedElements: NonDeleted[], - elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, appState: AppState, flipDirection: "horizontal" | "vertical", ): ExcalidrawElement[] => { @@ -113,9 +114,9 @@ const flipElements = ( flipDirection === "horizontal" ? minY : maxY, ); - (isBindingEnabled(appState) - ? bindOrUnbindSelectedElements - : unbindLinearElements)(selectedElements, elementsMap); + isBindingEnabled(appState) + ? bindOrUnbindSelectedElements(selectedElements, elements, elementsMap) + : unbindLinearElements(selectedElements, elementsMap); return selectedElements; }; diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts index 83611b027..5f1e672cb 100644 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ b/packages/excalidraw/actions/actionLinearEditor.ts @@ -24,7 +24,7 @@ export const actionToggleLinearEditor = register({ const editingLinearElement = appState.editingLinearElement?.elementId === selectedElement.id ? null - : new LinearElementEditor(selectedElement, app.scene); + : new LinearElementEditor(selectedElement); return { appState: { ...appState, diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 49f5072ce..398416f0c 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -43,7 +43,7 @@ export const actionSelectAll = register({ // single linear element selected Object.keys(selectedElementIds).length === 1 && isLinearElement(elements[0]) - ? new LinearElementEditor(elements[0], app.scene) + ? new LinearElementEditor(elements[0]) : null, }, commitToHistory: true, diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b4410ab2b..1f21de9cf 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -2603,7 +2603,7 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); - const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const elementsMap = this.scene.getNonDeletedElementsMap(); if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); @@ -3860,7 +3860,6 @@ class App extends React.Component { this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, - this.scene, ), }); } @@ -4013,7 +4012,11 @@ class App extends React.Component { const selectedElements = this.scene.getSelectedElements(this.state); const elementsMap = this.scene.getNonDeletedElementsMap(); isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements(selectedElements, elementsMap) + ? bindOrUnbindSelectedElements( + selectedElements, + this.scene.getNonDeletedElements(), + elementsMap, + ) : unbindLinearElements(selectedElements, elementsMap); this.setState({ suggestedBindings: [] }); } @@ -4578,10 +4581,7 @@ class App extends React.Component { ) { this.history.resumeRecording(); this.setState({ - editingLinearElement: new LinearElementEditor( - selectedElements[0], - this.scene, - ), + editingLinearElement: new LinearElementEditor(selectedElements[0]), }); return; } else if ( @@ -5305,10 +5305,12 @@ class App extends React.Component { scenePointerX: number, scenePointerY: number, ) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); - const elementsMap = this.scene.getNonDeletedElementsMap(); const boundTextElement = getBoundTextElement(element, elementsMap); if (!element) { @@ -6122,7 +6124,8 @@ class App extends React.Component { this.history, pointerDownState.origin, linearElementEditor, - this.scene.getNonDeletedElementsMap(), + this.scene.getNonDeletedElements(), + elementsMap, ); if (ret.hitElement) { pointerDownState.hit.element = ret.hitElement; @@ -6459,7 +6462,8 @@ class App extends React.Component { const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); this.scene.addNewElement(element); this.setState({ @@ -6727,7 +6731,8 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( pointerDownState.origin, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); this.scene.addNewElement(element); @@ -6997,6 +7002,7 @@ class App extends React.Component { return true; } } + const elementsMap = this.scene.getNonDeletedElementsMap(); if (this.state.selectedLinearElement) { const linearElementEditor = @@ -7007,6 +7013,7 @@ class App extends React.Component { this.state.selectedLinearElement, pointerCoords, this.state, + elementsMap, ) ) { const ret = LinearElementEditor.addMidpoint( @@ -7014,7 +7021,7 @@ class App extends React.Component { pointerCoords, this.state, !event[KEYS.CTRL_OR_CMD], - this.scene.getNonDeletedElementsMap(), + elementsMap, ); if (!ret) { return; @@ -7435,10 +7442,7 @@ class App extends React.Component { selectedLinearElement: elementsWithinSelection.length === 1 && isLinearElement(elementsWithinSelection[0]) - ? new LinearElementEditor( - elementsWithinSelection[0], - this.scene, - ) + ? new LinearElementEditor(elementsWithinSelection[0]) : null, showHyperlinkPopup: elementsWithinSelection.length === 1 && @@ -7539,6 +7543,7 @@ class App extends React.Component { childEvent, this.state.editingLinearElement, this.state, + this.scene.getNonDeletedElements(), elementsMap, ); if (editingLinearElement !== this.state.editingLinearElement) { @@ -7563,6 +7568,7 @@ class App extends React.Component { childEvent, this.state.selectedLinearElement, this.state, + this.scene.getNonDeletedElements(), elementsMap, ); @@ -7732,10 +7738,7 @@ class App extends React.Component { }, prevState, ), - selectedLinearElement: new LinearElementEditor( - draggingElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(draggingElement), })); } else { this.setState((prevState) => ({ @@ -7975,10 +7978,7 @@ class App extends React.Component { // the one we've hit if (selectedELements.length === 1) { this.setState({ - selectedLinearElement: new LinearElementEditor( - hitElement, - this.scene, - ), + selectedLinearElement: new LinearElementEditor(hitElement), }); } } @@ -8091,10 +8091,7 @@ class App extends React.Component { selectedLinearElement: newSelectedElements.length === 1 && isLinearElement(newSelectedElements[0]) - ? new LinearElementEditor( - newSelectedElements[0], - this.scene, - ) + ? new LinearElementEditor(newSelectedElements[0]) : prevState.selectedLinearElement, }; }); @@ -8168,7 +8165,7 @@ class App extends React.Component { // Don't set `selectedLinearElement` if its same as the hitElement, this is mainly to prevent resetting the `hoverPointIndex` to -1. // Future we should update the API to take care of setting the correct `hoverPointIndex` when initialized prevState.selectedLinearElement?.elementId !== hitElement.id - ? new LinearElementEditor(hitElement, this.scene) + ? new LinearElementEditor(hitElement) : prevState.selectedLinearElement, })); } @@ -8232,12 +8229,16 @@ class App extends React.Component { } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { - (isBindingEnabled(this.state) - ? bindOrUnbindSelectedElements - : unbindLinearElements)( - this.scene.getSelectedElements(this.state), - elementsMap, - ); + isBindingEnabled(this.state) + ? bindOrUnbindSelectedElements( + this.scene.getSelectedElements(this.state), + this.scene.getNonDeletedElements(), + elementsMap, + ) + : unbindLinearElements( + this.scene.getSelectedElements(this.state), + elementsMap, + ); } if (activeTool.type === "laser") { @@ -8714,7 +8715,8 @@ class App extends React.Component { }): void => { const hoveredBindableElement = getHoveredElementForBinding( pointerCoords, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); this.setState({ suggestedBindings: @@ -8741,7 +8743,8 @@ class App extends React.Component { (acc: NonDeleted[], coords) => { const hoveredBindableElement = getHoveredElementForBinding( coords, - this.scene, + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), ); if ( hoveredBindableElement != null && @@ -8769,6 +8772,7 @@ class App extends React.Component { } const suggestedBindings = getEligibleElementsForBinding( selectedElements, + this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), ); this.setState({ suggestedBindings }); @@ -9037,7 +9041,7 @@ class App extends React.Component { this, ), selectedLinearElement: isLinearElement(element) - ? new LinearElementEditor(element, this.scene) + ? new LinearElementEditor(element) : null, } : this.state), diff --git a/packages/excalidraw/data/transform.ts b/packages/excalidraw/data/transform.ts index 8d5b63a19..936272f07 100644 --- a/packages/excalidraw/data/transform.ts +++ b/packages/excalidraw/data/transform.ts @@ -39,11 +39,12 @@ import { ExcalidrawTextElement, FileId, FontFamilyValues, + NonDeletedSceneElementsMap, TextAlign, VerticalAlign, } from "../element/types"; import { MarkOptional } from "../utility-types"; -import { arrayToMap, assertNever, cloneJSON, getFontString } from "../utils"; +import { assertNever, cloneJSON, getFontString, toBrandedType } from "../utils"; import { getSizeFromPoints } from "../points"; import { randomId } from "../random"; @@ -231,7 +232,7 @@ const bindLinearElementToElement = ( start: ValidLinearElement["start"], end: ValidLinearElement["end"], elementStore: ElementStore, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): { linearElement: ExcalidrawLinearElement; startBoundElement?: ExcalidrawElement; @@ -460,6 +461,10 @@ class ElementStore { return Array.from(this.excalidrawElements.values()); }; + getElementsMap = () => { + return toBrandedType(this.excalidrawElements); + }; + getElement = (id: string) => { return this.excalidrawElements.get(id); }; @@ -615,7 +620,7 @@ export const convertToExcalidrawElements = ( } } - const elementsMap = arrayToMap(elementStore.getElements()); + const elementsMap = elementStore.getElementsMap(); // Add labels and arrow bindings for (const [id, element] of elementsWithIds) { const excalidrawElement = elementStore.getElement(id)!; diff --git a/packages/excalidraw/element/binding.ts b/packages/excalidraw/element/binding.ts index be766e33f..8d3959bc7 100644 --- a/packages/excalidraw/element/binding.ts +++ b/packages/excalidraw/element/binding.ts @@ -6,6 +6,7 @@ import { PointBinding, ExcalidrawElement, ElementsMap, + NonDeletedSceneElementsMap, } from "./types"; import { getElementAtPosition } from "../scene"; import { AppState } from "../types"; @@ -67,7 +68,7 @@ export const bindOrUnbindLinearElement = ( linearElement: NonDeleted, startBindingElement: ExcalidrawBindableElement | null | "keep", endBindingElement: ExcalidrawBindableElement | null | "keep", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { const boundToElementIds: Set = new Set(); const unboundFromElementIds: Set = new Set(); @@ -115,7 +116,7 @@ const bindOrUnbindLinearElementEdge = ( boundToElementIds: Set, // Is mutated unboundFromElementIds: Set, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (bindableElement !== "keep") { if (bindableElement != null) { @@ -151,7 +152,8 @@ const bindOrUnbindLinearElementEdge = ( export const bindOrUnbindSelectedElements = ( selectedElements: NonDeleted[], - elementsMap: ElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): void => { selectedElements.forEach((selectedElement) => { if (isBindingElement(selectedElement)) { @@ -160,11 +162,13 @@ export const bindOrUnbindSelectedElements = ( getElligibleElementForBindingElement( selectedElement, "start", + elements, elementsMap, ), getElligibleElementForBindingElement( selectedElement, "end", + elements, elementsMap, ), elementsMap, @@ -177,16 +181,18 @@ export const bindOrUnbindSelectedElements = ( const maybeBindBindableElement = ( bindableElement: NonDeleted, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { - getElligibleElementsForBindableElementAndWhere(bindableElement).forEach( - ([linearElement, where]) => - bindOrUnbindLinearElement( - linearElement, - where === "end" ? "keep" : bindableElement, - where === "start" ? "keep" : bindableElement, - elementsMap, - ), + getElligibleElementsForBindableElementAndWhere( + bindableElement, + elementsMap, + ).forEach(([linearElement, where]) => + bindOrUnbindLinearElement( + linearElement, + where === "end" ? "keep" : bindableElement, + where === "start" ? "keep" : bindableElement, + elementsMap, + ), ); }; @@ -195,7 +201,7 @@ export const maybeBindLinearElement = ( appState: AppState, scene: Scene, pointerCoords: { x: number; y: number }, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { if (appState.startBoundElement != null) { bindLinearElement( @@ -205,7 +211,11 @@ export const maybeBindLinearElement = ( elementsMap, ); } - const hoveredElement = getHoveredElementForBinding(pointerCoords, scene); + const hoveredElement = getHoveredElementForBinding( + pointerCoords, + scene.getNonDeletedElements(), + elementsMap, + ); if ( hoveredElement != null && !isLinearElementSimpleAndAlreadyBoundOnOppositeEdge( @@ -222,7 +232,7 @@ export const bindLinearElement = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { mutateElement(linearElement, { [startOrEnd === "start" ? "startBinding" : "endBinding"]: { @@ -274,7 +284,7 @@ export const isLinearElementSimpleAndAlreadyBound = ( export const unbindLinearElements = ( elements: NonDeleted[], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): void => { elements.forEach((element) => { if (isBindingElement(element)) { @@ -301,17 +311,14 @@ export const getHoveredElementForBinding = ( x: number; y: number; }, - scene: Scene, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { const hoveredElement = getElementAtPosition( - scene.getNonDeletedElements(), + elements, (element) => isBindableElement(element, false) && - bindingBorderTest( - element, - pointerCoords, - scene.getNonDeletedElementsMap(), - ), + bindingBorderTest(element, pointerCoords, elementsMap), ); return hoveredElement as NonDeleted | null; }; @@ -320,7 +327,7 @@ const calculateFocusAndGap = ( linearElement: NonDeleted, hoveredElement: ExcalidrawBindableElement, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): { focus: number; gap: number } => { const direction = startOrEnd === "start" ? -1 : 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; @@ -539,33 +546,47 @@ const maybeCalculateNewGapWhenScaling = ( // TODO: this is a bottleneck, optimise export const getEligibleElementsForBinding = ( - elements: NonDeleted[], - elementsMap: ElementsMap, + selectedElements: NonDeleted[], + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): SuggestedBinding[] => { - const includedElementIds = new Set(elements.map(({ id }) => id)); - return elements.flatMap((element) => - isBindingElement(element, false) + const includedElementIds = new Set(selectedElements.map(({ id }) => id)); + return selectedElements.flatMap((selectedElement) => + isBindingElement(selectedElement, false) ? (getElligibleElementsForBindingElement( - element as NonDeleted, + selectedElement as NonDeleted, + elements, elementsMap, ).filter( (element) => !includedElementIds.has(element.id), ) as SuggestedBinding[]) - : isBindableElement(element, false) - ? getElligibleElementsForBindableElementAndWhere(element).filter( - (binding) => !includedElementIds.has(binding[0].id), - ) + : isBindableElement(selectedElement, false) + ? getElligibleElementsForBindableElementAndWhere( + selectedElement, + elementsMap, + ).filter((binding) => !includedElementIds.has(binding[0].id)) : [], ); }; const getElligibleElementsForBindingElement = ( linearElement: NonDeleted, - elementsMap: ElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted[] => { return [ - getElligibleElementForBindingElement(linearElement, "start", elementsMap), - getElligibleElementForBindingElement(linearElement, "end", elementsMap), + getElligibleElementForBindingElement( + linearElement, + "start", + elements, + elementsMap, + ), + getElligibleElementForBindingElement( + linearElement, + "end", + elements, + elementsMap, + ), ].filter( (element): element is NonDeleted => element != null, @@ -575,18 +596,20 @@ const getElligibleElementsForBindingElement = ( const getElligibleElementForBindingElement = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elements: readonly ExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): NonDeleted | null => { return getHoveredElementForBinding( getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap), - Scene.getScene(linearElement)!, + elements, + elementsMap, ); }; const getLinearElementEdgeCoors = ( linearElement: NonDeleted, startOrEnd: "start" | "end", - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): { x: number; y: number } => { const index = startOrEnd === "start" ? 0 : -1; return tupleToCoors( @@ -600,6 +623,7 @@ const getLinearElementEdgeCoors = ( const getElligibleElementsForBindableElementAndWhere = ( bindableElement: NonDeleted, + elementsMap: NonDeletedSceneElementsMap, ): SuggestedPointBinding[] => { const scene = Scene.getScene(bindableElement)!; return scene @@ -612,13 +636,13 @@ const getElligibleElementsForBindableElementAndWhere = ( element, "start", bindableElement, - scene.getNonDeletedElementsMap(), + elementsMap, ); const canBindEnd = isLinearElementEligibleForNewBindingByBindable( element, "end", bindableElement, - scene.getNonDeletedElementsMap(), + elementsMap, ); if (!canBindStart && !canBindEnd) { return null; @@ -636,7 +660,7 @@ const isLinearElementEligibleForNewBindingByBindable = ( linearElement: NonDeleted, startOrEnd: "start" | "end", bindableElement: NonDeleted, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): boolean => { const existingBinding = linearElement[startOrEnd === "start" ? "startBinding" : "endBinding"]; diff --git a/packages/excalidraw/element/linearElementEditor.ts b/packages/excalidraw/element/linearElementEditor.ts index 85483b3d7..d493f1fbd 100644 --- a/packages/excalidraw/element/linearElementEditor.ts +++ b/packages/excalidraw/element/linearElementEditor.ts @@ -6,6 +6,8 @@ import { ExcalidrawBindableElement, ExcalidrawTextElementWithContainer, ElementsMap, + NonDeletedExcalidrawElement, + NonDeletedSceneElementsMap, } from "./types"; import { distance2d, @@ -36,7 +38,6 @@ import { import { mutateElement } from "./mutateElement"; import History from "../history"; -import Scene from "../scene/Scene"; import { bindOrUnbindLinearElement, getHoveredElementForBinding, @@ -86,11 +87,10 @@ export class LinearElementEditor { public readonly hoverPointIndex: number; public readonly segmentMidPointHoveredCoords: Point | null; - constructor(element: NonDeleted, scene: Scene) { + constructor(element: NonDeleted) { this.elementId = element.id as string & { _brand: "excalidrawLinearElementId"; }; - Scene.mapElementToScene(this.elementId, scene); LinearElementEditor.normalizePoints(element); this.selectedPointsIndices = null; @@ -123,8 +123,11 @@ export class LinearElementEditor { * @param id the `elementId` from the instance of this class (so that we can * statically guarantee this method returns an ExcalidrawLinearElement) */ - static getElement(id: InstanceType["elementId"]) { - const element = Scene.getScene(id)?.getNonDeletedElement(id); + static getElement( + id: InstanceType["elementId"], + elementsMap: ElementsMap, + ) { + const element = elementsMap.get(id); if (element) { return element as NonDeleted; } @@ -135,7 +138,7 @@ export class LinearElementEditor { event: PointerEvent, appState: AppState, setState: React.Component["setState"], - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ) { if ( !appState.editingLinearElement || @@ -146,7 +149,7 @@ export class LinearElementEditor { const { editingLinearElement } = appState; const { selectedPointsIndices, elementId } = editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -197,13 +200,13 @@ export class LinearElementEditor { pointSceneCoords: { x: number; y: number }[], ) => void, linearElementEditor: LinearElementEditor, - elementsMap: ElementsMap, + elementsMap: NonDeletedSceneElementsMap, ): boolean { if (!linearElementEditor) { return false; } const { selectedPointsIndices, elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return false; } @@ -331,11 +334,12 @@ export class LinearElementEditor { event: PointerEvent, editingLinearElement: LinearElementEditor, appState: AppState, - elementsMap: ElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): LinearElementEditor { const { elementId, selectedPointsIndices, isDragging, pointerDownState } = editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return editingLinearElement; } @@ -376,7 +380,8 @@ export class LinearElementEditor { elementsMap, ), ), - Scene.getScene(element)!, + elements, + elementsMap, ) : null; @@ -490,7 +495,7 @@ export class LinearElementEditor { elementsMap: ElementsMap, ) => { const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return null; } @@ -614,6 +619,7 @@ export class LinearElementEditor { ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { return -1; @@ -639,7 +645,8 @@ export class LinearElementEditor { history: History, scenePointer: { x: number; y: number }, linearElementEditor: LinearElementEditor, - elementsMap: ElementsMap, + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: NonDeletedSceneElementsMap, ): { didAddPoint: boolean; hitElement: NonDeleted | null; @@ -656,7 +663,7 @@ export class LinearElementEditor { } const { elementId } = linearElementEditor; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return ret; @@ -709,7 +716,8 @@ export class LinearElementEditor { lastUncommittedPoint: null, endBindingElement: getHoveredElementForBinding( scenePointer, - Scene.getScene(element)!, + elements, + elementsMap, ), }; @@ -813,7 +821,7 @@ export class LinearElementEditor { return null; } const { elementId, lastUncommittedPoint } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element) { return appState.editingLinearElement; } @@ -1020,14 +1028,14 @@ export class LinearElementEditor { mutateElement(element, LinearElementEditor.getNormalizedPoints(element)); } - static duplicateSelectedPoints(appState: AppState) { + static duplicateSelectedPoints(appState: AppState, elementsMap: ElementsMap) { if (!appState.editingLinearElement) { return false; } const { selectedPointsIndices, elementId } = appState.editingLinearElement; - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); if (!element || selectedPointsIndices === null) { return false; @@ -1189,9 +1197,11 @@ export class LinearElementEditor { linearElementEditor: LinearElementEditor, pointerCoords: PointerCoords, appState: AppState, + elementsMap: ElementsMap, ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { @@ -1234,6 +1244,7 @@ export class LinearElementEditor { ) { const element = LinearElementEditor.getElement( linearElementEditor.elementId, + elementsMap, ); if (!element) { return; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index d80540fd0..0e9fce164 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -354,7 +354,8 @@ const renderLinearElementPointHighlight = ( ) { return; } - const element = LinearElementEditor.getElement(elementId); + const element = LinearElementEditor.getElement(elementId, elementsMap); + if (!element) { return; } diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 625175700..06086f119 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,4 +1,3 @@ -import React from "react"; import ReactDOM from "react-dom"; import { render, fireEvent } from "./test-utils"; import { Excalidraw } from "../index"; @@ -13,7 +12,6 @@ import { import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; import { vi } from "vitest"; -import { arrayToMap } from "../utils"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); @@ -76,7 +74,7 @@ describe("move element", () => { const rectA = UI.createElement("rectangle", { size: 100 }); const rectB = UI.createElement("rectangle", { x: 200, y: 0, size: 300 }); const line = UI.createElement("line", { x: 110, y: 50, size: 80 }); - const elementsMap = arrayToMap(h.elements); + const elementsMap = h.app.scene.getNonDeletedElementsMap(); // bind line to two rectangles bindOrUnbindLinearElement( line.get() as NonDeleted, From 79d9dc2f8f86b38d1784519eb765d1a13416fdab Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Mon, 19 Feb 2024 19:39:14 +0530 Subject: [PATCH 04/66] fix: make bounds independent of scene (#7679) * fix: make bounds independent of scene * pass only elements to getCommonBounds * lint * pass elementsMap to getVisibleAndNonSelectedElements --- packages/excalidraw/components/App.tsx | 2 + packages/excalidraw/element/bounds.test.ts | 45 +++++++++++++----- packages/excalidraw/element/bounds.ts | 50 +++++++++----------- packages/excalidraw/element/sizeHelpers.ts | 5 +- packages/excalidraw/renderer/renderScene.ts | 2 +- packages/excalidraw/scene/Renderer.ts | 20 +++++--- packages/excalidraw/scene/scrollbars.ts | 7 +-- packages/excalidraw/scene/selection.ts | 13 +++-- packages/excalidraw/snapping.ts | 5 ++ packages/excalidraw/tests/clipboard.test.tsx | 13 +++-- packages/utils/withinBounds.ts | 3 +- 11 files changed, 106 insertions(+), 59 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 1f21de9cf..9f585d2e1 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -953,6 +953,7 @@ class App extends React.Component { normalizedWidth, normalizedHeight, this.state, + this.scene.getNonDeletedElementsMap(), ); const hasBeenInitialized = this.initializedEmbeds.has(el.id); @@ -1287,6 +1288,7 @@ class App extends React.Component { scrollY: this.state.scrollY, zoom: this.state.zoom, }, + this.scene.getNonDeletedElementsMap(), ) ) { // if frame not visible, don't render its name diff --git a/packages/excalidraw/element/bounds.test.ts b/packages/excalidraw/element/bounds.test.ts index 253137b07..e495343f7 100644 --- a/packages/excalidraw/element/bounds.test.ts +++ b/packages/excalidraw/element/bounds.test.ts @@ -62,9 +62,15 @@ describe("getElementAbsoluteCoords", () => { describe("getElementBounds", () => { it("rectangle", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "rectangle" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "rectangle", + }); + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(39.39339828220179); expect(y1).toEqual(24.393398282201787); expect(x2).toEqual(60.60660171779821); @@ -72,9 +78,17 @@ describe("getElementBounds", () => { }); it("diamond", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "diamond" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "diamond", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); + expect(x1).toEqual(42.928932188134524); expect(y1).toEqual(27.928932188134524); expect(x2).toEqual(57.071067811865476); @@ -82,9 +96,16 @@ describe("getElementBounds", () => { }); it("ellipse", () => { - const [x1, y1, x2, y2] = getElementBounds( - _ce({ x: 40, y: 30, w: 20, h: 10, a: Math.PI / 4, t: "ellipse" }), - ); + const element = _ce({ + x: 40, + y: 30, + w: 20, + h: 10, + a: Math.PI / 4, + t: "ellipse", + }); + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(42.09430584957905); expect(y1).toEqual(27.09430584957905); expect(x2).toEqual(57.90569415042095); @@ -92,7 +113,7 @@ describe("getElementBounds", () => { }); it("curved line", () => { - const [x1, y1, x2, y2] = getElementBounds({ + const element = { ..._ce({ t: "line", x: 449.58203125, @@ -106,7 +127,9 @@ describe("getElementBounds", () => { [67.33984375, 92.48828125] as [number, number], [-102.7890625, 52.15625] as [number, number], ], - } as ExcalidrawLinearElement); + } as ExcalidrawLinearElement; + + const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); expect(x1).toEqual(360.3176068760539); expect(y1).toEqual(185.90654264413516); expect(x2).toEqual(480.87005902729743); diff --git a/packages/excalidraw/element/bounds.ts b/packages/excalidraw/element/bounds.ts index 7eb7fa48a..e7c6f7fb3 100644 --- a/packages/excalidraw/element/bounds.ts +++ b/packages/excalidraw/element/bounds.ts @@ -5,7 +5,6 @@ import { ExcalidrawFreeDrawElement, NonDeleted, ExcalidrawTextElementWithContainer, - ElementsMapOrArray, ElementsMap, } from "./types"; import { distance2d, rotate, rotatePoint } from "../math"; @@ -25,7 +24,7 @@ import { getBoundTextElement, getContainerElement } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; import { Mutable } from "../utility-types"; import { ShapeCache } from "../scene/ShapeCache"; -import Scene from "../scene/Scene"; +import { arrayToMap } from "../utils"; export type RectangleBox = { x: number; @@ -63,7 +62,7 @@ export class ElementBounds { } >(); - static getBounds(element: ExcalidrawElement) { + static getBounds(element: ExcalidrawElement, elementsMap: ElementsMap) { const cachedBounds = ElementBounds.boundsCache.get(element); if ( @@ -75,23 +74,12 @@ export class ElementBounds { ) { return cachedBounds.bounds; } - const scene = Scene.getScene(element); - const bounds = ElementBounds.calculateBounds( - element, - scene?.getNonDeletedElementsMap() || new Map(), - ); + const bounds = ElementBounds.calculateBounds(element, elementsMap); - // hack to ensure that downstream checks could retrieve element Scene - // so as to have correctly calculated bounds - // FIXME remove when we get rid of all the id:Scene / element:Scene mapping - const shouldCache = !!scene; - - if (shouldCache) { - ElementBounds.boundsCache.set(element, { - version: element.version, - bounds, - }); - } + ElementBounds.boundsCache.set(element, { + version: element.version, + bounds, + }); return bounds; } @@ -748,11 +736,17 @@ const getLinearElementRotatedBounds = ( return coords; }; -export const getElementBounds = (element: ExcalidrawElement): Bounds => { - return ElementBounds.getBounds(element); +export const getElementBounds = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, +): Bounds => { + return ElementBounds.getBounds(element, elementsMap); }; -export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => { - if ("size" in elements ? !elements.size : !elements.length) { + +export const getCommonBounds = ( + elements: readonly ExcalidrawElement[], +): Bounds => { + if (!elements.length) { return [0, 0, 0, 0]; } @@ -761,8 +755,10 @@ export const getCommonBounds = (elements: ElementsMapOrArray): Bounds => { let minY = Infinity; let maxY = -Infinity; + const elementsMap = arrayToMap(elements); + elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element); + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); minX = Math.min(minX, x1); minY = Math.min(minY, y1); maxX = Math.max(maxX, x2); @@ -868,9 +864,9 @@ export const getClosestElementBounds = ( let minDistance = Infinity; let closestElement = elements[0]; - + const elementsMap = arrayToMap(elements); elements.forEach((element) => { - const [x1, y1, x2, y2] = getElementBounds(element); + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); const distance = distance2d((x1 + x2) / 2, (y1 + y2) / 2, from.x, from.y); if (distance < minDistance) { @@ -879,7 +875,7 @@ export const getClosestElementBounds = ( } }); - return getElementBounds(closestElement); + return getElementBounds(closestElement, elementsMap); }; export interface BoundingBox { diff --git a/packages/excalidraw/element/sizeHelpers.ts b/packages/excalidraw/element/sizeHelpers.ts index 1b69ca0bc..e30ea9877 100644 --- a/packages/excalidraw/element/sizeHelpers.ts +++ b/packages/excalidraw/element/sizeHelpers.ts @@ -1,4 +1,4 @@ -import { ExcalidrawElement } from "./types"; +import { ElementsMap, ExcalidrawElement } from "./types"; import { mutateElement } from "./mutateElement"; import { isFreeDrawElement, isLinearElement } from "./typeChecks"; import { SHIFT_LOCKING_ANGLE } from "../constants"; @@ -26,8 +26,9 @@ export const isElementInViewport = ( scrollX: number; scrollY: number; }, + elementsMap: ElementsMap, ) => { - const [x1, y1, x2, y2] = getElementBounds(element); // scene coordinates + const [x1, y1, x2, y2] = getElementBounds(element, elementsMap); // scene coordinates const topLeftSceneCoords = viewportCoordsToSceneCoords( { clientX: viewTransformations.offsetLeft, diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 0e9fce164..62c59b6f8 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -886,7 +886,7 @@ const _renderInteractiveScene = ({ let scrollBars; if (renderConfig.renderScrollbars) { scrollBars = getScrollBars( - elementsMap, + visibleElements, normalizedWidth, normalizedHeight, appState, diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 1593d6d2e..0875b9f05 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -40,13 +40,19 @@ export class Renderer { const visibleElements: NonDeletedExcalidrawElement[] = []; for (const element of elementsMap.values()) { if ( - isElementInViewport(element, width, height, { - zoom, - offsetLeft, - offsetTop, - scrollX, - scrollY, - }) + isElementInViewport( + element, + width, + height, + { + zoom, + offsetLeft, + offsetTop, + scrollX, + scrollY, + }, + elementsMap, + ) ) { visibleElements.push(element); } diff --git a/packages/excalidraw/scene/scrollbars.ts b/packages/excalidraw/scene/scrollbars.ts index 14009588b..0e0e6d0ab 100644 --- a/packages/excalidraw/scene/scrollbars.ts +++ b/packages/excalidraw/scene/scrollbars.ts @@ -1,20 +1,21 @@ import { getCommonBounds } from "../element"; import { InteractiveCanvasAppState } from "../types"; -import { RenderableElementsMap, ScrollBars } from "./types"; +import { ScrollBars } from "./types"; import { getGlobalCSSVariable } from "../utils"; import { getLanguage } from "../i18n"; +import { ExcalidrawElement } from "../element/types"; export const SCROLLBAR_MARGIN = 4; export const SCROLLBAR_WIDTH = 6; export const SCROLLBAR_COLOR = "rgba(0,0,0,0.3)"; export const getScrollBars = ( - elements: RenderableElementsMap, + elements: readonly ExcalidrawElement[], viewportWidth: number, viewportHeight: number, appState: InteractiveCanvasAppState, ): ScrollBars => { - if (!elements.size) { + if (!elements.length) { return { horizontal: null, vertical: null, diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 27d4db1c9..3c3df898e 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -52,12 +52,17 @@ export const getElementsWithinSelection = ( getElementAbsoluteCoords(selection, elementsMap); let elementsInSelection = elements.filter((element) => { - let [elementX1, elementY1, elementX2, elementY2] = - getElementBounds(element); + let [elementX1, elementY1, elementX2, elementY2] = getElementBounds( + element, + elementsMap, + ); const containingFrame = getContainingFrame(element); if (containingFrame) { - const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame); + const [fx1, fy1, fx2, fy2] = getElementBounds( + containingFrame, + elementsMap, + ); elementX1 = Math.max(fx1, elementX1); elementY1 = Math.max(fy1, elementY1); @@ -97,6 +102,7 @@ export const getVisibleAndNonSelectedElements = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: readonly NonDeletedExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const selectedElementsSet = new Set( selectedElements.map((element) => element.id), @@ -107,6 +113,7 @@ export const getVisibleAndNonSelectedElements = ( appState.width, appState.height, appState, + elementsMap, ); return !selectedElementsSet.has(element.id) && isVisible; diff --git a/packages/excalidraw/snapping.ts b/packages/excalidraw/snapping.ts index 3061c02d4..bc83d0057 100644 --- a/packages/excalidraw/snapping.ts +++ b/packages/excalidraw/snapping.ts @@ -269,6 +269,7 @@ const getReferenceElements = ( elements: readonly NonDeletedExcalidrawElement[], selectedElements: NonDeletedExcalidrawElement[], appState: AppState, + elementsMap: ElementsMap, ) => { const selectedFrames = selectedElements .filter((element) => isFrameLikeElement(element)) @@ -278,6 +279,7 @@ const getReferenceElements = ( elements, selectedElements, appState, + elementsMap, ).filter( (element) => !(element.frameId && selectedFrames.includes(element.frameId)), ); @@ -293,6 +295,7 @@ export const getVisibleGaps = ( elements, selectedElements, appState, + elementsMap, ); const referenceBounds = getMaximumGroups(referenceElements, elementsMap) @@ -580,6 +583,7 @@ export const getReferenceSnapPoints = ( elements, selectedElements, appState, + elementsMap, ); return getMaximumGroups(referenceElements, elementsMap) .filter( @@ -1296,6 +1300,7 @@ export const getSnapLinesAtPointer = ( elements, [], appState, + elementsMap, ); const snapDistance = getSnapDistance(appState.zoom.value); diff --git a/packages/excalidraw/tests/clipboard.test.tsx b/packages/excalidraw/tests/clipboard.test.tsx index 149ebcd1e..1a6fe47be 100644 --- a/packages/excalidraw/tests/clipboard.test.tsx +++ b/packages/excalidraw/tests/clipboard.test.tsx @@ -12,6 +12,7 @@ import { getElementBounds } from "../element"; import { NormalizedZoomValue } from "../types"; import { API } from "./helpers/api"; import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard"; +import { arrayToMap } from "../utils"; const { h } = window; @@ -138,6 +139,8 @@ describe("paste text as single lines", () => { }); it("should space items correctly", async () => { + const elementsMap = arrayToMap(h.elements); + const text = "hkhkjhki\njgkjhffjh\njgkjhffjh"; const lineHeightPx = getLineHeightInPx( @@ -149,16 +152,17 @@ describe("paste text as single lines", () => { pasteWithCtrlCmdV(text); await waitFor(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [fx, firstElY] = getElementBounds(h.elements[0]); + const [fx, firstElY] = getElementBounds(h.elements[0], elementsMap); for (let i = 1; i < h.elements.length; i++) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [fx, elY] = getElementBounds(h.elements[i]); + const [fx, elY] = getElementBounds(h.elements[i], elementsMap); expect(elY).toEqual(firstElY + lineHeightPx * i); } }); }); it("should leave a space for blank new lines", async () => { + const elementsMap = arrayToMap(h.elements); const text = "hkhkjhki\n\njgkjhffjh"; const lineHeightPx = getLineHeightInPx( @@ -168,11 +172,12 @@ describe("paste text as single lines", () => { 10 / h.app.state.zoom.value; mouse.moveTo(100, 100); pasteWithCtrlCmdV(text); + await waitFor(async () => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [fx, firstElY] = getElementBounds(h.elements[0]); + const [fx, firstElY] = getElementBounds(h.elements[0], elementsMap); // eslint-disable-next-line @typescript-eslint/no-unused-vars - const [lx, lastElY] = getElementBounds(h.elements[1]); + const [lx, lastElY] = getElementBounds(h.elements[1], elementsMap); expect(lastElY).toEqual(firstElY + lineHeightPx * 2); }); }); diff --git a/packages/utils/withinBounds.ts b/packages/utils/withinBounds.ts index 637cab3e1..6a380d9c3 100644 --- a/packages/utils/withinBounds.ts +++ b/packages/utils/withinBounds.ts @@ -14,6 +14,7 @@ import { import { isValueInRange, rotatePoint } from "../excalidraw/math"; import type { Point } from "../excalidraw/types"; import { Bounds, getElementBounds } from "../excalidraw/element/bounds"; +import { arrayToMap } from "../excalidraw/utils"; type Element = NonDeletedExcalidrawElement; type Elements = readonly NonDeletedExcalidrawElement[]; @@ -158,7 +159,7 @@ export const elementsOverlappingBBox = ({ type: "overlap" | "contain" | "inside"; }) => { if (isExcalidrawElement(bounds)) { - bounds = getElementBounds(bounds); + bounds = getElementBounds(bounds, arrayToMap(elements)); } const adjustedBBox: Bounds = [ bounds[0] - errorMargin, From 2e719ff6712750de50a83b873897ea3557283605 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 20 Feb 2024 20:59:01 +0530 Subject: [PATCH 05/66] fix: decouple pure functions from hyperlink to prevent mermaid bundling (#7710) * move hyperlink code into its folder * move pure js functions to hyperlink/helpers and move actionLink to actions * fix tests * fix --- packages/excalidraw/actions/actionLink.tsx | 54 ++++++ packages/excalidraw/actions/index.ts | 2 +- packages/excalidraw/components/App.tsx | 38 ++-- .../hyperlink}/Hyperlink.scss | 2 +- .../hyperlink}/Hyperlink.tsx | 175 ++---------------- .../components/hyperlink/helpers.ts | 93 ++++++++++ packages/excalidraw/renderer/renderScene.ts | 2 +- packages/excalidraw/tests/helpers/api.ts | 3 + packages/excalidraw/tests/helpers/ui.ts | 5 +- 9 files changed, 198 insertions(+), 176 deletions(-) create mode 100644 packages/excalidraw/actions/actionLink.tsx rename packages/excalidraw/{element => components/hyperlink}/Hyperlink.scss (96%) rename packages/excalidraw/{element => components/hyperlink}/Hyperlink.tsx (70%) create mode 100644 packages/excalidraw/components/hyperlink/helpers.ts diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx new file mode 100644 index 000000000..f7710874e --- /dev/null +++ b/packages/excalidraw/actions/actionLink.tsx @@ -0,0 +1,54 @@ +import { getContextMenuLabel } from "../components/hyperlink/Hyperlink"; +import { LinkIcon } from "../components/icons"; +import { ToolButton } from "../components/ToolButton"; +import { isEmbeddableElement } from "../element/typeChecks"; +import { t } from "../i18n"; +import { KEYS } from "../keys"; +import { getSelectedElements } from "../scene"; +import { getShortcutKey } from "../utils"; +import { register } from "./register"; + +export const actionLink = register({ + name: "hyperlink", + perform: (elements, appState) => { + if (appState.showHyperlinkPopup === "editor") { + return false; + } + + return { + elements, + appState: { + ...appState, + showHyperlinkPopup: "editor", + openMenu: null, + }, + commitToHistory: true, + }; + }, + trackEvent: { category: "hyperlink", action: "click" }, + keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, + contextItemLabel: (elements, appState) => + getContextMenuLabel(elements, appState), + predicate: (elements, appState) => { + const selectedElements = getSelectedElements(elements, appState); + return selectedElements.length === 1; + }, + PanelComponent: ({ elements, appState, updateData }) => { + const selectedElements = getSelectedElements(elements, appState); + + return ( + updateData(null)} + selected={selectedElements.length === 1 && !!selectedElements[0].link} + /> + ); + }, +}); diff --git a/packages/excalidraw/actions/index.ts b/packages/excalidraw/actions/index.ts index b4551acf5..092060425 100644 --- a/packages/excalidraw/actions/index.ts +++ b/packages/excalidraw/actions/index.ts @@ -83,6 +83,6 @@ export { actionToggleObjectsSnapMode } from "./actionToggleObjectsSnapMode"; export { actionToggleStats } from "./actionToggleStats"; export { actionUnbindText, actionBindText } from "./actionBoundText"; -export { actionLink } from "../element/Hyperlink"; +export { actionLink } from "./actionLink"; export { actionToggleElementLock } from "./actionElementLock"; export { actionToggleLinearEditor } from "./actionLinearEditor"; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 9f585d2e1..7b310ca38 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -326,9 +326,7 @@ import { showHyperlinkTooltip, hideHyperlinkToolip, Hyperlink, - isPointHittingLink, - isPointHittingLinkIcon, -} from "../element/Hyperlink"; +} from "../components/hyperlink/Hyperlink"; import { isLocalLink, normalizeLink, toValidURL } from "../data/url"; import { shouldShowBoundingBox } from "../element/transformHandles"; import { actionUnlockAllElements } from "../actions/actionElementLock"; @@ -410,6 +408,10 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; import { textWysiwyg } from "../element/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; +import { + isPointHittingLink, + isPointHittingLinkIcon, +} from "./hyperlink/helpers"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); @@ -9571,7 +9573,6 @@ class App extends React.Component { // ----------------------------------------------------------------------------- // TEST HOOKS // ----------------------------------------------------------------------------- - declare global { interface Window { h: { @@ -9584,20 +9585,23 @@ declare global { } } -if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { - window.h = window.h || ({} as Window["h"]); +export const createTestHook = () => { + if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) { + window.h = window.h || ({} as Window["h"]); - Object.defineProperties(window.h, { - elements: { - configurable: true, - get() { - return this.app?.scene.getElementsIncludingDeleted(); + Object.defineProperties(window.h, { + elements: { + configurable: true, + get() { + return this.app?.scene.getElementsIncludingDeleted(); + }, + set(elements: ExcalidrawElement[]) { + return this.app?.scene.replaceAllElements(elements); + }, }, - set(elements: ExcalidrawElement[]) { - return this.app?.scene.replaceAllElements(elements); - }, - }, - }); -} + }); + } +}; +createTestHook(); export default App; diff --git a/packages/excalidraw/element/Hyperlink.scss b/packages/excalidraw/components/hyperlink/Hyperlink.scss similarity index 96% rename from packages/excalidraw/element/Hyperlink.scss rename to packages/excalidraw/components/hyperlink/Hyperlink.scss index ba7e86373..6a5db325a 100644 --- a/packages/excalidraw/element/Hyperlink.scss +++ b/packages/excalidraw/components/hyperlink/Hyperlink.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@import "../../css/variables.module.scss"; .excalidraw-hyperlinkContainer { display: flex; diff --git a/packages/excalidraw/element/Hyperlink.tsx b/packages/excalidraw/components/hyperlink/Hyperlink.tsx similarity index 70% rename from packages/excalidraw/element/Hyperlink.tsx rename to packages/excalidraw/components/hyperlink/Hyperlink.tsx index 29b76d31d..c87ff773c 100644 --- a/packages/excalidraw/element/Hyperlink.tsx +++ b/packages/excalidraw/components/hyperlink/Hyperlink.tsx @@ -1,22 +1,20 @@ -import { AppState, ExcalidrawProps, Point, UIAppState } from "../types"; +import { AppState, ExcalidrawProps, Point } from "../../types"; import { - getShortcutKey, sceneCoordsToViewportCoords, viewportCoordsToSceneCoords, wrapEvent, -} from "../utils"; -import { getEmbedLink, embeddableURLValidator } from "./embeddable"; -import { mutateElement } from "./mutateElement"; +} from "../../utils"; +import { getEmbedLink, embeddableURLValidator } from "../../element/embeddable"; +import { mutateElement } from "../../element/mutateElement"; import { ElementsMap, ExcalidrawEmbeddableElement, NonDeletedExcalidrawElement, -} from "./types"; +} from "../../element/types"; -import { register } from "../actions/register"; -import { ToolButton } from "../components/ToolButton"; -import { FreedrawIcon, LinkIcon, TrashIcon } from "../components/icons"; -import { t } from "../i18n"; +import { ToolButton } from "../ToolButton"; +import { FreedrawIcon, TrashIcon } from "../icons"; +import { t } from "../../i18n"; import { useCallback, useEffect, @@ -25,21 +23,19 @@ import { useState, } from "react"; import clsx from "clsx"; -import { KEYS } from "../keys"; -import { DEFAULT_LINK_SIZE } from "../renderer/renderElement"; -import { rotate } from "../math"; -import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants"; -import { Bounds } from "./bounds"; -import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip"; -import { getSelectedElements } from "../scene"; -import { isPointHittingElementBoundingBox } from "./collision"; -import { getElementAbsoluteCoords } from "."; -import { isLocalLink, normalizeLink } from "../data/url"; +import { KEYS } from "../../keys"; +import { EVENT, HYPERLINK_TOOLTIP_DELAY } from "../../constants"; +import { getElementAbsoluteCoords } from "../../element/bounds"; +import { getTooltipDiv, updateTooltipPosition } from "../Tooltip"; +import { getSelectedElements } from "../../scene"; +import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { isLocalLink, normalizeLink } from "../../data/url"; import "./Hyperlink.scss"; -import { trackEvent } from "../analytics"; -import { useAppProps, useExcalidrawAppState } from "../components/App"; -import { isEmbeddableElement } from "./typeChecks"; +import { trackEvent } from "../../analytics"; +import { useAppProps, useExcalidrawAppState } from "../App"; +import { isEmbeddableElement } from "../../element/typeChecks"; +import { getLinkHandleFromCoords } from "./helpers"; const CONTAINER_WIDTH = 320; const SPACE_BOTTOM = 85; @@ -47,11 +43,6 @@ const CONTAINER_PADDING = 5; const CONTAINER_HEIGHT = 42; const AUTO_HIDE_TIMEOUT = 500; -export const EXTERNAL_LINK_IMG = document.createElement("img"); -EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( - ``, -)}`; - let IS_HYPERLINK_TOOLTIP_VISIBLE = false; const embeddableLinkCache = new Map< @@ -339,51 +330,6 @@ const getCoordsForPopover = ( return { x, y }; }; -export const actionLink = register({ - name: "hyperlink", - perform: (elements, appState) => { - if (appState.showHyperlinkPopup === "editor") { - return false; - } - - return { - elements, - appState: { - ...appState, - showHyperlinkPopup: "editor", - openMenu: null, - }, - commitToHistory: true, - }; - }, - trackEvent: { category: "hyperlink", action: "click" }, - keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.K, - contextItemLabel: (elements, appState) => - getContextMenuLabel(elements, appState), - predicate: (elements, appState) => { - const selectedElements = getSelectedElements(elements, appState); - return selectedElements.length === 1; - }, - PanelComponent: ({ elements, appState, updateData }) => { - const selectedElements = getSelectedElements(elements, appState); - - return ( - updateData(null)} - selected={selectedElements.length === 1 && !!selectedElements[0].link} - /> - ); - }, -}); - export const getContextMenuLabel = ( elements: readonly NonDeletedExcalidrawElement[], appState: AppState, @@ -399,87 +345,6 @@ export const getContextMenuLabel = ( return label; }; -export const getLinkHandleFromCoords = ( - [x1, y1, x2, y2]: Bounds, - angle: number, - appState: Pick, -): Bounds => { - const size = DEFAULT_LINK_SIZE; - const linkWidth = size / appState.zoom.value; - const linkHeight = size / appState.zoom.value; - const linkMarginY = size / appState.zoom.value; - const centerX = (x1 + x2) / 2; - const centerY = (y1 + y2) / 2; - const centeringOffset = (size - 8) / (2 * appState.zoom.value); - const dashedLineMargin = 4 / appState.zoom.value; - - // Same as `ne` resize handle - const x = x2 + dashedLineMargin - centeringOffset; - const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; - - const [rotatedX, rotatedY] = rotate( - x + linkWidth / 2, - y + linkHeight / 2, - centerX, - centerY, - angle, - ); - return [ - rotatedX - linkWidth / 2, - rotatedY - linkHeight / 2, - linkWidth, - linkHeight, - ]; -}; - -export const isPointHittingLinkIcon = ( - element: NonDeletedExcalidrawElement, - elementsMap: ElementsMap, - appState: AppState, - [x, y]: Point, -) => { - const threshold = 4 / appState.zoom.value; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( - [x1, y1, x2, y2], - element.angle, - appState, - ); - const hitLink = - x > linkX - threshold && - x < linkX + threshold + linkWidth && - y > linkY - threshold && - y < linkY + linkHeight + threshold; - return hitLink; -}; - -export const isPointHittingLink = ( - element: NonDeletedExcalidrawElement, - elementsMap: ElementsMap, - appState: AppState, - [x, y]: Point, - isMobile: boolean, -) => { - if (!element.link || appState.selectedElementIds[element.id]) { - return false; - } - const threshold = 4 / appState.zoom.value; - if ( - !isMobile && - appState.viewModeEnabled && - isPointHittingElementBoundingBox( - element, - elementsMap, - [x, y], - threshold, - null, - ) - ) { - return true; - } - return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); -}; - let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null; export const showHyperlinkTooltip = ( element: NonDeletedExcalidrawElement, @@ -547,7 +412,7 @@ export const hideHyperlinkToolip = () => { } }; -export const shouldHideLinkPopup = ( +const shouldHideLinkPopup = ( element: NonDeletedExcalidrawElement, elementsMap: ElementsMap, appState: AppState, diff --git a/packages/excalidraw/components/hyperlink/helpers.ts b/packages/excalidraw/components/hyperlink/helpers.ts new file mode 100644 index 000000000..9b7da3d76 --- /dev/null +++ b/packages/excalidraw/components/hyperlink/helpers.ts @@ -0,0 +1,93 @@ +import { MIME_TYPES } from "../../constants"; +import { Bounds, getElementAbsoluteCoords } from "../../element/bounds"; +import { isPointHittingElementBoundingBox } from "../../element/collision"; +import { ElementsMap, NonDeletedExcalidrawElement } from "../../element/types"; +import { rotate } from "../../math"; +import { DEFAULT_LINK_SIZE } from "../../renderer/renderElement"; +import { AppState, Point, UIAppState } from "../../types"; + +export const EXTERNAL_LINK_IMG = document.createElement("img"); +EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent( + ``, +)}`; + +export const getLinkHandleFromCoords = ( + [x1, y1, x2, y2]: Bounds, + angle: number, + appState: Pick, +): Bounds => { + const size = DEFAULT_LINK_SIZE; + const linkWidth = size / appState.zoom.value; + const linkHeight = size / appState.zoom.value; + const linkMarginY = size / appState.zoom.value; + const centerX = (x1 + x2) / 2; + const centerY = (y1 + y2) / 2; + const centeringOffset = (size - 8) / (2 * appState.zoom.value); + const dashedLineMargin = 4 / appState.zoom.value; + + // Same as `ne` resize handle + const x = x2 + dashedLineMargin - centeringOffset; + const y = y1 - dashedLineMargin - linkMarginY + centeringOffset; + + const [rotatedX, rotatedY] = rotate( + x + linkWidth / 2, + y + linkHeight / 2, + centerX, + centerY, + angle, + ); + return [ + rotatedX - linkWidth / 2, + rotatedY - linkHeight / 2, + linkWidth, + linkHeight, + ]; +}; + +export const isPointHittingLinkIcon = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + appState: AppState, + [x, y]: Point, +) => { + const threshold = 4 / appState.zoom.value; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const hitLink = + x > linkX - threshold && + x < linkX + threshold + linkWidth && + y > linkY - threshold && + y < linkY + linkHeight + threshold; + return hitLink; +}; + +export const isPointHittingLink = ( + element: NonDeletedExcalidrawElement, + elementsMap: ElementsMap, + appState: AppState, + [x, y]: Point, + isMobile: boolean, +) => { + if (!element.link || appState.selectedElementIds[element.id]) { + return false; + } + const threshold = 4 / appState.zoom.value; + if ( + !isMobile && + appState.viewModeEnabled && + isPointHittingElementBoundingBox( + element, + elementsMap, + [x, y], + threshold, + null, + ) + ) { + return true; + } + return isPointHittingLinkIcon(element, elementsMap, appState, [x, y]); +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/renderScene.ts index 62c59b6f8..69926b72d 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/renderScene.ts @@ -73,7 +73,7 @@ import { import { EXTERNAL_LINK_IMG, getLinkHandleFromCoords, -} from "../element/Hyperlink"; +} from "../components/hyperlink/helpers"; import { renderSnaps } from "./renderSnaps"; import { isEmbeddableElement, diff --git a/packages/excalidraw/tests/helpers/api.ts b/packages/excalidraw/tests/helpers/api.ts index d22d3f221..503ebfc01 100644 --- a/packages/excalidraw/tests/helpers/api.ts +++ b/packages/excalidraw/tests/helpers/api.ts @@ -31,8 +31,11 @@ import { getSelectedElements } from "../../scene/selection"; import { isLinearElementType } from "../../element/typeChecks"; import { Mutable } from "../../utility-types"; import { assertNever } from "../../utils"; +import { createTestHook } from "../../components/App"; const readFile = util.promisify(fs.readFile); +// so that window.h is available when App.tsx is not imported as well. +createTestHook(); const { h } = window; diff --git a/packages/excalidraw/tests/helpers/ui.ts b/packages/excalidraw/tests/helpers/ui.ts index 42685b866..c03b889df 100644 --- a/packages/excalidraw/tests/helpers/ui.ts +++ b/packages/excalidraw/tests/helpers/ui.ts @@ -33,6 +33,10 @@ import { getCommonBounds, getElementPointsCoords } from "../../element/bounds"; import { rotatePoint } from "../../math"; import { getTextEditor } from "../queries/dom"; import { arrayToMap } from "../../utils"; +import { createTestHook } from "../../components/App"; + +// so that window.h is available when App.tsx is not imported as well. +createTestHook(); const { h } = window; @@ -460,7 +464,6 @@ export class UI { mouse.reset(); mouse.up(x + width, y + height); } - const origElement = h.elements[h.elements.length - 1] as any; if (angle !== 0) { From 361a9449bb53beaf8a36a0c15338b648aff1cf22 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 21 Feb 2024 16:34:20 +0530 Subject: [PATCH 06/66] fix: remove scene hack from export.ts & remove pass elementsMap to getContainingFrame (#7713) * fix: remove scene hack from export.ts as its not needed anymore * remove * pass elementsMap to getContainingFrame * remove unused `mapElementIds` param --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/clipboard.ts | 7 ++-- packages/excalidraw/components/App.tsx | 6 +-- packages/excalidraw/frame.ts | 26 ++++-------- packages/excalidraw/renderer/renderElement.ts | 20 ++++++--- packages/excalidraw/scene/Scene.ts | 25 +++-------- packages/excalidraw/scene/export.ts | 42 +------------------ packages/excalidraw/scene/selection.ts | 4 +- 7 files changed, 38 insertions(+), 92 deletions(-) diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index a88402d69..c6b6082ff 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -16,7 +16,7 @@ import { import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; -import { isMemberOf, isPromiseLike } from "./utils"; +import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; import { t } from "./i18n"; type ElementsClipboard = { @@ -126,6 +126,7 @@ export const serializeAsClipboardJSON = ({ elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | null; }) => { + const elementsMap = arrayToMap(elements); const framesToCopy = new Set( elements.filter((element) => isFrameLikeElement(element)), ); @@ -152,8 +153,8 @@ export const serializeAsClipboardJSON = ({ type: EXPORT_DATA_TYPES.excalidrawClipboard, elements: elements.map((element) => { if ( - getContainingFrame(element) && - !framesToCopy.has(getContainingFrame(element)!) + getContainingFrame(element, elementsMap) && + !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { const copiedElement = deepCopyElement(element); mutateElement(copiedElement, { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 7b310ca38..c9985c88d 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -1131,7 +1131,7 @@ class App extends React.Component { display: isVisible ? "block" : "none", opacity: getRenderOpacity( el, - getContainingFrame(el), + getContainingFrame(el, this.scene.getNonDeletedElementsMap()), this.elementsPendingErasure, ), ["--embeddable-radius" as string]: `${getCornerRadius( @@ -4399,7 +4399,7 @@ class App extends React.Component { ), ).filter((element) => { // hitting a frame's element from outside the frame is not considered a hit - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); return containingFrame && this.state.frameRendering.enabled && this.state.frameRendering.clip @@ -7789,7 +7789,7 @@ class App extends React.Component { ); if (linearElement?.frameId) { - const frame = getContainingFrame(linearElement); + const frame = getContainingFrame(linearElement, elementsMap); if (frame && linearElement) { if ( diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index 8f550e86a..cc80531ee 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -21,7 +21,7 @@ import { mutateElement } from "./element/mutateElement"; import { AppClassProperties, AppState, StaticCanvasAppState } from "./types"; import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; -import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; +import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; import { doLineSegmentsIntersect, @@ -377,25 +377,13 @@ export const getElementsInNewFrame = ( export const getContainingFrame = ( element: ExcalidrawElement, - /** - * Optionally an elements map, in case the elements aren't in the Scene yet. - * Takes precedence over Scene elements, even if the element exists - * in Scene elements and not the supplied elements map. - */ - elementsMap?: Map, + elementsMap: ElementsMap, ) => { - if (element.frameId) { - if (elementsMap) { - return (elementsMap.get(element.frameId) || - null) as null | ExcalidrawFrameLikeElement; - } - return ( - (Scene.getScene(element)?.getElement( - element.frameId, - ) as ExcalidrawFrameLikeElement) || null - ); + if (!element.frameId) { + return null; } - return null; + return (elementsMap.get(element.frameId) || + null) as null | ExcalidrawFrameLikeElement; }; // --------------------------- Frame Operations ------------------------------- @@ -697,7 +685,7 @@ export const getTargetFrame = ( return appState.selectedElementIds[_element.id] && appState.selectedElementsAreBeingDragged ? appState.frameToHighlight - : getContainingFrame(_element); + : getContainingFrame(_element, elementsMap); }; // TODO: this a huge bottleneck for large scenes, optimise diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a0b8228c9..637a9fe1e 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -257,7 +257,8 @@ const generateElementCanvas = ( canvasOffsetY, boundTextElementVersion: getBoundTextElement(element, elementsMap)?.version || null, - containingFrameOpacity: getContainingFrame(element)?.opacity || 100, + containingFrameOpacity: + getContainingFrame(element, elementsMap)?.opacity || 100, }; }; @@ -440,7 +441,8 @@ const generateElementWithCanvas = ( const boundTextElementVersion = getBoundTextElement(element, elementsMap)?.version || null; - const containingFrameOpacity = getContainingFrame(element)?.opacity || 100; + const containingFrameOpacity = + getContainingFrame(element, elementsMap)?.opacity || 100; if ( !prevElementWithCanvas || @@ -652,7 +654,7 @@ export const renderElement = ( ) => { context.globalAlpha = getRenderOpacity( element, - getContainingFrame(element), + getContainingFrame(element, elementsMap), renderConfig.elementsPendingErasure, ); @@ -924,11 +926,12 @@ const maybeWrapNodesInFrameClipPath = ( root: SVGElement, nodes: SVGElement[], frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, ) => { if (!frameRendering.enabled || !frameRendering.clip) { return null; } - const frame = getContainingFrame(element); + const frame = getContainingFrame(element, elementsMap); if (frame) { const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); @@ -990,7 +993,9 @@ export const renderElementToSvg = ( }; const opacity = - ((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000; + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; switch (element.type) { case "selection": { @@ -1024,6 +1029,7 @@ export const renderElementToSvg = ( root, [node], renderConfig.frameRendering, + elementsMap, ); addToRoot(g || node, element); @@ -1215,6 +1221,7 @@ export const renderElementToSvg = ( root, [group, maskPath], renderConfig.frameRendering, + elementsMap, ); if (g) { addToRoot(g, element); @@ -1258,6 +1265,7 @@ export const renderElementToSvg = ( root, [node], renderConfig.frameRendering, + elementsMap, ); addToRoot(g || node, element); @@ -1355,6 +1363,7 @@ export const renderElementToSvg = ( root, [g], renderConfig.frameRendering, + elementsMap, ); addToRoot(clipG || g, element); } @@ -1442,6 +1451,7 @@ export const renderElementToSvg = ( root, [node], renderConfig.frameRendering, + elementsMap, ); addToRoot(g || node, element); diff --git a/packages/excalidraw/scene/Scene.ts b/packages/excalidraw/scene/Scene.ts index 88c3d8996..c76b81a82 100644 --- a/packages/excalidraw/scene/Scene.ts +++ b/packages/excalidraw/scene/Scene.ts @@ -80,29 +80,16 @@ class Scene { private static sceneMapByElement = new WeakMap(); private static sceneMapById = new Map(); - static mapElementToScene( - elementKey: ElementKey, - scene: Scene, - /** - * needed because of frame exporting hack. - * elementId:Scene mapping will be removed completely, soon. - */ - mapElementIds = true, - ) { + static mapElementToScene(elementKey: ElementKey, scene: Scene) { if (isIdKey(elementKey)) { - if (!mapElementIds) { - return; - } // for cases where we don't have access to the element object // (e.g. restore serialized appState with id references) this.sceneMapById.set(elementKey, scene); } else { this.sceneMapByElement.set(elementKey, scene); - if (!mapElementIds) { - // if mapping element objects, also cache the id string when later - // looking up by id alone - this.sceneMapById.set(elementKey.id, scene); - } + // if mapping element objects, also cache the id string when later + // looking up by id alone + this.sceneMapById.set(elementKey.id, scene); } } @@ -256,7 +243,7 @@ class Scene { return didChange; } - replaceAllElements(nextElements: ElementsMapOrArray, mapElementIds = true) { + replaceAllElements(nextElements: ElementsMapOrArray) { this.elements = // ts doesn't like `Array.isArray` of `instanceof Map` nextElements instanceof Array @@ -269,7 +256,7 @@ class Scene { nextFrameLikes.push(element); } this.elementsMap.set(element.id, element); - Scene.mapElementToScene(element, this, mapElementIds); + Scene.mapElementToScene(element, this); }); const nonDeletedElements = getNonDeletedElements(this.elements); this.nonDeletedElements = nonDeletedElements.elements; diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index a8d08c900..42a417cc8 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -12,13 +12,7 @@ import { getElementAbsoluteCoords, } from "../element/bounds"; import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; -import { - arrayToMap, - cloneJSON, - distance, - getFontString, - toBrandedType, -} from "../utils"; +import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { DEFAULT_EXPORT_PADDING, @@ -42,35 +36,11 @@ import { import { newTextElement } from "../element"; import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; -import Scene from "./Scene"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; import { RenderableElementsMap } from "./types"; const SVG_EXPORT_TAG = ``; -// getContainerElement and getBoundTextElement and potentially other helpers -// depend on `Scene` which will not be available when these pure utils are -// called outside initialized Excalidraw editor instance or even if called -// from inside Excalidraw if the elements were never cached by Scene (e.g. -// for library elements). -// -// As such, before passing the elements down, we need to initialize a custom -// Scene instance and assign them to it. -// -// FIXME This is a super hacky workaround and we'll need to rewrite this soon. -const __createSceneForElementsHack__ = ( - elements: readonly ExcalidrawElement[], -) => { - const scene = new Scene(); - // we can't duplicate elements to regenerate ids because we need the - // orig ids when embedding. So we do another hack of not mapping element - // ids to Scene instances so that we don't override the editor elements - // mapping. - // We still need to clone the objects themselves to regen references. - scene.replaceAllElements(cloneJSON(elements), false); - return scene; -}; - const truncateText = (element: ExcalidrawTextElement, maxWidth: number) => { if (element.width <= maxWidth) { return element; @@ -213,9 +183,6 @@ export const exportToCanvas = async ( return { canvas, scale: appState.exportScale }; }, ) => { - const tempScene = __createSceneForElementsHack__(elements); - elements = tempScene.getNonDeletedElements(); - const frameRendering = getFrameRenderingConfig( exportingFrame ?? null, appState.frameRendering ?? null, @@ -281,8 +248,6 @@ export const exportToCanvas = async ( }, }); - tempScene.destroy(); - return canvas; }; @@ -306,9 +271,6 @@ export const exportToSvg = async ( exportingFrame?: ExcalidrawFrameLikeElement | null; }, ): Promise => { - const tempScene = __createSceneForElementsHack__(elements); - elements = tempScene.getNonDeletedElements(); - const frameRendering = getFrameRenderingConfig( opts?.exportingFrame ?? null, appState.frameRendering ?? null, @@ -470,8 +432,6 @@ export const exportToSvg = async ( }, ); - tempScene.destroy(); - return svgRoot; }; diff --git a/packages/excalidraw/scene/selection.ts b/packages/excalidraw/scene/selection.ts index 3c3df898e..deec19406 100644 --- a/packages/excalidraw/scene/selection.ts +++ b/packages/excalidraw/scene/selection.ts @@ -57,7 +57,7 @@ export const getElementsWithinSelection = ( elementsMap, ); - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); if (containingFrame) { const [fx1, fy1, fx2, fy2] = getElementBounds( containingFrame, @@ -86,7 +86,7 @@ export const getElementsWithinSelection = ( : elementsInSelection; elementsInSelection = elementsInSelection.filter((element) => { - const containingFrame = getContainingFrame(element); + const containingFrame = getContainingFrame(element, elementsMap); if (containingFrame) { return elementOverlapsWithFrame(element, containingFrame, elementsMap); From f5ab3e4e12e96c25fe2c3aff9990b6220937169b Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 21 Feb 2024 19:45:33 +0530 Subject: [PATCH 07/66] fix: remove dependency of t from clipboard and image (#7712) * fix: remove dependency of t from clipboard and image * pass errorMessage to copyTextToSystemClipboard where needed * wrap copyTextToSystemClipboard and rethrow translated error in caller * review fix * typo --- excalidraw-app/collab/RoomDialog.tsx | 23 +++++++++--------- excalidraw-app/share/ShareDialog.tsx | 24 +++++++++---------- .../excalidraw/actions/actionClipboard.tsx | 6 ++++- packages/excalidraw/clipboard.ts | 3 +-- .../components/ShareableLinkDialog.tsx | 23 +++++++++--------- packages/excalidraw/data/index.ts | 9 ++++--- packages/excalidraw/element/image.ts | 3 +-- packages/excalidraw/locales/en.json | 3 +-- 8 files changed, 48 insertions(+), 46 deletions(-) diff --git a/excalidraw-app/collab/RoomDialog.tsx b/excalidraw-app/collab/RoomDialog.tsx index f2614674d..74266d3d9 100644 --- a/excalidraw-app/collab/RoomDialog.tsx +++ b/excalidraw-app/collab/RoomDialog.tsx @@ -65,19 +65,18 @@ export const RoomModal = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 2fa92dff8..85e500dae 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -69,20 +69,20 @@ const ActiveRoomDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(activeRoomLink); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - collabAPI.setErrorMessage(error.message); + } catch (e) { + collabAPI.setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); + ref.current?.select(); }; diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index b9634886b..dbc3c8751 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -238,7 +238,11 @@ export const copyText = register({ return acc; }, []) .join("\n\n"); - copyTextToSystemClipboard(text); + try { + copyTextToSystemClipboard(text); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return { commitToHistory: false, }; diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index c6b6082ff..e24961c64 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -17,7 +17,6 @@ import { deepCopyElement } from "./element/newElement"; import { mutateElement } from "./element/mutateElement"; import { getContainingFrame } from "./frame"; import { arrayToMap, isMemberOf, isPromiseLike } from "./utils"; -import { t } from "./i18n"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; @@ -435,7 +434,7 @@ export const copyTextToSystemClipboard = async ( // (3) if that fails, use document.execCommand if (!copyTextViaExecCommand(text)) { - throw new Error(t("errors.copyToSystemClipboardFailed")); + throw new Error("Error copying to clipboard."); } }; diff --git a/packages/excalidraw/components/ShareableLinkDialog.tsx b/packages/excalidraw/components/ShareableLinkDialog.tsx index cb8ba4cef..145cc21b5 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.tsx +++ b/packages/excalidraw/components/ShareableLinkDialog.tsx @@ -31,19 +31,18 @@ export const ShareableLinkDialog = ({ const copyRoomLink = async () => { try { await copyTextToSystemClipboard(link); - - setJustCopied(true); - - if (timerRef.current) { - window.clearTimeout(timerRef.current); - } - - timerRef.current = window.setTimeout(() => { - setJustCopied(false); - }, 3000); - } catch (error: any) { - setErrorMessage(error.message); + } catch (e) { + setErrorMessage(t("errors.copyToSystemClipboardFailed")); } + setJustCopied(true); + + if (timerRef.current) { + window.clearTimeout(timerRef.current); + } + + timerRef.current = window.setTimeout(() => { + setJustCopied(false); + }, 3000); ref.current?.select(); }; diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index 51446921f..ac3975696 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -133,9 +133,12 @@ export const exportCanvas = async ( }, ); } else if (type === "clipboard-svg") { - await copyTextToSystemClipboard( - await svgPromise.then((svg) => svg.outerHTML), - ); + const svg = await svgPromise.then((svg) => svg.outerHTML); + try { + await copyTextToSystemClipboard(svg); + } catch (e) { + throw new Error(t("errors.copyToSystemClipboardFailed")); + } return; } } diff --git a/packages/excalidraw/element/image.ts b/packages/excalidraw/element/image.ts index bd9bcd627..ad94c51e0 100644 --- a/packages/excalidraw/element/image.ts +++ b/packages/excalidraw/element/image.ts @@ -3,7 +3,6 @@ // ----------------------------------------------------------------------------- import { MIME_TYPES, SVG_NS } from "../constants"; -import { t } from "../i18n"; import { AppClassProperties, DataURL, BinaryFiles } from "../types"; import { isInitializedImageElement } from "./typeChecks"; import { @@ -100,7 +99,7 @@ export const normalizeSVG = async (SVGString: string) => { const svg = doc.querySelector("svg"); const errorNode = doc.querySelector("parsererror"); if (errorNode || !isHTMLSVGElement(svg)) { - throw new Error(t("errors.invalidSVGString")); + throw new Error("Invalid SVG"); } else { if (!svg.hasAttribute("xmlns")) { svg.setAttribute("xmlns", SVG_NS); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index b983586bb..924d5c8ae 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -214,7 +214,6 @@ "fileTooBig": "File is too big. Maximum allowed size is {{maxSize}}.", "svgImageInsertError": "Couldn't insert SVG image. The SVG markup looks invalid.", "failedToFetchImage": "Failed to fetch image.", - "invalidSVGString": "Invalid SVG.", "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "importLibraryError": "Couldn't load library", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", @@ -248,7 +247,7 @@ "library": "Library", "lock": "Keep selected tool active after drawing", "penMode": "Pen mode - prevent touch", - "link": "Add/ Update link for a selected shape", + "link": "Add / Update link for a selected shape", "eraser": "Eraser", "frame": "Frame tool", "magicframe": "Wireframe to code", From f639d44a954385e846edda12ca66916d7e47aaa6 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Fri, 23 Feb 2024 15:05:46 +0530 Subject: [PATCH 08/66] fix: remove dependency of t in blob.ts (#7717) * remove dependency of t in blob.ts * fix --- packages/excalidraw/components/App.tsx | 75 +++++++++++++------ .../components/ImageExportDialog.tsx | 13 +++- .../excalidraw/components/TTDDialog/common.ts | 10 ++- packages/excalidraw/data/blob.ts | 30 ++++---- packages/excalidraw/data/index.ts | 2 +- 5 files changed, 86 insertions(+), 44 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index c9985c88d..97ce14662 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3214,7 +3214,13 @@ class App extends React.Component { try { return { file: await ImageURLToFile(url) }; } catch (error: any) { - return { errorMessage: error.message as string }; + let errorMessage = error.message; + if (error.cause === "FETCH_ERROR") { + errorMessage = t("errors.failedToFetchImage"); + } else if (error.cause === "UNSUPPORTED") { + errorMessage = t("errors.unsupportedFileType"); + } + return { errorMessage }; } }), ); @@ -8478,10 +8484,18 @@ class App extends React.Component { // mustn't be larger than 128 px // https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Basic_User_Interface/Using_URL_values_for_the_cursor_property const cursorImageSizePx = 96; + let imagePreview; - const imagePreview = await resizeImageFile(imageFile, { - maxWidthOrHeight: cursorImageSizePx, - }); + try { + imagePreview = await resizeImageFile(imageFile, { + maxWidthOrHeight: cursorImageSizePx, + }); + } catch (e: any) { + if (e.cause === "UNSUPPORTED") { + throw new Error(t("errors.unsupportedFileType")); + } + throw e; + } let previewDataURL = await getDataURL(imagePreview); @@ -8870,8 +8884,9 @@ class App extends React.Component { }); return; } catch (error: any) { + // Don't throw for image scene daa if (error.name !== "EncodingError") { - throw error; + throw new Error(t("alerts.couldNotLoadInvalidFile")); } } } @@ -8945,12 +8960,39 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { - const ret = await loadSceneOrLibraryFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); + let ret; + try { + ret = await loadSceneOrLibraryFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + fileHandle, + ); + } catch (error: any) { + const imageSceneDataError = error instanceof ImageSceneDataError; + if ( + imageSceneDataError && + error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && + !this.isToolSupported("image") + ) { + this.setState({ + isLoading: false, + errorMessage: t("errors.imageToolNotSupported"), + }); + return; + } + const errorMessage = imageSceneDataError + ? t("alerts.cannotRestoreFromImage") + : t("alerts.couldNotLoadInvalidFile"); + this.setState({ + isLoading: false, + errorMessage, + }); + } + if (!ret) { + return; + } + if (ret.type === MIME_TYPES.excalidraw) { this.setState({ isLoading: true }); this.syncActionResult({ @@ -8975,17 +9017,6 @@ class App extends React.Component { }); } } catch (error: any) { - if ( - error instanceof ImageSceneDataError && - error.code === "IMAGE_NOT_CONTAINS_SCENE_DATA" && - !this.isToolSupported("image") - ) { - this.setState({ - isLoading: false, - errorMessage: t("errors.imageToolNotSupported"), - }); - return; - } this.setState({ isLoading: false, errorMessage: error.message }); } }; diff --git a/packages/excalidraw/components/ImageExportDialog.tsx b/packages/excalidraw/components/ImageExportDialog.tsx index cecdfa79a..73c9a0def 100644 --- a/packages/excalidraw/components/ImageExportDialog.tsx +++ b/packages/excalidraw/components/ImageExportDialog.tsx @@ -124,9 +124,16 @@ const ImageExportModal = ({ setRenderError(null); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - return canvasToBlob(canvas).then(() => { - previewNode.replaceChildren(canvas); - }); + return canvasToBlob(canvas) + .then(() => { + previewNode.replaceChildren(canvas); + }) + .catch((e) => { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + }); }) .catch((error) => { console.error(error); diff --git a/packages/excalidraw/components/TTDDialog/common.ts b/packages/excalidraw/components/TTDDialog/common.ts index 636d160a8..2389b841e 100644 --- a/packages/excalidraw/components/TTDDialog/common.ts +++ b/packages/excalidraw/components/TTDDialog/common.ts @@ -10,6 +10,7 @@ import { NonDeletedExcalidrawElement } from "../../element/types"; import { AppClassProperties, BinaryFiles } from "../../types"; import { canvasToBlob } from "../../data/blob"; import { EditorLocalStorage } from "../../data/EditorLocalStorage"; +import { t } from "../../i18n"; const resetPreview = ({ canvasRef, @@ -108,7 +109,14 @@ export const convertMermaidToExcalidraw = async ({ }); // if converting to blob fails, there's some problem that will // likely prevent preview and export (e.g. canvas too big) - await canvasToBlob(canvas); + try { + await canvasToBlob(canvas); + } catch (e: any) { + if (e.name === "CANVAS_POSSIBLY_TOO_BIG") { + throw new Error(t("canvasError.canvasTooBig")); + } + throw e; + } parent.style.background = "var(--default-bg-color)"; canvasNode.replaceChildren(canvas); } catch (err: any) { diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 2f8c0db96..527f1c0ea 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -4,7 +4,6 @@ import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants"; import { clearElementsForExport } from "../element"; import { ExcalidrawElement, FileId } from "../element/types"; import { CanvasError, ImageSceneDataError } from "../errors"; -import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { AppState, DataURL, LibraryItem } from "../types"; import { ValueOf } from "../utility-types"; @@ -23,11 +22,11 @@ const parseFileContents = async (blob: Blob | File) => { } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); + throw new ImageSceneDataError("Error: cannot restore image"); } } } else { @@ -54,11 +53,11 @@ const parseFileContents = async (blob: Blob | File) => { } catch (error: any) { if (error.message === "INVALID") { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } else { - throw new ImageSceneDataError(t("alerts.cannotRestoreFromImage")); + throw new ImageSceneDataError("Error: cannot restore image"); } } } @@ -130,7 +129,7 @@ export const loadSceneOrLibraryFromBlob = async ( } catch (error: any) { if (isSupportedImageFile(blob)) { throw new ImageSceneDataError( - t("alerts.imageDoesNotContainScene"), + "Image doesn't contain scene", "IMAGE_NOT_CONTAINS_SCENE_DATA", ); } @@ -163,12 +162,12 @@ export const loadSceneOrLibraryFromBlob = async ( data, }; } - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } catch (error: any) { if (error instanceof ImageSceneDataError) { throw error; } - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } }; @@ -187,7 +186,7 @@ export const loadFromBlob = async ( fileHandle, ); if (ret.type !== MIME_TYPES.excalidraw) { - throw new Error(t("alerts.couldNotLoadInvalidFile")); + throw new Error("Error: invalid file"); } return ret.data; }; @@ -222,10 +221,7 @@ export const canvasToBlob = async ( canvas.toBlob((blob) => { if (!blob) { return reject( - new CanvasError( - t("canvasError.canvasTooBig"), - "CANVAS_POSSIBLY_TOO_BIG", - ), + new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"), ); } resolve(blob); @@ -314,7 +310,7 @@ export const resizeImageFile = async ( } if (!isSupportedImageFile(file)) { - throw new Error(t("errors.unsupportedFileType")); + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); } return new File( @@ -340,11 +336,11 @@ export const ImageURLToFile = async ( try { response = await fetch(imageUrl); } catch (error: any) { - throw new Error(t("errors.failedToFetchImage")); + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } if (!response.ok) { - throw new Error(t("errors.failedToFetchImage")); + throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" }); } const blob = await response.blob(); @@ -354,7 +350,7 @@ export const ImageURLToFile = async ( return new File([blob], name, { type: blob.type }); } - throw new Error(t("errors.unsupportedFileType")); + throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" }); }; export const getFileFromEvent = async ( diff --git a/packages/excalidraw/data/index.ts b/packages/excalidraw/data/index.ts index ac3975696..3d0555e10 100644 --- a/packages/excalidraw/data/index.ts +++ b/packages/excalidraw/data/index.ts @@ -179,7 +179,7 @@ export const exportCanvas = async ( } catch (error: any) { console.warn(error); if (error.name === "CANVAS_POSSIBLY_TOO_BIG") { - throw error; + throw new Error(t("canvasError.canvasTooBig")); } // TypeError *probably* suggests ClipboardItem not defined, which // people on Firefox can enable through a flag, so let's tell them. From dd8529743a0236fd1e7faf5f187ff29440270f56 Mon Sep 17 00:00:00 2001 From: Aashman Verma <111674354+aashmanVerma@users.noreply.github.com> Date: Mon, 26 Feb 2024 14:54:27 +0530 Subject: [PATCH 09/66] docs: type mistake in integration of excalidraw (#7723) --- dev-docs/docs/@excalidraw/excalidraw/integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index d6bf3fd0d..b9edda725 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -58,7 +58,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor ```jsx showLineNumbers "use client"; - import { Excalidraw. convertToExcalidrawElements } from "@excalidraw/excalidraw"; + import { Excalidraw, convertToExcalidrawElements } from "@excalidraw/excalidraw"; import "@excalidraw/excalidraw/index.css"; From b09b5cb5f4b7c33c9a5d9bb73ba196f51dfffe42 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Tue, 27 Feb 2024 10:37:44 +0530 Subject: [PATCH 10/66] fix: split renderScene so that locales aren't imported unnecessarily (#7718) * fix: split renderScene so that locales aren't imported unnecessarily * lint * split export code * rename renderScene to helpers.ts * add helpers * fix typo * fixes * move renderElementToSvg to export * lint * rename export to staticSvgScene * fix --- .../components/canvases/InteractiveCanvas.tsx | 2 +- .../components/canvases/StaticCanvas.tsx | 2 +- packages/excalidraw/renderer/helpers.ts | 75 + .../{renderScene.ts => interactiveScene.ts} | 1250 +++++------------ packages/excalidraw/renderer/renderElement.ts | 583 +------- packages/excalidraw/renderer/staticScene.ts | 370 +++++ .../excalidraw/renderer/staticSvgScene.ts | 653 +++++++++ packages/excalidraw/scene/Renderer.ts | 7 +- packages/excalidraw/scene/export.ts | 3 +- packages/excalidraw/tests/App.test.tsx | 4 +- .../excalidraw/tests/contextmenu.test.tsx | 4 +- packages/excalidraw/tests/dragCreate.test.tsx | 10 +- .../tests/linearElementEditor.test.tsx | 11 +- packages/excalidraw/tests/move.test.tsx | 10 +- .../tests/multiPointCreate.test.tsx | 10 +- .../excalidraw/tests/regressionTests.test.tsx | 4 +- packages/excalidraw/tests/selection.test.tsx | 10 +- 17 files changed, 1528 insertions(+), 1480 deletions(-) create mode 100644 packages/excalidraw/renderer/helpers.ts rename packages/excalidraw/renderer/{renderScene.ts => interactiveScene.ts} (67%) create mode 100644 packages/excalidraw/renderer/staticScene.ts create mode 100644 packages/excalidraw/renderer/staticSvgScene.ts diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 0782b92b9..e76d8ae68 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef } from "react"; -import { renderInteractiveScene } from "../../renderer/renderScene"; import { isShallowEqual, sceneCoordsToViewportCoords } from "../../utils"; import { CURSOR_TYPE } from "../../constants"; import { t } from "../../i18n"; @@ -12,6 +11,7 @@ import type { } from "../../scene/types"; import type { NonDeletedExcalidrawElement } from "../../element/types"; import { isRenderThrottlingEnabled } from "../../reactUtils"; +import { renderInteractiveScene } from "../../renderer/interactiveScene"; type InteractiveCanvasProps = { containerRef: React.RefObject; diff --git a/packages/excalidraw/components/canvases/StaticCanvas.tsx b/packages/excalidraw/components/canvases/StaticCanvas.tsx index bfdb669e6..f5cc3dfe5 100644 --- a/packages/excalidraw/components/canvases/StaticCanvas.tsx +++ b/packages/excalidraw/components/canvases/StaticCanvas.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef } from "react"; import { RoughCanvas } from "roughjs/bin/canvas"; -import { renderStaticScene } from "../../renderer/renderScene"; +import { renderStaticScene } from "../../renderer/staticScene"; import { isShallowEqual } from "../../utils"; import type { AppState, StaticCanvasAppState } from "../../types"; import type { diff --git a/packages/excalidraw/renderer/helpers.ts b/packages/excalidraw/renderer/helpers.ts new file mode 100644 index 000000000..9ab85975f --- /dev/null +++ b/packages/excalidraw/renderer/helpers.ts @@ -0,0 +1,75 @@ +import { StaticCanvasAppState, AppState } from "../types"; + +import { StaticCanvasRenderConfig } from "../scene/types"; + +import { THEME_FILTER } from "../constants"; + +export const fillCircle = ( + context: CanvasRenderingContext2D, + cx: number, + cy: number, + radius: number, + stroke = true, +) => { + context.beginPath(); + context.arc(cx, cy, radius, 0, Math.PI * 2); + context.fill(); + if (stroke) { + context.stroke(); + } +}; + +export const getNormalizedCanvasDimensions = ( + canvas: HTMLCanvasElement, + scale: number, +): [number, number] => { + // When doing calculations based on canvas width we should used normalized one + return [canvas.width / scale, canvas.height / scale]; +}; + +export const bootstrapCanvas = ({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme, + isExporting, + viewBackgroundColor, +}: { + canvas: HTMLCanvasElement; + scale: number; + normalizedWidth: number; + normalizedHeight: number; + theme?: AppState["theme"]; + isExporting?: StaticCanvasRenderConfig["isExporting"]; + viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; +}): CanvasRenderingContext2D => { + const context = canvas.getContext("2d")!; + + context.setTransform(1, 0, 0, 1, 0, 0); + context.scale(scale, scale); + + if (isExporting && theme === "dark") { + context.filter = THEME_FILTER; + } + + // Paint background + if (typeof viewBackgroundColor === "string") { + const hasTransparence = + viewBackgroundColor === "transparent" || + viewBackgroundColor.length === 5 || // #RGBA + viewBackgroundColor.length === 9 || // #RRGGBBA + /(hsla|rgba)\(/.test(viewBackgroundColor); + if (hasTransparence) { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + context.save(); + context.fillStyle = viewBackgroundColor; + context.fillRect(0, 0, normalizedWidth, normalizedHeight); + context.restore(); + } else { + context.clearRect(0, 0, normalizedWidth, normalizedHeight); + } + + return context; +}; diff --git a/packages/excalidraw/renderer/renderScene.ts b/packages/excalidraw/renderer/interactiveScene.ts similarity index 67% rename from packages/excalidraw/renderer/renderScene.ts rename to packages/excalidraw/renderer/interactiveScene.ts index 69926b72d..a6d997770 100644 --- a/packages/excalidraw/renderer/renderScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -1,24 +1,3 @@ -import { RoughSVG } from "roughjs/bin/svg"; -import oc from "open-color"; - -import { - InteractiveCanvasAppState, - StaticCanvasAppState, - BinaryFiles, - Point, - Zoom, - AppState, -} from "../types"; -import { - ExcalidrawElement, - NonDeletedExcalidrawElement, - ExcalidrawLinearElement, - NonDeleted, - GroupId, - ExcalidrawBindableElement, - ExcalidrawFrameLikeElement, - ElementsMap, -} from "../element/types"; import { getElementAbsoluteCoords, OMIT_SIDES_FOR_MULTIPLE_ELEMENTS, @@ -27,36 +6,22 @@ import { getCommonBounds, } from "../element"; -import { roundRect } from "./roundRect"; -import { - InteractiveCanvasRenderConfig, - InteractiveSceneRenderConfig, - SVGRenderConfig, - StaticCanvasRenderConfig, - StaticSceneRenderConfig, - RenderableElementsMap, -} from "../scene/types"; +import { roundRect } from "../renderer/roundRect"; + import { getScrollBars, SCROLLBAR_COLOR, SCROLLBAR_WIDTH, } from "../scene/scrollbars"; -import { - renderElement, - renderElementToSvg, - renderSelectionElement, -} from "./renderElement"; +import { renderSelectionElement } from "../renderer/renderElement"; import { getClientColor } from "../clients"; -import { LinearElementEditor } from "../element/linearElementEditor"; import { isSelectedViaGroup, getSelectedGroupIds, getElementsInGroup, selectGroupsFromGivenElements, } from "../groups"; -import { maxBindingGap } from "../element/collision"; -import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; import { OMIT_SIDES_FOR_FRAME, shouldShowBoundingBox, @@ -64,29 +29,81 @@ import { TransformHandleType, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import { UserIdleState } from "../types"; +import { InteractiveCanvasAppState, Point, UserIdleState } from "../types"; +import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; + +import { renderSnaps } from "../renderer/renderSnaps"; + +import { maxBindingGap } from "../element/collision"; +import { SuggestedBinding, SuggestedPointBinding } from "../element/binding"; +import { LinearElementEditor } from "../element/linearElementEditor"; import { - DEFAULT_TRANSFORM_HANDLE_SPACING, - FRAME_STYLE, - THEME_FILTER, -} from "../constants"; + bootstrapCanvas, + fillCircle, + getNormalizedCanvasDimensions, +} from "./helpers"; +import oc from "open-color"; +import { isFrameLikeElement, isLinearElement } from "../element/typeChecks"; import { - EXTERNAL_LINK_IMG, - getLinkHandleFromCoords, -} from "../components/hyperlink/helpers"; -import { renderSnaps } from "./renderSnaps"; + ElementsMap, + ExcalidrawBindableElement, + ExcalidrawElement, + ExcalidrawFrameLikeElement, + ExcalidrawLinearElement, + GroupId, + NonDeleted, +} from "../element/types"; import { - isEmbeddableElement, - isFrameLikeElement, - isIframeLikeElement, - isLinearElement, -} from "../element/typeChecks"; -import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; -import { - elementOverlapsWithFrame, - getTargetFrame, - isElementInFrame, -} from "../frame"; + InteractiveCanvasRenderConfig, + InteractiveSceneRenderConfig, + RenderableElementsMap, +} from "../scene/types"; + +const renderLinearElementPointHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementsMap: ElementsMap, +) => { + const { elementId, hoverPointIndex } = appState.selectedLinearElement!; + if ( + appState.editingLinearElement?.selectedPointsIndices?.includes( + hoverPointIndex, + ) + ) { + return; + } + const element = LinearElementEditor.getElement(elementId, elementsMap); + + if (!element) { + return; + } + const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + hoverPointIndex, + elementsMap, + ); + context.save(); + context.translate(appState.scrollX, appState.scrollY); + + highlightPoint(point, context, appState); + context.restore(); +}; + +const highlightPoint = ( + point: Point, + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, +) => { + context.fillStyle = "rgba(105, 101, 219, 0.4)"; + + fillCircle( + context, + point[0], + point[1], + LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, + false, + ); +}; const strokeRectWithRotation = ( context: CanvasRenderingContext2D, @@ -139,86 +156,6 @@ const strokeDiamondWithRotation = ( context.restore(); }; -const strokeEllipseWithRotation = ( - context: CanvasRenderingContext2D, - width: number, - height: number, - cx: number, - cy: number, - angle: number, -) => { - context.beginPath(); - context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); - context.stroke(); -}; - -const fillCircle = ( - context: CanvasRenderingContext2D, - cx: number, - cy: number, - radius: number, - stroke = true, -) => { - context.beginPath(); - context.arc(cx, cy, radius, 0, Math.PI * 2); - context.fill(); - if (stroke) { - context.stroke(); - } -}; - -const strokeGrid = ( - context: CanvasRenderingContext2D, - gridSize: number, - scrollX: number, - scrollY: number, - zoom: Zoom, - width: number, - height: number, -) => { - const BOLD_LINE_FREQUENCY = 5; - - enum GridLineColor { - Bold = "#cccccc", - Regular = "#e5e5e5", - } - - const offsetX = - -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); - const offsetY = - -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); - - const lineWidth = Math.min(1 / zoom.value, 1); - - const spaceWidth = 1 / zoom.value; - const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; - - context.save(); - context.lineWidth = lineWidth; - - for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { - const isBold = - Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(x, offsetY - gridSize); - context.lineTo(x, offsetY + height + gridSize * 2); - context.stroke(); - } - for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { - const isBold = - Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; - context.beginPath(); - context.setLineDash(isBold ? [] : lineDash); - context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; - context.moveTo(offsetX - gridSize, y); - context.lineTo(offsetX + width + gridSize * 2, y); - context.stroke(); - } - context.restore(); -}; - const renderSingleLinearPoint = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -245,6 +182,266 @@ const renderSingleLinearPoint = ( ); }; +const strokeEllipseWithRotation = ( + context: CanvasRenderingContext2D, + width: number, + height: number, + cx: number, + cy: number, + angle: number, +) => { + context.beginPath(); + context.ellipse(cx, cy, width / 2, height / 2, angle, 0, Math.PI * 2); + context.stroke(); +}; + +const renderBindingHighlightForBindableElement = ( + context: CanvasRenderingContext2D, + element: ExcalidrawBindableElement, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + const threshold = maxBindingGap(element, width, height); + + // So that we don't overlap the element itself + const strokeOffset = 4; + context.strokeStyle = "rgba(0,0,0,.05)"; + context.lineWidth = threshold - strokeOffset; + const padding = strokeOffset / 2 + threshold / 2; + + switch (element.type) { + case "rectangle": + case "text": + case "image": + case "iframe": + case "embeddable": + case "frame": + case "magicframe": + strokeRectWithRotation( + context, + x1 - padding, + y1 - padding, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "diamond": + const side = Math.hypot(width, height); + const wPadding = (padding * side) / height; + const hPadding = (padding * side) / width; + strokeDiamondWithRotation( + context, + width + wPadding * 2, + height + hPadding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + case "ellipse": + strokeEllipseWithRotation( + context, + width + padding * 2, + height + padding * 2, + x1 + width / 2, + y1 + height / 2, + element.angle, + ); + break; + } +}; + +const renderBindingHighlightForSuggestedPointBinding = ( + context: CanvasRenderingContext2D, + suggestedBinding: SuggestedPointBinding, + elementsMap: ElementsMap, +) => { + const [element, startOrEnd, bindableElement] = suggestedBinding; + + const threshold = maxBindingGap( + bindableElement, + bindableElement.width, + bindableElement.height, + ); + + context.strokeStyle = "rgba(0,0,0,0)"; + context.fillStyle = "rgba(0,0,0,.05)"; + + const pointIndices = + startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; + pointIndices.forEach((index) => { + const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( + element, + index, + elementsMap, + ); + fillCircle(context, x, y, threshold); + }); +}; + +const renderSelectionBorder = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elementProperties: { + angle: number; + elementX1: number; + elementY1: number; + elementX2: number; + elementY2: number; + selectionColors: string[]; + dashed?: boolean; + cx: number; + cy: number; + activeEmbeddable: boolean; + }, + padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, +) => { + const { + angle, + elementX1, + elementY1, + elementX2, + elementY2, + selectionColors, + cx, + cy, + dashed, + activeEmbeddable, + } = elementProperties; + const elementWidth = elementX2 - elementX1; + const elementHeight = elementY2 - elementY1; + + const linePadding = padding / appState.zoom.value; + const lineWidth = 8 / appState.zoom.value; + const spaceWidth = 4 / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; + + const count = selectionColors.length; + for (let index = 0; index < count; ++index) { + context.strokeStyle = selectionColors[index]; + if (dashed) { + context.setLineDash([ + lineWidth, + spaceWidth + (lineWidth + spaceWidth) * (count - 1), + ]); + } + context.lineDashOffset = (lineWidth + spaceWidth) * index; + strokeRectWithRotation( + context, + elementX1 - linePadding, + elementY1 - linePadding, + elementWidth + linePadding * 2, + elementHeight + linePadding * 2, + cx, + cy, + angle, + ); + } + context.restore(); +}; + +const renderBindingHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + suggestedBinding: SuggestedBinding, + elementsMap: ElementsMap, +) => { + const renderHighlight = Array.isArray(suggestedBinding) + ? renderBindingHighlightForSuggestedPointBinding + : renderBindingHighlightForBindableElement; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + renderHighlight(context, suggestedBinding as any, elementsMap); + + context.restore(); +}; + +const renderFrameHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + frame: NonDeleted, + elementsMap: ElementsMap, +) => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); + const width = x2 - x1; + const height = y2 - y1; + + context.strokeStyle = "rgb(0,118,255)"; + context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; + + context.save(); + context.translate(appState.scrollX, appState.scrollY); + strokeRectWithRotation( + context, + x1, + y1, + width, + height, + x1 + width / 2, + y1 + height / 2, + frame.angle, + false, + FRAME_STYLE.radius / appState.zoom.value, + ); + context.restore(); +}; + +const renderElementsBoxHighlight = ( + context: CanvasRenderingContext2D, + appState: InteractiveCanvasAppState, + elements: NonDeleted[], +) => { + const individualElements = elements.filter( + (element) => element.groupIds.length === 0, + ); + + const elementsInGroups = elements.filter( + (element) => element.groupIds.length > 0, + ); + + const getSelectionFromElements = (elements: ExcalidrawElement[]) => { + const [elementX1, elementY1, elementX2, elementY2] = + getCommonBounds(elements); + return { + angle: 0, + elementX1, + elementX2, + elementY1, + elementY2, + selectionColors: ["rgb(0,118,255)"], + dashed: false, + cx: elementX1 + (elementX2 - elementX1) / 2, + cy: elementY1 + (elementY2 - elementY1) / 2, + activeEmbeddable: false, + }; + }; + + const getSelectionForGroupId = (groupId: GroupId) => { + const groupElements = getElementsInGroup(elements, groupId); + return getSelectionFromElements(groupElements); + }; + + Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) + .filter(([id, isSelected]) => isSelected) + .map(([id, isSelected]) => id) + .map((groupId) => getSelectionForGroupId(groupId)) + .concat( + individualElements.map((element) => getSelectionFromElements([element])), + ) + .forEach((selection) => + renderSelectionBorder(context, appState, selection), + ); +}; + const renderLinearPointHandles = ( context: CanvasRenderingContext2D, appState: InteractiveCanvasAppState, @@ -326,130 +523,47 @@ const renderLinearPointHandles = ( context.restore(); }; -const highlightPoint = ( - point: Point, +const renderTransformHandles = ( context: CanvasRenderingContext2D, + renderConfig: InteractiveCanvasRenderConfig, appState: InteractiveCanvasAppState, -) => { - context.fillStyle = "rgba(105, 101, 219, 0.4)"; + transformHandles: TransformHandles, + angle: number, +): void => { + Object.keys(transformHandles).forEach((key) => { + const transformHandle = transformHandles[key as TransformHandleType]; + if (transformHandle !== undefined) { + const [x, y, width, height] = transformHandle; - fillCircle( - context, - point[0], - point[1], - LinearElementEditor.POINT_HANDLE_SIZE / appState.zoom.value, - false, - ); -}; -const renderLinearElementPointHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementsMap: ElementsMap, -) => { - const { elementId, hoverPointIndex } = appState.selectedLinearElement!; - if ( - appState.editingLinearElement?.selectedPointsIndices?.includes( - hoverPointIndex, - ) - ) { - return; - } - const element = LinearElementEditor.getElement(elementId, elementsMap); - - if (!element) { - return; - } - const point = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - hoverPointIndex, - elementsMap, - ); - context.save(); - context.translate(appState.scrollX, appState.scrollY); - - highlightPoint(point, context, appState); - context.restore(); -}; - -const frameClip = ( - frame: ExcalidrawFrameLikeElement, - context: CanvasRenderingContext2D, - renderConfig: StaticCanvasRenderConfig, - appState: StaticCanvasAppState, -) => { - context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); - context.beginPath(); - if (context.roundRect) { - context.roundRect( - 0, - 0, - frame.width, - frame.height, - FRAME_STYLE.radius / appState.zoom.value, - ); - } else { - context.rect(0, 0, frame.width, frame.height); - } - context.clip(); - context.translate( - -(frame.x + appState.scrollX), - -(frame.y + appState.scrollY), - ); -}; - -const getNormalizedCanvasDimensions = ( - canvas: HTMLCanvasElement, - scale: number, -): [number, number] => { - // When doing calculations based on canvas width we should used normalized one - return [canvas.width / scale, canvas.height / scale]; -}; - -const bootstrapCanvas = ({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme, - isExporting, - viewBackgroundColor, -}: { - canvas: HTMLCanvasElement; - scale: number; - normalizedWidth: number; - normalizedHeight: number; - theme?: AppState["theme"]; - isExporting?: StaticCanvasRenderConfig["isExporting"]; - viewBackgroundColor?: StaticCanvasAppState["viewBackgroundColor"]; -}): CanvasRenderingContext2D => { - const context = canvas.getContext("2d")!; - - context.setTransform(1, 0, 0, 1, 0, 0); - context.scale(scale, scale); - - if (isExporting && theme === "dark") { - context.filter = THEME_FILTER; - } - - // Paint background - if (typeof viewBackgroundColor === "string") { - const hasTransparence = - viewBackgroundColor === "transparent" || - viewBackgroundColor.length === 5 || // #RGBA - viewBackgroundColor.length === 9 || // #RRGGBBA - /(hsla|rgba)\(/.test(viewBackgroundColor); - if (hasTransparence) { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); + context.save(); + context.lineWidth = 1 / appState.zoom.value; + if (renderConfig.selectionColor) { + context.strokeStyle = renderConfig.selectionColor; + } + if (key === "rotation") { + fillCircle(context, x + width / 2, y + height / 2, width / 2); + // prefer round corners if roundRect API is available + } else if (context.roundRect) { + context.beginPath(); + context.roundRect(x, y, width, height, 2 / appState.zoom.value); + context.fill(); + context.stroke(); + } else { + strokeRectWithRotation( + context, + x, + y, + width, + height, + x + width / 2, + y + height / 2, + angle, + true, // fill before stroke + ); + } + context.restore(); } - context.save(); - context.fillStyle = viewBackgroundColor; - context.fillRect(0, 0, normalizedWidth, normalizedHeight); - context.restore(); - } else { - context.clearRect(0, 0, normalizedWidth, normalizedHeight); - } - - return context; + }); }; const _renderInteractiveScene = ({ @@ -917,192 +1031,8 @@ const _renderInteractiveScene = ({ }; }; -const _renderStaticScene = ({ - canvas, - rc, - elementsMap, - allElementsMap, - visibleElements, - scale, - appState, - renderConfig, -}: StaticSceneRenderConfig) => { - if (canvas === null) { - return; - } - - const { renderGrid = true, isExporting } = renderConfig; - - const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( - canvas, - scale, - ); - - const context = bootstrapCanvas({ - canvas, - scale, - normalizedWidth, - normalizedHeight, - theme: appState.theme, - isExporting, - viewBackgroundColor: appState.viewBackgroundColor, - }); - - // Apply zoom - context.scale(appState.zoom.value, appState.zoom.value); - - // Grid - if (renderGrid && appState.gridSize) { - strokeGrid( - context, - appState.gridSize, - appState.scrollX, - appState.scrollY, - appState.zoom, - normalizedWidth / appState.zoom.value, - normalizedHeight / appState.zoom.value, - ); - } - - const groupsToBeAddedToFrame = new Set(); - - visibleElements.forEach((element) => { - if ( - element.groupIds.length > 0 && - appState.frameToHighlight && - appState.selectedElementIds[element.id] && - (elementOverlapsWithFrame( - element, - appState.frameToHighlight, - elementsMap, - ) || - element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) - ) { - element.groupIds.forEach((groupId) => - groupsToBeAddedToFrame.add(groupId), - ); - } - }); - - // Paint visible elements - visibleElements - .filter((el) => !isIframeLikeElement(el)) - .forEach((element) => { - try { - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, elementsMap, appState); - - // TODO do we need to check isElementInFrame here? - if (frame && isElementInFrame(element, elementsMap, appState)) { - frameClip(frame, context, renderConfig, appState); - } - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - context.restore(); - } else { - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - } - if (!isExporting) { - renderLinkIcon(element, context, appState, elementsMap); - } - } catch (error: any) { - console.error(error); - } - }); - - // render embeddables on top - visibleElements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - try { - const render = () => { - renderElement( - element, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - - if ( - isIframeLikeElement(element) && - (isExporting || - (isEmbeddableElement(element) && - renderConfig.embedsValidationStatus.get(element.id) !== - true)) && - element.width && - element.height - ) { - const label = createPlaceholderEmbeddableLabel(element); - renderElement( - label, - elementsMap, - allElementsMap, - rc, - context, - renderConfig, - appState, - ); - } - if (!isExporting) { - renderLinkIcon(element, context, appState, elementsMap); - } - }; - // - when exporting the whole canvas, we DO NOT apply clipping - // - when we are exporting a particular frame, apply clipping - // if the containing frame is not selected, apply clipping - const frameId = element.frameId || appState.frameToHighlight?.id; - - if ( - frameId && - appState.frameRendering.enabled && - appState.frameRendering.clip - ) { - context.save(); - - const frame = getTargetFrame(element, elementsMap, appState); - - if (frame && isElementInFrame(element, elementsMap, appState)) { - frameClip(frame, context, renderConfig, appState); - } - render(); - context.restore(); - } else { - render(); - } - } catch (error: any) { - console.error(error); - } - }); -}; - /** throttled to animation framerate */ -const renderInteractiveSceneThrottled = throttleRAF( +export const renderInteractiveSceneThrottled = throttleRAF( (config: InteractiveSceneRenderConfig) => { const ret = _renderInteractiveScene(config); config.callback?.(ret); @@ -1111,7 +1041,7 @@ const renderInteractiveSceneThrottled = throttleRAF( ); /** - * Interactive scene is the ui-canvas where we render boundinb boxes, selections + * Interactive scene is the ui-canvas where we render bounding boxes, selections * and other ui stuff. */ export const renderInteractiveScene = < @@ -1129,435 +1059,3 @@ export const renderInteractiveScene = < renderConfig.callback(ret); return ret as T extends true ? void : ReturnType; }; - -/** throttled to animation framerate */ -const renderStaticSceneThrottled = throttleRAF( - (config: StaticSceneRenderConfig) => { - _renderStaticScene(config); - }, - { trailing: true }, -); - -/** - * Static scene is the non-ui canvas where we render elements. - */ -export const renderStaticScene = ( - renderConfig: StaticSceneRenderConfig, - throttle?: boolean, -) => { - if (throttle) { - renderStaticSceneThrottled(renderConfig); - return; - } - - _renderStaticScene(renderConfig); -}; - -export const cancelRender = () => { - renderInteractiveSceneThrottled.cancel(); - renderStaticSceneThrottled.cancel(); -}; - -const renderTransformHandles = ( - context: CanvasRenderingContext2D, - renderConfig: InteractiveCanvasRenderConfig, - appState: InteractiveCanvasAppState, - transformHandles: TransformHandles, - angle: number, -): void => { - Object.keys(transformHandles).forEach((key) => { - const transformHandle = transformHandles[key as TransformHandleType]; - if (transformHandle !== undefined) { - const [x, y, width, height] = transformHandle; - - context.save(); - context.lineWidth = 1 / appState.zoom.value; - if (renderConfig.selectionColor) { - context.strokeStyle = renderConfig.selectionColor; - } - if (key === "rotation") { - fillCircle(context, x + width / 2, y + height / 2, width / 2); - // prefer round corners if roundRect API is available - } else if (context.roundRect) { - context.beginPath(); - context.roundRect(x, y, width, height, 2 / appState.zoom.value); - context.fill(); - context.stroke(); - } else { - strokeRectWithRotation( - context, - x, - y, - width, - height, - x + width / 2, - y + height / 2, - angle, - true, // fill before stroke - ); - } - context.restore(); - } - }); -}; - -const renderSelectionBorder = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elementProperties: { - angle: number; - elementX1: number; - elementY1: number; - elementX2: number; - elementY2: number; - selectionColors: string[]; - dashed?: boolean; - cx: number; - cy: number; - activeEmbeddable: boolean; - }, - padding = DEFAULT_TRANSFORM_HANDLE_SPACING * 2, -) => { - const { - angle, - elementX1, - elementY1, - elementX2, - elementY2, - selectionColors, - cx, - cy, - dashed, - activeEmbeddable, - } = elementProperties; - const elementWidth = elementX2 - elementX1; - const elementHeight = elementY2 - elementY1; - - const linePadding = padding / appState.zoom.value; - const lineWidth = 8 / appState.zoom.value; - const spaceWidth = 4 / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - context.lineWidth = (activeEmbeddable ? 4 : 1) / appState.zoom.value; - - const count = selectionColors.length; - for (let index = 0; index < count; ++index) { - context.strokeStyle = selectionColors[index]; - if (dashed) { - context.setLineDash([ - lineWidth, - spaceWidth + (lineWidth + spaceWidth) * (count - 1), - ]); - } - context.lineDashOffset = (lineWidth + spaceWidth) * index; - strokeRectWithRotation( - context, - elementX1 - linePadding, - elementY1 - linePadding, - elementWidth + linePadding * 2, - elementHeight + linePadding * 2, - cx, - cy, - angle, - ); - } - context.restore(); -}; - -const renderBindingHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - suggestedBinding: SuggestedBinding, - elementsMap: ElementsMap, -) => { - const renderHighlight = Array.isArray(suggestedBinding) - ? renderBindingHighlightForSuggestedPointBinding - : renderBindingHighlightForBindableElement; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - renderHighlight(context, suggestedBinding as any, elementsMap); - - context.restore(); -}; - -const renderBindingHighlightForBindableElement = ( - context: CanvasRenderingContext2D, - element: ExcalidrawBindableElement, - elementsMap: ElementsMap, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - const threshold = maxBindingGap(element, width, height); - - // So that we don't overlap the element itself - const strokeOffset = 4; - context.strokeStyle = "rgba(0,0,0,.05)"; - context.lineWidth = threshold - strokeOffset; - const padding = strokeOffset / 2 + threshold / 2; - - switch (element.type) { - case "rectangle": - case "text": - case "image": - case "iframe": - case "embeddable": - case "frame": - case "magicframe": - strokeRectWithRotation( - context, - x1 - padding, - y1 - padding, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - case "diamond": - const side = Math.hypot(width, height); - const wPadding = (padding * side) / height; - const hPadding = (padding * side) / width; - strokeDiamondWithRotation( - context, - width + wPadding * 2, - height + hPadding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - case "ellipse": - strokeEllipseWithRotation( - context, - width + padding * 2, - height + padding * 2, - x1 + width / 2, - y1 + height / 2, - element.angle, - ); - break; - } -}; - -const renderFrameHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - frame: NonDeleted, - elementsMap: ElementsMap, -) => { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame, elementsMap); - const width = x2 - x1; - const height = y2 - y1; - - context.strokeStyle = "rgb(0,118,255)"; - context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; - - context.save(); - context.translate(appState.scrollX, appState.scrollY); - strokeRectWithRotation( - context, - x1, - y1, - width, - height, - x1 + width / 2, - y1 + height / 2, - frame.angle, - false, - FRAME_STYLE.radius / appState.zoom.value, - ); - context.restore(); -}; - -const renderElementsBoxHighlight = ( - context: CanvasRenderingContext2D, - appState: InteractiveCanvasAppState, - elements: NonDeleted[], -) => { - const individualElements = elements.filter( - (element) => element.groupIds.length === 0, - ); - - const elementsInGroups = elements.filter( - (element) => element.groupIds.length > 0, - ); - - const getSelectionFromElements = (elements: ExcalidrawElement[]) => { - const [elementX1, elementY1, elementX2, elementY2] = - getCommonBounds(elements); - return { - angle: 0, - elementX1, - elementX2, - elementY1, - elementY2, - selectionColors: ["rgb(0,118,255)"], - dashed: false, - cx: elementX1 + (elementX2 - elementX1) / 2, - cy: elementY1 + (elementY2 - elementY1) / 2, - activeEmbeddable: false, - }; - }; - - const getSelectionForGroupId = (groupId: GroupId) => { - const groupElements = getElementsInGroup(elements, groupId); - return getSelectionFromElements(groupElements); - }; - - Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState)) - .filter(([id, isSelected]) => isSelected) - .map(([id, isSelected]) => id) - .map((groupId) => getSelectionForGroupId(groupId)) - .concat( - individualElements.map((element) => getSelectionFromElements([element])), - ) - .forEach((selection) => - renderSelectionBorder(context, appState, selection), - ); -}; - -const renderBindingHighlightForSuggestedPointBinding = ( - context: CanvasRenderingContext2D, - suggestedBinding: SuggestedPointBinding, - elementsMap: ElementsMap, -) => { - const [element, startOrEnd, bindableElement] = suggestedBinding; - - const threshold = maxBindingGap( - bindableElement, - bindableElement.width, - bindableElement.height, - ); - - context.strokeStyle = "rgba(0,0,0,0)"; - context.fillStyle = "rgba(0,0,0,.05)"; - - const pointIndices = - startOrEnd === "both" ? [0, -1] : startOrEnd === "start" ? [0] : [-1]; - pointIndices.forEach((index) => { - const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates( - element, - index, - elementsMap, - ); - fillCircle(context, x, y, threshold); - }); -}; - -let linkCanvasCache: any; -const renderLinkIcon = ( - element: NonDeletedExcalidrawElement, - context: CanvasRenderingContext2D, - appState: StaticCanvasAppState, - elementsMap: ElementsMap, -) => { - if (element.link && !appState.selectedElementIds[element.id]) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - const [x, y, width, height] = getLinkHandleFromCoords( - [x1, y1, x2, y2], - element.angle, - appState, - ); - const centerX = x + width / 2; - const centerY = y + height / 2; - context.save(); - context.translate(appState.scrollX + centerX, appState.scrollY + centerY); - context.rotate(element.angle); - - if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { - linkCanvasCache = document.createElement("canvas"); - linkCanvasCache.zoom = appState.zoom.value; - linkCanvasCache.width = - width * window.devicePixelRatio * appState.zoom.value; - linkCanvasCache.height = - height * window.devicePixelRatio * appState.zoom.value; - const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; - linkCanvasCacheContext.scale( - window.devicePixelRatio * appState.zoom.value, - window.devicePixelRatio * appState.zoom.value, - ); - linkCanvasCacheContext.fillStyle = "#fff"; - linkCanvasCacheContext.fillRect(0, 0, width, height); - linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); - linkCanvasCacheContext.restore(); - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } else { - context.drawImage( - linkCanvasCache, - x - centerX, - y - centerY, - width, - height, - ); - } - context.restore(); - } -}; - -// This should be only called for exporting purposes -export const renderSceneToSvg = ( - elements: readonly NonDeletedExcalidrawElement[], - elementsMap: RenderableElementsMap, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - renderConfig: SVGRenderConfig, -) => { - if (!svgRoot) { - return; - } - - // render elements - elements - .filter((el) => !isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - elementsMap, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); - - // render embeddables on top - elements - .filter((el) => isIframeLikeElement(el)) - .forEach((element) => { - if (!element.isDeleted) { - try { - renderElementToSvg( - element, - elementsMap, - rsvg, - svgRoot, - files, - element.x + renderConfig.offsetX, - element.y + renderConfig.offsetY, - renderConfig, - ); - } catch (error: any) { - console.error(error); - } - } - }); -}; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index 637a9fe1e..a40e3d398 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -20,27 +20,17 @@ import { } from "../element/typeChecks"; import { getElementAbsoluteCoords } from "../element/bounds"; import type { RoughCanvas } from "roughjs/bin/canvas"; -import type { Drawable } from "roughjs/bin/core"; -import type { RoughSVG } from "roughjs/bin/svg"; import { - SVGRenderConfig, StaticCanvasRenderConfig, RenderableElementsMap, } from "../scene/types"; -import { - distance, - getFontString, - getFontFamilyString, - isRTL, - isTestEnv, -} from "../utils"; -import { getCornerRadius, isPathALoop, isRightAngle } from "../math"; +import { distance, getFontString, isRTL } from "../utils"; +import { getCornerRadius, isRightAngle } from "../math"; import rough from "roughjs/bin/rough"; import { AppState, StaticCanvasAppState, - BinaryFiles, Zoom, InteractiveCanvasAppState, ElementsPendingErasure, @@ -50,9 +40,7 @@ import { BOUND_TEXT_PADDING, ELEMENT_READY_TO_ERASE_OPACITY, FRAME_STYLE, - MAX_DECIMALS_FOR_SVG_EXPORT, MIME_TYPES, - SVG_NS, } from "../constants"; import { getStroke, StrokeOptions } from "perfect-freehand"; import { @@ -64,19 +52,16 @@ import { getBoundTextMaxWidth, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; -import { - createPlaceholderEmbeddableLabel, - getEmbedLink, -} from "../element/embeddable"; + import { getContainingFrame } from "../frame"; -import { normalizeLink, toValidURL } from "../data/url"; import { ShapeCache } from "../scene/ShapeCache"; // using a stronger invert (100% vs our regular 93%) and saturate // as a temp hack to make images in dark theme look closer to original // color scheme (it's still not quite there and the colors look slightly // desatured, alas...) -const IMAGE_INVERT_FILTER = "invert(100%) hue-rotate(180deg) saturate(1.25)"; +export const IMAGE_INVERT_FILTER = + "invert(100%) hue-rotate(180deg) saturate(1.25)"; const defaultAppState = getDefaultAppState(); @@ -905,564 +890,6 @@ export const renderElement = ( context.globalAlpha = 1; }; -const roughSVGDrawWithPrecision = ( - rsvg: RoughSVG, - drawable: Drawable, - precision?: number, -) => { - if (typeof precision === "undefined") { - return rsvg.draw(drawable); - } - const pshape: Drawable = { - sets: drawable.sets, - shape: drawable.shape, - options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, - }; - return rsvg.draw(pshape); -}; - -const maybeWrapNodesInFrameClipPath = ( - element: NonDeletedExcalidrawElement, - root: SVGElement, - nodes: SVGElement[], - frameRendering: AppState["frameRendering"], - elementsMap: RenderableElementsMap, -) => { - if (!frameRendering.enabled || !frameRendering.clip) { - return null; - } - const frame = getContainingFrame(element, elementsMap); - if (frame) { - const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); - g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); - nodes.forEach((node) => g.appendChild(node)); - return g; - } - - return null; -}; - -export const renderElementToSvg = ( - element: NonDeletedExcalidrawElement, - elementsMap: RenderableElementsMap, - rsvg: RoughSVG, - svgRoot: SVGElement, - files: BinaryFiles, - offsetX: number, - offsetY: number, - renderConfig: SVGRenderConfig, -) => { - const offset = { x: offsetX, y: offsetY }; - const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); - let cx = (x2 - x1) / 2 - (element.x - x1); - let cy = (y2 - y1) / 2 - (element.y - y1); - if (isTextElement(element)) { - const container = getContainerElement(element, elementsMap); - if (isArrowElement(container)) { - const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); - - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - container, - element as ExcalidrawTextElementWithContainer, - elementsMap, - ); - cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); - cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); - offsetX = offsetX + boundTextCoords.x - element.x; - offsetY = offsetY + boundTextCoords.y - element.y; - } - } - const degree = (180 * element.angle) / Math.PI; - - // element to append node to, most of the time svgRoot - let root = svgRoot; - - // if the element has a link, create an anchor tag and make that the new root - if (element.link) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link)); - root.appendChild(anchorTag); - root = anchorTag; - } - - const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { - if (isTestEnv()) { - node.setAttribute("data-id", element.id); - } - root.appendChild(node); - }; - - const opacity = - ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * - element.opacity) / - 10000; - - switch (element.type) { - case "selection": { - // Since this is used only during editing experience, which is canvas based, - // this should not happen - throw new Error("Selection rendering is not supported for SVG"); - } - case "rectangle": - case "diamond": - case "ellipse": { - const shape = ShapeCache.generateElementShape(element, null); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - break; - } - case "iframe": - case "embeddable": { - // render placeholder rectangle - const shape = ShapeCache.generateElementShape(element, renderConfig); - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - const opacity = element.opacity / 100; - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute("stroke-linecap", "round"); - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - addToRoot(node, element); - - const label: ExcalidrawElement = - createPlaceholderEmbeddableLabel(element); - renderElementToSvg( - label, - elementsMap, - rsvg, - root, - files, - label.x + offset.x - element.x, - label.y + offset.y - element.y, - renderConfig, - ); - - // render embeddable element + iframe - const embeddableNode = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - embeddableNode.setAttribute("stroke-linecap", "round"); - embeddableNode.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - while (embeddableNode.firstChild) { - embeddableNode.removeChild(embeddableNode.firstChild); - } - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - - const embedLink = getEmbedLink(toValidURL(element.link || "")); - - // if rendering embeddables explicitly disabled or - // embedding documents via srcdoc (which doesn't seem to work for SVGs) - // replace with a link instead - if ( - renderConfig.renderEmbeddables === false || - embedLink?.type === "document" - ) { - const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); - anchorTag.setAttribute("href", normalizeLink(element.link || "")); - anchorTag.setAttribute("target", "_blank"); - anchorTag.setAttribute("rel", "noopener noreferrer"); - anchorTag.style.borderRadius = `${radius}px`; - - embeddableNode.appendChild(anchorTag); - } else { - const foreignObject = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "foreignObject", - ); - foreignObject.style.width = `${element.width}px`; - foreignObject.style.height = `${element.height}px`; - foreignObject.style.border = "none"; - const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); - div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); - div.style.width = "100%"; - div.style.height = "100%"; - const iframe = div.ownerDocument!.createElement("iframe"); - iframe.src = embedLink?.link ?? ""; - iframe.style.width = "100%"; - iframe.style.height = "100%"; - iframe.style.border = "none"; - iframe.style.borderRadius = `${radius}px`; - iframe.style.top = "0"; - iframe.style.left = "0"; - iframe.allowFullscreen = true; - div.appendChild(iframe); - foreignObject.appendChild(div); - - embeddableNode.appendChild(foreignObject); - } - addToRoot(embeddableNode, element); - break; - } - case "line": - case "arrow": { - const boundText = getBoundTextElement(element, elementsMap); - const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); - if (boundText) { - maskPath.setAttribute("id", `mask-${element.id}`); - const maskRectVisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - offsetX = offsetX || 0; - offsetY = offsetY || 0; - maskRectVisible.setAttribute("x", "0"); - maskRectVisible.setAttribute("y", "0"); - maskRectVisible.setAttribute("fill", "#fff"); - maskRectVisible.setAttribute( - "width", - `${element.width + 100 + offsetX}`, - ); - maskRectVisible.setAttribute( - "height", - `${element.height + 100 + offsetY}`, - ); - - maskPath.appendChild(maskRectVisible); - const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( - element, - boundText, - elementsMap, - ); - - const maskX = offsetX + boundTextCoords.x - element.x; - const maskY = offsetY + boundTextCoords.y - element.y; - - maskRectInvisible.setAttribute("x", maskX.toString()); - maskRectInvisible.setAttribute("y", maskY.toString()); - maskRectInvisible.setAttribute("fill", "#000"); - maskRectInvisible.setAttribute("width", `${boundText.width}`); - maskRectInvisible.setAttribute("height", `${boundText.height}`); - maskRectInvisible.setAttribute("opacity", "1"); - maskPath.appendChild(maskRectInvisible); - } - const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (boundText) { - group.setAttribute("mask", `url(#mask-${element.id})`); - } - group.setAttribute("stroke-linecap", "round"); - - const shapes = ShapeCache.generateElementShape(element, renderConfig); - shapes.forEach((shape) => { - const node = roughSVGDrawWithPrecision( - rsvg, - shape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - if ( - element.type === "line" && - isPathALoop(element.points) && - element.backgroundColor !== "transparent" - ) { - node.setAttribute("fill-rule", "evenodd"); - } - group.appendChild(node); - }); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [group, maskPath], - renderConfig.frameRendering, - elementsMap, - ); - if (g) { - addToRoot(g, element); - root.appendChild(g); - } else { - addToRoot(group, element); - root.append(maskPath); - } - break; - } - case "freedraw": { - const backgroundFillShape = ShapeCache.generateElementShape( - element, - renderConfig, - ); - const node = backgroundFillShape - ? roughSVGDrawWithPrecision( - rsvg, - backgroundFillShape, - MAX_DECIMALS_FOR_SVG_EXPORT, - ) - : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - node.setAttribute("stroke", "none"); - const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); - path.setAttribute("fill", element.strokeColor); - path.setAttribute("d", getFreeDrawSvgPath(element)); - node.appendChild(path); - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - break; - } - case "image": { - const width = Math.round(element.width); - const height = Math.round(element.height); - const fileData = - isInitializedImageElement(element) && files[element.fileId]; - if (fileData) { - const symbolId = `image-${fileData.id}`; - let symbol = svgRoot.querySelector(`#${symbolId}`); - if (!symbol) { - symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); - symbol.id = symbolId; - - const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); - - image.setAttribute("width", "100%"); - image.setAttribute("height", "100%"); - image.setAttribute("href", fileData.dataURL); - - symbol.appendChild(image); - - root.prepend(symbol); - } - - const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); - use.setAttribute("href", `#${symbolId}`); - - // in dark theme, revert the image color filter - if ( - renderConfig.exportWithDarkMode && - fileData.mimeType !== MIME_TYPES.svg - ) { - use.setAttribute("filter", IMAGE_INVERT_FILTER); - } - - use.setAttribute("width", `${width}`); - use.setAttribute("height", `${height}`); - use.setAttribute("opacity", `${opacity}`); - - // We first apply `scale` transforms (horizontal/vertical mirroring) - // on the element, then apply translation and rotation - // on the element which wraps the . - // Doing this separately is a quick hack to to work around compositing - // the transformations correctly (the transform-origin was not being - // applied correctly). - if (element.scale[0] !== 1 || element.scale[1] !== 1) { - const translateX = element.scale[0] !== 1 ? -width : 0; - const translateY = element.scale[1] !== 1 ? -height : 0; - use.setAttribute( - "transform", - `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, - ); - } - - const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - g.appendChild(use); - g.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - if (element.roundness) { - const clipPath = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "clipPath", - ); - clipPath.id = `image-clipPath-${element.id}`; - - const clipRect = svgRoot.ownerDocument!.createElementNS( - SVG_NS, - "rect", - ); - const radius = getCornerRadius( - Math.min(element.width, element.height), - element, - ); - clipRect.setAttribute("width", `${element.width}`); - clipRect.setAttribute("height", `${element.height}`); - clipRect.setAttribute("rx", `${radius}`); - clipRect.setAttribute("ry", `${radius}`); - clipPath.appendChild(clipRect); - addToRoot(clipPath, element); - - g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); - } - - const clipG = maybeWrapNodesInFrameClipPath( - element, - root, - [g], - renderConfig.frameRendering, - elementsMap, - ); - addToRoot(clipG || g, element); - } - break; - } - // frames are not rendered and only acts as a container - case "frame": - case "magicframe": { - if ( - renderConfig.frameRendering.enabled && - renderConfig.frameRendering.outline - ) { - const rect = document.createElementNS(SVG_NS, "rect"); - - rect.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - - rect.setAttribute("width", `${element.width}px`); - rect.setAttribute("height", `${element.height}px`); - // Rounded corners - rect.setAttribute("rx", FRAME_STYLE.radius.toString()); - rect.setAttribute("ry", FRAME_STYLE.radius.toString()); - - rect.setAttribute("fill", "none"); - rect.setAttribute("stroke", FRAME_STYLE.strokeColor); - rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); - - addToRoot(rect, element); - } - break; - } - default: { - if (isTextElement(element)) { - const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); - if (opacity !== 1) { - node.setAttribute("stroke-opacity", `${opacity}`); - node.setAttribute("fill-opacity", `${opacity}`); - } - - node.setAttribute( - "transform", - `translate(${offsetX || 0} ${ - offsetY || 0 - }) rotate(${degree} ${cx} ${cy})`, - ); - const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); - const lineHeightPx = getLineHeightInPx( - element.fontSize, - element.lineHeight, - ); - const horizontalOffset = - element.textAlign === "center" - ? element.width / 2 - : element.textAlign === "right" - ? element.width - : 0; - const direction = isRTL(element.text) ? "rtl" : "ltr"; - const textAnchor = - element.textAlign === "center" - ? "middle" - : element.textAlign === "right" || direction === "rtl" - ? "end" - : "start"; - for (let i = 0; i < lines.length; i++) { - const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); - text.textContent = lines[i]; - text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeightPx}`); - text.setAttribute("font-family", getFontFamilyString(element)); - text.setAttribute("font-size", `${element.fontSize}px`); - text.setAttribute("fill", element.strokeColor); - text.setAttribute("text-anchor", textAnchor); - text.setAttribute("style", "white-space: pre;"); - text.setAttribute("direction", direction); - text.setAttribute("dominant-baseline", "text-before-edge"); - node.appendChild(text); - } - - const g = maybeWrapNodesInFrameClipPath( - element, - root, - [node], - renderConfig.frameRendering, - elementsMap, - ); - - addToRoot(g || node, element); - } else { - // @ts-ignore - throw new Error(`Unimplemented type ${element.type}`); - } - } - } -}; - export const pathsCache = new WeakMap([]); export function generateFreeDrawShape(element: ExcalidrawFreeDrawElement) { diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts new file mode 100644 index 000000000..c036226a0 --- /dev/null +++ b/packages/excalidraw/renderer/staticScene.ts @@ -0,0 +1,370 @@ +import { FRAME_STYLE } from "../constants"; +import { getElementAbsoluteCoords } from "../element"; + +import { + elementOverlapsWithFrame, + getTargetFrame, + isElementInFrame, +} from "../frame"; +import { + isEmbeddableElement, + isIframeLikeElement, +} from "../element/typeChecks"; +import { renderElement } from "../renderer/renderElement"; +import { createPlaceholderEmbeddableLabel } from "../element/embeddable"; +import { StaticCanvasAppState, Zoom } from "../types"; +import { + ElementsMap, + ExcalidrawFrameLikeElement, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { + StaticCanvasRenderConfig, + StaticSceneRenderConfig, +} from "../scene/types"; +import { + EXTERNAL_LINK_IMG, + getLinkHandleFromCoords, +} from "../components/hyperlink/helpers"; +import { bootstrapCanvas, getNormalizedCanvasDimensions } from "./helpers"; +import { throttleRAF } from "../utils"; + +const strokeGrid = ( + context: CanvasRenderingContext2D, + gridSize: number, + scrollX: number, + scrollY: number, + zoom: Zoom, + width: number, + height: number, +) => { + const BOLD_LINE_FREQUENCY = 5; + + enum GridLineColor { + Bold = "#cccccc", + Regular = "#e5e5e5", + } + + const offsetX = + -Math.round(zoom.value / gridSize) * gridSize + (scrollX % gridSize); + const offsetY = + -Math.round(zoom.value / gridSize) * gridSize + (scrollY % gridSize); + + const lineWidth = Math.min(1 / zoom.value, 1); + + const spaceWidth = 1 / zoom.value; + const lineDash = [lineWidth * 3, spaceWidth + (lineWidth + spaceWidth)]; + + context.save(); + context.lineWidth = lineWidth; + + for (let x = offsetX; x < offsetX + width + gridSize * 2; x += gridSize) { + const isBold = + Math.round(x - scrollX) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(x, offsetY - gridSize); + context.lineTo(x, offsetY + height + gridSize * 2); + context.stroke(); + } + for (let y = offsetY; y < offsetY + height + gridSize * 2; y += gridSize) { + const isBold = + Math.round(y - scrollY) % (BOLD_LINE_FREQUENCY * gridSize) === 0; + context.beginPath(); + context.setLineDash(isBold ? [] : lineDash); + context.strokeStyle = isBold ? GridLineColor.Bold : GridLineColor.Regular; + context.moveTo(offsetX - gridSize, y); + context.lineTo(offsetX + width + gridSize * 2, y); + context.stroke(); + } + context.restore(); +}; + +const frameClip = ( + frame: ExcalidrawFrameLikeElement, + context: CanvasRenderingContext2D, + renderConfig: StaticCanvasRenderConfig, + appState: StaticCanvasAppState, +) => { + context.translate(frame.x + appState.scrollX, frame.y + appState.scrollY); + context.beginPath(); + if (context.roundRect) { + context.roundRect( + 0, + 0, + frame.width, + frame.height, + FRAME_STYLE.radius / appState.zoom.value, + ); + } else { + context.rect(0, 0, frame.width, frame.height); + } + context.clip(); + context.translate( + -(frame.x + appState.scrollX), + -(frame.y + appState.scrollY), + ); +}; + +let linkCanvasCache: any; +const renderLinkIcon = ( + element: NonDeletedExcalidrawElement, + context: CanvasRenderingContext2D, + appState: StaticCanvasAppState, + elementsMap: ElementsMap, +) => { + if (element.link && !appState.selectedElementIds[element.id]) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + const [x, y, width, height] = getLinkHandleFromCoords( + [x1, y1, x2, y2], + element.angle, + appState, + ); + const centerX = x + width / 2; + const centerY = y + height / 2; + context.save(); + context.translate(appState.scrollX + centerX, appState.scrollY + centerY); + context.rotate(element.angle); + + if (!linkCanvasCache || linkCanvasCache.zoom !== appState.zoom.value) { + linkCanvasCache = document.createElement("canvas"); + linkCanvasCache.zoom = appState.zoom.value; + linkCanvasCache.width = + width * window.devicePixelRatio * appState.zoom.value; + linkCanvasCache.height = + height * window.devicePixelRatio * appState.zoom.value; + const linkCanvasCacheContext = linkCanvasCache.getContext("2d")!; + linkCanvasCacheContext.scale( + window.devicePixelRatio * appState.zoom.value, + window.devicePixelRatio * appState.zoom.value, + ); + linkCanvasCacheContext.fillStyle = "#fff"; + linkCanvasCacheContext.fillRect(0, 0, width, height); + linkCanvasCacheContext.drawImage(EXTERNAL_LINK_IMG, 0, 0, width, height); + linkCanvasCacheContext.restore(); + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } else { + context.drawImage( + linkCanvasCache, + x - centerX, + y - centerY, + width, + height, + ); + } + context.restore(); + } +}; +const _renderStaticScene = ({ + canvas, + rc, + elementsMap, + allElementsMap, + visibleElements, + scale, + appState, + renderConfig, +}: StaticSceneRenderConfig) => { + if (canvas === null) { + return; + } + + const { renderGrid = true, isExporting } = renderConfig; + + const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( + canvas, + scale, + ); + + const context = bootstrapCanvas({ + canvas, + scale, + normalizedWidth, + normalizedHeight, + theme: appState.theme, + isExporting, + viewBackgroundColor: appState.viewBackgroundColor, + }); + + // Apply zoom + context.scale(appState.zoom.value, appState.zoom.value); + + // Grid + if (renderGrid && appState.gridSize) { + strokeGrid( + context, + appState.gridSize, + appState.scrollX, + appState.scrollY, + appState.zoom, + normalizedWidth / appState.zoom.value, + normalizedHeight / appState.zoom.value, + ); + } + + const groupsToBeAddedToFrame = new Set(); + + visibleElements.forEach((element) => { + if ( + element.groupIds.length > 0 && + appState.frameToHighlight && + appState.selectedElementIds[element.id] && + (elementOverlapsWithFrame( + element, + appState.frameToHighlight, + elementsMap, + ) || + element.groupIds.find((groupId) => groupsToBeAddedToFrame.has(groupId))) + ) { + element.groupIds.forEach((groupId) => + groupsToBeAddedToFrame.add(groupId), + ); + } + }); + + // Paint visible elements + visibleElements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + try { + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + // TODO do we need to check isElementInFrame here? + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + context.restore(); + } else { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + } catch (error: any) { + console.error(error); + } + }); + + // render embeddables on top + visibleElements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + try { + const render = () => { + renderElement( + element, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + + if ( + isIframeLikeElement(element) && + (isExporting || + (isEmbeddableElement(element) && + renderConfig.embedsValidationStatus.get(element.id) !== + true)) && + element.width && + element.height + ) { + const label = createPlaceholderEmbeddableLabel(element); + renderElement( + label, + elementsMap, + allElementsMap, + rc, + context, + renderConfig, + appState, + ); + } + if (!isExporting) { + renderLinkIcon(element, context, appState, elementsMap); + } + }; + // - when exporting the whole canvas, we DO NOT apply clipping + // - when we are exporting a particular frame, apply clipping + // if the containing frame is not selected, apply clipping + const frameId = element.frameId || appState.frameToHighlight?.id; + + if ( + frameId && + appState.frameRendering.enabled && + appState.frameRendering.clip + ) { + context.save(); + + const frame = getTargetFrame(element, elementsMap, appState); + + if (frame && isElementInFrame(element, elementsMap, appState)) { + frameClip(frame, context, renderConfig, appState); + } + render(); + context.restore(); + } else { + render(); + } + } catch (error: any) { + console.error(error); + } + }); +}; + +/** throttled to animation framerate */ +export const renderStaticSceneThrottled = throttleRAF( + (config: StaticSceneRenderConfig) => { + _renderStaticScene(config); + }, + { trailing: true }, +); + +/** + * Static scene is the non-ui canvas where we render elements. + */ +export const renderStaticScene = ( + renderConfig: StaticSceneRenderConfig, + throttle?: boolean, +) => { + if (throttle) { + renderStaticSceneThrottled(renderConfig); + return; + } + + _renderStaticScene(renderConfig); +}; diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts new file mode 100644 index 000000000..de026300e --- /dev/null +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -0,0 +1,653 @@ +import { Drawable } from "roughjs/bin/core"; +import { RoughSVG } from "roughjs/bin/svg"; +import { + FRAME_STYLE, + MAX_DECIMALS_FOR_SVG_EXPORT, + MIME_TYPES, + SVG_NS, +} from "../constants"; +import { normalizeLink, toValidURL } from "../data/url"; +import { getElementAbsoluteCoords } from "../element"; +import { + createPlaceholderEmbeddableLabel, + getEmbedLink, +} from "../element/embeddable"; +import { LinearElementEditor } from "../element/linearElementEditor"; +import { + getBoundTextElement, + getContainerElement, + getLineHeightInPx, +} from "../element/textElement"; +import { + isArrowElement, + isIframeLikeElement, + isInitializedImageElement, + isTextElement, +} from "../element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawTextElementWithContainer, + NonDeletedExcalidrawElement, +} from "../element/types"; +import { getContainingFrame } from "../frame"; +import { getCornerRadius, isPathALoop } from "../math"; +import { ShapeCache } from "../scene/ShapeCache"; +import { RenderableElementsMap, SVGRenderConfig } from "../scene/types"; +import { AppState, BinaryFiles } from "../types"; +import { getFontFamilyString, isRTL, isTestEnv } from "../utils"; +import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; + +const roughSVGDrawWithPrecision = ( + rsvg: RoughSVG, + drawable: Drawable, + precision?: number, +) => { + if (typeof precision === "undefined") { + return rsvg.draw(drawable); + } + const pshape: Drawable = { + sets: drawable.sets, + shape: drawable.shape, + options: { ...drawable.options, fixedDecimalPlaceDigits: precision }, + }; + return rsvg.draw(pshape); +}; + +const maybeWrapNodesInFrameClipPath = ( + element: NonDeletedExcalidrawElement, + root: SVGElement, + nodes: SVGElement[], + frameRendering: AppState["frameRendering"], + elementsMap: RenderableElementsMap, +) => { + if (!frameRendering.enabled || !frameRendering.clip) { + return null; + } + const frame = getContainingFrame(element, elementsMap); + if (frame) { + const g = root.ownerDocument!.createElementNS(SVG_NS, "g"); + g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`); + nodes.forEach((node) => g.appendChild(node)); + return g; + } + + return null; +}; + +const renderElementToSvg = ( + element: NonDeletedExcalidrawElement, + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + offsetX: number, + offsetY: number, + renderConfig: SVGRenderConfig, +) => { + const offset = { x: offsetX, y: offsetY }; + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element, elementsMap); + let cx = (x2 - x1) / 2 - (element.x - x1); + let cy = (y2 - y1) / 2 - (element.y - y1); + if (isTextElement(element)) { + const container = getContainerElement(element, elementsMap); + if (isArrowElement(container)) { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(container, elementsMap); + + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + container, + element as ExcalidrawTextElementWithContainer, + elementsMap, + ); + cx = (x2 - x1) / 2 - (boundTextCoords.x - x1); + cy = (y2 - y1) / 2 - (boundTextCoords.y - y1); + offsetX = offsetX + boundTextCoords.x - element.x; + offsetY = offsetY + boundTextCoords.y - element.y; + } + } + const degree = (180 * element.angle) / Math.PI; + + // element to append node to, most of the time svgRoot + let root = svgRoot; + + // if the element has a link, create an anchor tag and make that the new root + if (element.link) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link)); + root.appendChild(anchorTag); + root = anchorTag; + } + + const addToRoot = (node: SVGElement, element: ExcalidrawElement) => { + if (isTestEnv()) { + node.setAttribute("data-id", element.id); + } + root.appendChild(node); + }; + + const opacity = + ((getContainingFrame(element, elementsMap)?.opacity ?? 100) * + element.opacity) / + 10000; + + switch (element.type) { + case "selection": { + // Since this is used only during editing experience, which is canvas based, + // this should not happen + throw new Error("Selection rendering is not supported for SVG"); + } + case "rectangle": + case "diamond": + case "ellipse": { + const shape = ShapeCache.generateElementShape(element, null); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "iframe": + case "embeddable": { + // render placeholder rectangle + const shape = ShapeCache.generateElementShape(element, renderConfig); + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + const opacity = element.opacity / 100; + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute("stroke-linecap", "round"); + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + addToRoot(node, element); + + const label: ExcalidrawElement = + createPlaceholderEmbeddableLabel(element); + renderElementToSvg( + label, + elementsMap, + rsvg, + root, + files, + label.x + offset.x - element.x, + label.y + offset.y - element.y, + renderConfig, + ); + + // render embeddable element + iframe + const embeddableNode = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + embeddableNode.setAttribute("stroke-linecap", "round"); + embeddableNode.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + while (embeddableNode.firstChild) { + embeddableNode.removeChild(embeddableNode.firstChild); + } + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + + const embedLink = getEmbedLink(toValidURL(element.link || "")); + + // if rendering embeddables explicitly disabled or + // embedding documents via srcdoc (which doesn't seem to work for SVGs) + // replace with a link instead + if ( + renderConfig.renderEmbeddables === false || + embedLink?.type === "document" + ) { + const anchorTag = svgRoot.ownerDocument!.createElementNS(SVG_NS, "a"); + anchorTag.setAttribute("href", normalizeLink(element.link || "")); + anchorTag.setAttribute("target", "_blank"); + anchorTag.setAttribute("rel", "noopener noreferrer"); + anchorTag.style.borderRadius = `${radius}px`; + + embeddableNode.appendChild(anchorTag); + } else { + const foreignObject = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "foreignObject", + ); + foreignObject.style.width = `${element.width}px`; + foreignObject.style.height = `${element.height}px`; + foreignObject.style.border = "none"; + const div = foreignObject.ownerDocument!.createElementNS(SVG_NS, "div"); + div.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + div.style.width = "100%"; + div.style.height = "100%"; + const iframe = div.ownerDocument!.createElement("iframe"); + iframe.src = embedLink?.link ?? ""; + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + iframe.style.borderRadius = `${radius}px`; + iframe.style.top = "0"; + iframe.style.left = "0"; + iframe.allowFullscreen = true; + div.appendChild(iframe); + foreignObject.appendChild(div); + + embeddableNode.appendChild(foreignObject); + } + addToRoot(embeddableNode, element); + break; + } + case "line": + case "arrow": { + const boundText = getBoundTextElement(element, elementsMap); + const maskPath = svgRoot.ownerDocument!.createElementNS(SVG_NS, "mask"); + if (boundText) { + maskPath.setAttribute("id", `mask-${element.id}`); + const maskRectVisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + offsetX = offsetX || 0; + offsetY = offsetY || 0; + maskRectVisible.setAttribute("x", "0"); + maskRectVisible.setAttribute("y", "0"); + maskRectVisible.setAttribute("fill", "#fff"); + maskRectVisible.setAttribute( + "width", + `${element.width + 100 + offsetX}`, + ); + maskRectVisible.setAttribute( + "height", + `${element.height + 100 + offsetY}`, + ); + + maskPath.appendChild(maskRectVisible); + const maskRectInvisible = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const boundTextCoords = LinearElementEditor.getBoundTextElementPosition( + element, + boundText, + elementsMap, + ); + + const maskX = offsetX + boundTextCoords.x - element.x; + const maskY = offsetY + boundTextCoords.y - element.y; + + maskRectInvisible.setAttribute("x", maskX.toString()); + maskRectInvisible.setAttribute("y", maskY.toString()); + maskRectInvisible.setAttribute("fill", "#000"); + maskRectInvisible.setAttribute("width", `${boundText.width}`); + maskRectInvisible.setAttribute("height", `${boundText.height}`); + maskRectInvisible.setAttribute("opacity", "1"); + maskPath.appendChild(maskRectInvisible); + } + const group = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (boundText) { + group.setAttribute("mask", `url(#mask-${element.id})`); + } + group.setAttribute("stroke-linecap", "round"); + + const shapes = ShapeCache.generateElementShape(element, renderConfig); + shapes.forEach((shape) => { + const node = roughSVGDrawWithPrecision( + rsvg, + shape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + if ( + element.type === "line" && + isPathALoop(element.points) && + element.backgroundColor !== "transparent" + ) { + node.setAttribute("fill-rule", "evenodd"); + } + group.appendChild(node); + }); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [group, maskPath], + renderConfig.frameRendering, + elementsMap, + ); + if (g) { + addToRoot(g, element); + root.appendChild(g); + } else { + addToRoot(group, element); + root.append(maskPath); + } + break; + } + case "freedraw": { + const backgroundFillShape = ShapeCache.generateElementShape( + element, + renderConfig, + ); + const node = backgroundFillShape + ? roughSVGDrawWithPrecision( + rsvg, + backgroundFillShape, + MAX_DECIMALS_FOR_SVG_EXPORT, + ) + : svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + node.setAttribute("stroke", "none"); + const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); + path.setAttribute("fill", element.strokeColor); + path.setAttribute("d", getFreeDrawSvgPath(element)); + node.appendChild(path); + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + break; + } + case "image": { + const width = Math.round(element.width); + const height = Math.round(element.height); + const fileData = + isInitializedImageElement(element) && files[element.fileId]; + if (fileData) { + const symbolId = `image-${fileData.id}`; + let symbol = svgRoot.querySelector(`#${symbolId}`); + if (!symbol) { + symbol = svgRoot.ownerDocument!.createElementNS(SVG_NS, "symbol"); + symbol.id = symbolId; + + const image = svgRoot.ownerDocument!.createElementNS(SVG_NS, "image"); + + image.setAttribute("width", "100%"); + image.setAttribute("height", "100%"); + image.setAttribute("href", fileData.dataURL); + + symbol.appendChild(image); + + root.prepend(symbol); + } + + const use = svgRoot.ownerDocument!.createElementNS(SVG_NS, "use"); + use.setAttribute("href", `#${symbolId}`); + + // in dark theme, revert the image color filter + if ( + renderConfig.exportWithDarkMode && + fileData.mimeType !== MIME_TYPES.svg + ) { + use.setAttribute("filter", IMAGE_INVERT_FILTER); + } + + use.setAttribute("width", `${width}`); + use.setAttribute("height", `${height}`); + use.setAttribute("opacity", `${opacity}`); + + // We first apply `scale` transforms (horizontal/vertical mirroring) + // on the element, then apply translation and rotation + // on the element which wraps the . + // Doing this separately is a quick hack to to work around compositing + // the transformations correctly (the transform-origin was not being + // applied correctly). + if (element.scale[0] !== 1 || element.scale[1] !== 1) { + const translateX = element.scale[0] !== 1 ? -width : 0; + const translateY = element.scale[1] !== 1 ? -height : 0; + use.setAttribute( + "transform", + `scale(${element.scale[0]}, ${element.scale[1]}) translate(${translateX} ${translateY})`, + ); + } + + const g = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + g.appendChild(use); + g.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + if (element.roundness) { + const clipPath = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "clipPath", + ); + clipPath.id = `image-clipPath-${element.id}`; + + const clipRect = svgRoot.ownerDocument!.createElementNS( + SVG_NS, + "rect", + ); + const radius = getCornerRadius( + Math.min(element.width, element.height), + element, + ); + clipRect.setAttribute("width", `${element.width}`); + clipRect.setAttribute("height", `${element.height}`); + clipRect.setAttribute("rx", `${radius}`); + clipRect.setAttribute("ry", `${radius}`); + clipPath.appendChild(clipRect); + addToRoot(clipPath, element); + + g.setAttributeNS(SVG_NS, "clip-path", `url(#${clipPath.id})`); + } + + const clipG = maybeWrapNodesInFrameClipPath( + element, + root, + [g], + renderConfig.frameRendering, + elementsMap, + ); + addToRoot(clipG || g, element); + } + break; + } + // frames are not rendered and only acts as a container + case "frame": + case "magicframe": { + if ( + renderConfig.frameRendering.enabled && + renderConfig.frameRendering.outline + ) { + const rect = document.createElementNS(SVG_NS, "rect"); + + rect.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + + rect.setAttribute("width", `${element.width}px`); + rect.setAttribute("height", `${element.height}px`); + // Rounded corners + rect.setAttribute("rx", FRAME_STYLE.radius.toString()); + rect.setAttribute("ry", FRAME_STYLE.radius.toString()); + + rect.setAttribute("fill", "none"); + rect.setAttribute("stroke", FRAME_STYLE.strokeColor); + rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); + + addToRoot(rect, element); + } + break; + } + default: { + if (isTextElement(element)) { + const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g"); + if (opacity !== 1) { + node.setAttribute("stroke-opacity", `${opacity}`); + node.setAttribute("fill-opacity", `${opacity}`); + } + + node.setAttribute( + "transform", + `translate(${offsetX || 0} ${ + offsetY || 0 + }) rotate(${degree} ${cx} ${cy})`, + ); + const lines = element.text.replace(/\r\n?/g, "\n").split("\n"); + const lineHeightPx = getLineHeightInPx( + element.fontSize, + element.lineHeight, + ); + const horizontalOffset = + element.textAlign === "center" + ? element.width / 2 + : element.textAlign === "right" + ? element.width + : 0; + const direction = isRTL(element.text) ? "rtl" : "ltr"; + const textAnchor = + element.textAlign === "center" + ? "middle" + : element.textAlign === "right" || direction === "rtl" + ? "end" + : "start"; + for (let i = 0; i < lines.length; i++) { + const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); + text.textContent = lines[i]; + text.setAttribute("x", `${horizontalOffset}`); + text.setAttribute("y", `${i * lineHeightPx}`); + text.setAttribute("font-family", getFontFamilyString(element)); + text.setAttribute("font-size", `${element.fontSize}px`); + text.setAttribute("fill", element.strokeColor); + text.setAttribute("text-anchor", textAnchor); + text.setAttribute("style", "white-space: pre;"); + text.setAttribute("direction", direction); + text.setAttribute("dominant-baseline", "text-before-edge"); + node.appendChild(text); + } + + const g = maybeWrapNodesInFrameClipPath( + element, + root, + [node], + renderConfig.frameRendering, + elementsMap, + ); + + addToRoot(g || node, element); + } else { + // @ts-ignore + throw new Error(`Unimplemented type ${element.type}`); + } + } + } +}; + +export const renderSceneToSvg = ( + elements: readonly NonDeletedExcalidrawElement[], + elementsMap: RenderableElementsMap, + rsvg: RoughSVG, + svgRoot: SVGElement, + files: BinaryFiles, + renderConfig: SVGRenderConfig, +) => { + if (!svgRoot) { + return; + } + + // render elements + elements + .filter((el) => !isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); + + // render embeddables on top + elements + .filter((el) => isIframeLikeElement(el)) + .forEach((element) => { + if (!element.isDeleted) { + try { + renderElementToSvg( + element, + elementsMap, + rsvg, + svgRoot, + files, + element.x + renderConfig.offsetX, + element.y + renderConfig.offsetY, + renderConfig, + ); + } catch (error: any) { + console.error(error); + } + } + }); +}; diff --git a/packages/excalidraw/scene/Renderer.ts b/packages/excalidraw/scene/Renderer.ts index 0875b9f05..7970f8c1c 100644 --- a/packages/excalidraw/scene/Renderer.ts +++ b/packages/excalidraw/scene/Renderer.ts @@ -4,7 +4,9 @@ import { NonDeletedElementsMap, NonDeletedExcalidrawElement, } from "../element/types"; -import { cancelRender } from "../renderer/renderScene"; +import { renderInteractiveSceneThrottled } from "../renderer/interactiveScene"; +import { renderStaticSceneThrottled } from "../renderer/staticScene"; + import { AppState } from "../types"; import { memoize, toBrandedType } from "../utils"; import Scene from "./Scene"; @@ -147,7 +149,8 @@ export class Renderer { // NOTE Doesn't destroy everything (scene, rc, etc.) because it may not be // safe to break TS contract here (for upstream cases) public destroy() { - cancelRender(); + renderInteractiveSceneThrottled.cancel(); + renderStaticSceneThrottled.cancel(); this.getRenderableElements.clear(); } } diff --git a/packages/excalidraw/scene/export.ts b/packages/excalidraw/scene/export.ts index 42a417cc8..8733c997e 100644 --- a/packages/excalidraw/scene/export.ts +++ b/packages/excalidraw/scene/export.ts @@ -11,7 +11,7 @@ import { getCommonBounds, getElementAbsoluteCoords, } from "../element/bounds"; -import { renderSceneToSvg, renderStaticScene } from "../renderer/renderScene"; +import { renderSceneToSvg } from "../renderer/staticSvgScene"; import { arrayToMap, distance, getFontString, toBrandedType } from "../utils"; import { AppState, BinaryFiles } from "../types"; import { @@ -38,6 +38,7 @@ import { Mutable } from "../utility-types"; import { newElementWith } from "../element/mutateElement"; import { isFrameElement, isFrameLikeElement } from "../element/typeChecks"; import { RenderableElementsMap } from "./types"; +import { renderStaticScene } from "../renderer/staticScene"; const SVG_EXPORT_TAG = ``; diff --git a/packages/excalidraw/tests/App.test.tsx b/packages/excalidraw/tests/App.test.tsx index 316d274ef..9fb055453 100644 --- a/packages/excalidraw/tests/App.test.tsx +++ b/packages/excalidraw/tests/App.test.tsx @@ -1,12 +1,12 @@ import ReactDOM from "react-dom"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { render, queryByTestId } from "../tests/test-utils"; import { Excalidraw } from "../index"; import { vi } from "vitest"; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); describe("Test ", () => { beforeEach(async () => { diff --git a/packages/excalidraw/tests/contextmenu.test.tsx b/packages/excalidraw/tests/contextmenu.test.tsx index 8c413d003..f034dbd8c 100644 --- a/packages/excalidraw/tests/contextmenu.test.tsx +++ b/packages/excalidraw/tests/contextmenu.test.tsx @@ -12,7 +12,7 @@ import { togglePopover, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { reseed } from "../random"; import { UI, Pointer, Keyboard } from "./helpers/ui"; import { KEYS } from "../keys"; @@ -39,7 +39,7 @@ const mouse = new Pointer("mouse"); // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); renderStaticScene.mockClear(); diff --git a/packages/excalidraw/tests/dragCreate.test.tsx b/packages/excalidraw/tests/dragCreate.test.tsx index a34696d81..7bde27b1c 100644 --- a/packages/excalidraw/tests/dragCreate.test.tsx +++ b/packages/excalidraw/tests/dragCreate.test.tsx @@ -1,6 +1,7 @@ import ReactDOM from "react-dom"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveScene from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { render, @@ -15,8 +16,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveScene, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/linearElementEditor.test.tsx b/packages/excalidraw/tests/linearElementEditor.test.tsx index 6c01987c9..551b79479 100644 --- a/packages/excalidraw/tests/linearElementEditor.test.tsx +++ b/packages/excalidraw/tests/linearElementEditor.test.tsx @@ -8,7 +8,9 @@ import { import { Excalidraw } from "../index"; import { centerPoint } from "../math"; import { reseed } from "../random"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; + import { Keyboard, Pointer, UI } from "./helpers/ui"; import { screen, render, fireEvent, GlobalTestState } from "./test-utils"; import { API } from "../tests/helpers/api"; @@ -26,8 +28,11 @@ import { ROUNDNESS, VERTICAL_ALIGN } from "../constants"; import { vi } from "vitest"; import { arrayToMap } from "../utils"; -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const { h } = window; const font = "20px Cascadia, width: Segoe UI Emoji" as FontString; diff --git a/packages/excalidraw/tests/move.test.tsx b/packages/excalidraw/tests/move.test.tsx index 06086f119..8a0e562be 100644 --- a/packages/excalidraw/tests/move.test.tsx +++ b/packages/excalidraw/tests/move.test.tsx @@ -1,7 +1,8 @@ import ReactDOM from "react-dom"; import { render, fireEvent } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { reseed } from "../random"; import { bindOrUnbindLinearElement } from "../element/binding"; import { @@ -16,8 +17,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/multiPointCreate.test.tsx b/packages/excalidraw/tests/multiPointCreate.test.tsx index f462cfacf..bc8c7843d 100644 --- a/packages/excalidraw/tests/multiPointCreate.test.tsx +++ b/packages/excalidraw/tests/multiPointCreate.test.tsx @@ -6,7 +6,8 @@ import { restoreOriginalGetBoundingClientRect, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { ExcalidrawLinearElement } from "../element/types"; import { reseed } from "../random"; @@ -15,8 +16,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); diff --git a/packages/excalidraw/tests/regressionTests.test.tsx b/packages/excalidraw/tests/regressionTests.test.tsx index 22f2c0159..e15a12ed2 100644 --- a/packages/excalidraw/tests/regressionTests.test.tsx +++ b/packages/excalidraw/tests/regressionTests.test.tsx @@ -3,7 +3,7 @@ import { ExcalidrawElement } from "../element/types"; import { CODES, KEYS } from "../keys"; import { Excalidraw } from "../index"; import { reseed } from "../random"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; import { setDateTimeForTests } from "../utils"; import { API } from "./helpers/api"; import { Keyboard, Pointer, UI } from "./helpers/ui"; @@ -19,7 +19,7 @@ import { vi } from "vitest"; const { h } = window; -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); const mouse = new Pointer("mouse"); const finger1 = new Pointer("touch", 1); diff --git a/packages/excalidraw/tests/selection.test.tsx b/packages/excalidraw/tests/selection.test.tsx index 54f244ece..18e0dfe2a 100644 --- a/packages/excalidraw/tests/selection.test.tsx +++ b/packages/excalidraw/tests/selection.test.tsx @@ -7,7 +7,8 @@ import { assertSelectedElements, } from "./test-utils"; import { Excalidraw } from "../index"; -import * as Renderer from "../renderer/renderScene"; +import * as StaticScene from "../renderer/staticScene"; +import * as InteractiveCanvas from "../renderer/interactiveScene"; import { KEYS } from "../keys"; import { reseed } from "../random"; import { API } from "./helpers/api"; @@ -18,8 +19,11 @@ import { vi } from "vitest"; // Unmount ReactDOM from root ReactDOM.unmountComponentAtNode(document.getElementById("root")!); -const renderInteractiveScene = vi.spyOn(Renderer, "renderInteractiveScene"); -const renderStaticScene = vi.spyOn(Renderer, "renderStaticScene"); +const renderInteractiveScene = vi.spyOn( + InteractiveCanvas, + "renderInteractiveScene", +); +const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene"); beforeEach(() => { localStorage.clear(); From 36e56267c947319d8d69c17eeebc2f378c54f433 Mon Sep 17 00:00:00 2001 From: Wabweni Brian <115115387+WabweniBrian@users.noreply.github.com> Date: Tue, 27 Feb 2024 10:19:20 +0300 Subject: [PATCH 11/66] docs: add missing closing angle bracket in integration.mdx (#7729) Update integration.mdx: Fix missing closing angle bracket in code sample A closing angle bracket was missing in a code sample. Original code:
Changes:
--- dev-docs/docs/@excalidraw/excalidraw/integration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx index b9edda725..391b5800b 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/integration.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/integration.mdx @@ -70,7 +70,7 @@ If you are using `pages router` then importing the wrapper dynamically would wor height: 141.9765625, },])); return ( -
); From af1a3d5b76b47af5d2ba8a6ea9684525576c2732 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 28 Feb 2024 11:14:57 +0530 Subject: [PATCH 12/66] fix: export utils from excalidraw package in excalidraw library (#7731) * fix: export utils from excalidraw package in excalidraw library * don't export utils utilities * fix import path * fix export * don't export export utilites * fix export paths * reexport utils from excalidraw package * add exports from withinBounds * fix path --- packages/excalidraw/frame.ts | 5 +---- packages/excalidraw/index.tsx | 19 +++++++++++-------- packages/utils/export.ts | 18 ------------------ packages/utils/index.js | 1 - packages/utils/index.ts | 3 +++ 5 files changed, 15 insertions(+), 31 deletions(-) delete mode 100644 packages/utils/index.js create mode 100644 packages/utils/index.ts diff --git a/packages/excalidraw/frame.ts b/packages/excalidraw/frame.ts index cc80531ee..d627fc4c9 100644 --- a/packages/excalidraw/frame.ts +++ b/packages/excalidraw/frame.ts @@ -23,10 +23,7 @@ import { getElementsWithinSelection, getSelectedElements } from "./scene"; import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups"; import type { ExcalidrawElementsIncludingDeleted } from "./scene/Scene"; import { getElementLineSegments } from "./element/bounds"; -import { - doLineSegmentsIntersect, - elementsOverlappingBBox, -} from "../utils/export"; +import { doLineSegmentsIntersect, elementsOverlappingBBox } from "../utils/"; import { isFrameElement, isFrameLikeElement } from "./element/typeChecks"; import { ReadonlySetLike } from "./utility-types"; diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index f7be8affc..2dae37c6b 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -217,19 +217,22 @@ export { restoreElements, restoreLibraryItems, } from "./data/restore"; + export { exportToCanvas, exportToBlob, exportToSvg, - serializeAsJSON, - serializeLibraryAsJSON, - loadLibraryFromBlob, + exportToClipboard, +} from "../utils/export"; + +export { serializeAsJSON, serializeLibraryAsJSON } from "./data/json"; +export { loadFromBlob, loadSceneOrLibraryFromBlob, - getFreeDrawSvgPath, - exportToClipboard, - mergeLibraryItems, -} from "../utils/export"; + loadLibraryFromBlob, +} from "./data/blob"; +export { getFreeDrawSvgPath } from "./renderer/renderElement"; +export { mergeLibraryItems } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; @@ -268,4 +271,4 @@ export { elementsOverlappingBBox, isElementInsideBBox, elementPartiallyOverlapsWithOrContainsBBox, -} from "../utils/export"; +} from "../utils/withinBounds"; diff --git a/packages/utils/export.ts b/packages/utils/export.ts index ceb733881..5bdddba4f 100644 --- a/packages/utils/export.ts +++ b/packages/utils/export.ts @@ -205,21 +205,3 @@ export const exportToClipboard = async ( throw new Error("Invalid export type"); } }; - -export * from "./bbox"; -export { - elementsOverlappingBBox, - isElementInsideBBox, - elementPartiallyOverlapsWithOrContainsBBox, -} from "./withinBounds"; -export { - serializeAsJSON, - serializeLibraryAsJSON, -} from "../excalidraw/data/json"; -export { - loadFromBlob, - loadSceneOrLibraryFromBlob, - loadLibraryFromBlob, -} from "../excalidraw/data/blob"; -export { getFreeDrawSvgPath } from "../excalidraw/renderer/renderElement"; -export { mergeLibraryItems } from "../excalidraw/data/library"; diff --git a/packages/utils/index.js b/packages/utils/index.js deleted file mode 100644 index ffea9c3cf..000000000 --- a/packages/utils/index.js +++ /dev/null @@ -1 +0,0 @@ -export * from "./export"; diff --git a/packages/utils/index.ts b/packages/utils/index.ts new file mode 100644 index 000000000..d199849eb --- /dev/null +++ b/packages/utils/index.ts @@ -0,0 +1,3 @@ +export * from "./export"; +export * from "./withinBounds"; +export * from "./bbox"; From 99601baffc43cddf74bda9a0c6f50ce924e578ad Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Wed, 28 Feb 2024 19:33:47 +0530 Subject: [PATCH 13/66] =?UTF-8?q?build:=20create=20ESM=20build=20for=20uti?= =?UTF-8?q?ls=20package=20=F0=9F=A5=B3=20(#7500)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: create ESM build for utils package * add deps, exports and import.meta --- .gitignore | 3 +- packages/utils/package.json | 23 ++++++- scripts/buildUtils.js | 123 ++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 scripts/buildUtils.js diff --git a/.gitignore b/.gitignore index 21d2730a2..81b63339f 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ packages/excalidraw/types coverage dev-dist html -examples/**/bundle.* \ No newline at end of file +examples/**/bundle.* +meta*.json \ No newline at end of file diff --git a/packages/utils/package.json b/packages/utils/package.json index 7375e8b58..cfa1c4375 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,15 @@ { "name": "@excalidraw/utils", "version": "0.1.2", - "main": "dist/excalidraw-utils.min.js", + "main": "./dist/prod/index.js", + "type": "module", + "module": "./dist/prod/index.js", + "exports": { + ".": { + "development": "./dist/dev/index.js", + "default": "./dist/prod/index.js" + } + }, "files": [ "dist/*" ], @@ -33,6 +41,18 @@ "last 1 safari version" ] }, + "dependencies": { + "@braintree/sanitize-url": "6.0.2", + "@excalidraw/laser-pointer": "1.3.1", + "browser-fs-access": "0.29.1", + "open-color": "1.9.1", + "pako": "1.0.11", + "perfect-freehand": "1.2.0", + "png-chunk-text": "1.0.0", + "png-chunks-encode": "1.0.0", + "png-chunks-extract": "1.0.0", + "roughjs": "4.6.4" + }, "devDependencies": { "@babel/core": "7.18.9", "@babel/plugin-transform-arrow-functions": "7.18.6", @@ -56,6 +76,7 @@ "repository": "https://github.com/excalidraw/excalidraw", "scripts": { "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js", + "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js", "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", "pack": "yarn build:umd && yarn pack" } diff --git a/scripts/buildUtils.js b/scripts/buildUtils.js new file mode 100644 index 000000000..e910fc9f3 --- /dev/null +++ b/scripts/buildUtils.js @@ -0,0 +1,123 @@ +const { build } = require("esbuild"); +const { sassPlugin } = require("esbuild-sass-plugin"); + +const fs = require("fs"); + +const browserConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", + plugins: [sassPlugin()], +}; + +// Will be used later for treeshaking + +// function getFiles(dir, files = []) { +// const fileList = fs.readdirSync(dir); +// for (const file of fileList) { +// const name = `${dir}/${file}`; +// if ( +// name.includes("node_modules") || +// name.includes("config") || +// name.includes("package.json") || +// name.includes("main.js") || +// name.includes("index-node.ts") || +// name.endsWith(".d.ts") || +// name.endsWith(".md") +// ) { +// continue; +// } + +// if (fs.statSync(name).isDirectory()) { +// getFiles(name, files); +// } else if ( +// name.match(/\.(sa|sc|c)ss$/) || +// name.match(/\.(woff|woff2|eot|ttf|otf)$/) || +// name.match(/locales\/[^/]+\.json$/) +// ) { +// continue; +// } else { +// files.push(name); +// } +// } +// return files; +// } +const createESMBrowserBuild = async () => { + // Development unminified build with source maps + const browserDev = await build({ + ...browserConfig, + outdir: "dist/browser/dev", + sourcemap: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + fs.writeFileSync( + "meta-browser-dev.json", + JSON.stringify(browserDev.metafile), + ); + + // production minified build without sourcemaps + const browserProd = await build({ + ...browserConfig, + outdir: "dist/browser/prod", + minify: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); + fs.writeFileSync( + "meta-browser-prod.json", + JSON.stringify(browserProd.metafile), + ); +}; + +const rawConfig = { + entryPoints: ["index.ts"], + bundle: true, + format: "esm", + packages: "external", + plugins: [sassPlugin()], +}; + +// const BASE_PATH = `${path.resolve(`${__dirname}/..`)}`; +// const filesinExcalidrawPackage = getFiles(`${BASE_PATH}/packages/utils`); + +// const filesToTransform = filesinExcalidrawPackage.filter((file) => { +// return !( +// file.includes("/__tests__/") || +// file.includes(".test.") || +// file.includes("/tests/") || +// file.includes("example") +// ); +// }); +const createESMRawBuild = async () => { + // Development unminified build with source maps + const rawDev = await build({ + ...rawConfig, + outdir: "dist/dev", + sourcemap: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ DEV: true }), + }, + }); + fs.writeFileSync("meta-raw-dev.json", JSON.stringify(rawDev.metafile)); + + // production minified build without sourcemaps + const rawProd = await build({ + ...rawConfig, + outdir: "dist/prod", + minify: true, + metafile: true, + define: { + "import.meta.env": JSON.stringify({ PROD: true }), + }, + }); + fs.writeFileSync("meta-raw-prod.json", JSON.stringify(rawProd.metafile)); +}; + +createESMRawBuild(); +createESMBrowserBuild(); From f207bd0a1c99bfc746ec19f7a295162b35960636 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 29 Feb 2024 15:43:04 +0530 Subject: [PATCH 14/66] build: export types for @excalidraw/utils (#7736) * build: export types for @excalidraw/utils * fix * add types --- packages/utils/global.d.ts | 3 +++ packages/utils/package.json | 5 ++++- packages/utils/tsconfig.json | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/utils/global.d.ts create mode 100644 packages/utils/tsconfig.json diff --git a/packages/utils/global.d.ts b/packages/utils/global.d.ts new file mode 100644 index 000000000..faf1d1878 --- /dev/null +++ b/packages/utils/global.d.ts @@ -0,0 +1,3 @@ +/// +import "../excalidraw/global"; +import "../excalidraw/css"; diff --git a/packages/utils/package.json b/packages/utils/package.json index cfa1c4375..d8374f8b7 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -10,6 +10,7 @@ "default": "./dist/prod/index.js" } }, + "types": "./dist/utils/index.d.ts", "files": [ "dist/*" ], @@ -68,6 +69,7 @@ "file-loader": "6.2.0", "sass-loader": "13.0.2", "ts-loader": "9.3.1", + "typescript": "4.9.4", "webpack": "5.76.0", "webpack-bundle-analyzer": "4.5.0", "webpack-cli": "4.10.0" @@ -75,8 +77,9 @@ "bugs": "https://github.com/excalidraw/excalidraw/issues", "repository": "https://github.com/excalidraw/excalidraw", "scripts": { + "gen:types": "rm -rf types && tsc", "build:umd": "cross-env NODE_ENV=production webpack --config webpack.prod.config.js", - "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js", + "build:esm": "rm -rf dist && node ../../scripts/buildUtils.js && yarn gen:types", "build:umd:withAnalyzer": "cross-env NODE_ENV=production ANALYZER=true webpack --config webpack.prod.config.js", "pack": "yarn build:umd && yarn pack" } diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 000000000..f8aa631e9 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "strict": true, + "outDir": "dist", + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "allowSyntheticDefaultImports": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "exclude": ["**/*.test.*", "**/tests/*", "types", "dist"] +} From 160440b8607fd6c2502487814f3524716a1a5c52 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Mon, 4 Mar 2024 20:43:44 +0800 Subject: [PATCH 15/66] feat: improve collab error notification (#7741) * identify cause * toast after dialog for error messages in collab * remove comment * shake tooltip instead for repeating collab errors * clear collab error * empty commit * simplify & fix reset race condition --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 17 ++++--- excalidraw-app/collab/Collab.tsx | 64 +++++++++++++++++++----- excalidraw-app/collab/CollabError.scss | 35 +++++++++++++ excalidraw-app/collab/CollabError.tsx | 54 ++++++++++++++++++++ excalidraw-app/index.scss | 7 +++ excalidraw-app/share/ShareDialog.tsx | 2 +- packages/excalidraw/components/Toast.tsx | 5 +- packages/excalidraw/components/icons.tsx | 4 ++ 8 files changed, 168 insertions(+), 20 deletions(-) create mode 100644 excalidraw-app/collab/CollabError.scss create mode 100644 excalidraw-app/collab/CollabError.tsx diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 972737b9d..7517bb379 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -104,6 +104,7 @@ import { openConfirmModal } from "../packages/excalidraw/components/OverwriteCon import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; import Trans from "../packages/excalidraw/components/Trans"; import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; +import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; polyfill(); @@ -310,6 +311,7 @@ const ExcalidrawWrapper = () => { const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => { return isCollaborationLink(window.location.href); }); + const collabError = useAtomValue(collabErrorIndicatorAtom); useHandleLibrary({ excalidrawAPI, @@ -748,12 +750,15 @@ const ExcalidrawWrapper = () => { return null; } return ( - - setShareDialogState({ isOpen: true, type: "share" }) - } - /> +
+ {collabError.message && } + + setShareDialogState({ isOpen: true, type: "share" }) + } + /> +
); }} > diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index 14538b674..f7879c64e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -81,6 +81,7 @@ import { appJotaiStore } from "../app-jotai"; import { Mutable, ValueOf } from "../../packages/excalidraw/utility-types"; import { getVisibleSceneBounds } from "../../packages/excalidraw/element/bounds"; import { withBatchedUpdates } from "../../packages/excalidraw/reactUtils"; +import { collabErrorIndicatorAtom } from "./CollabError"; export const collabAPIAtom = atom(null); export const isCollaboratingAtom = atom(false); @@ -88,6 +89,8 @@ export const isOfflineAtom = atom(false); interface CollabState { errorMessage: string | null; + /** errors related to saving */ + dialogNotifiedErrors: Record; username: string; activeRoomLink: string | null; } @@ -107,7 +110,7 @@ export interface CollabAPI { setUsername: CollabInstance["setUsername"]; getUsername: CollabInstance["getUsername"]; getActiveRoomLink: CollabInstance["getActiveRoomLink"]; - setErrorMessage: CollabInstance["setErrorMessage"]; + setCollabError: CollabInstance["setErrorDialog"]; } interface CollabProps { @@ -129,6 +132,7 @@ class Collab extends PureComponent { super(props); this.state = { errorMessage: null, + dialogNotifiedErrors: {}, username: importUsernameFromLocalStorage() || "", activeRoomLink: null, }; @@ -197,7 +201,7 @@ class Collab extends PureComponent { setUsername: this.setUsername, getUsername: this.getUsername, getActiveRoomLink: this.getActiveRoomLink, - setErrorMessage: this.setErrorMessage, + setCollabError: this.setErrorDialog, }; appJotaiStore.set(collabAPIAtom, collabAPI); @@ -276,18 +280,35 @@ class Collab extends PureComponent { this.excalidrawAPI.getAppState(), ); + this.resetErrorIndicator(); + if (this.isCollaborating() && savedData && savedData.reconciledElements) { this.handleRemoteSceneUpdate( this.reconcileElements(savedData.reconciledElements), ); } } catch (error: any) { - this.setState({ - // firestore doesn't return a specific error code when size exceeded - errorMessage: /is longer than.*?bytes/.test(error.message) - ? t("errors.collabSaveFailed_sizeExceeded") - : t("errors.collabSaveFailed"), - }); + const errorMessage = /is longer than.*?bytes/.test(error.message) + ? t("errors.collabSaveFailed_sizeExceeded") + : t("errors.collabSaveFailed"); + + if ( + !this.state.dialogNotifiedErrors[errorMessage] || + !this.isCollaborating() + ) { + this.setErrorDialog(errorMessage); + this.setState({ + dialogNotifiedErrors: { + ...this.state.dialogNotifiedErrors, + [errorMessage]: true, + }, + }); + } + + if (this.isCollaborating()) { + this.setErrorIndicator(errorMessage); + } + console.error(error); } }; @@ -296,6 +317,7 @@ class Collab extends PureComponent { this.queueBroadcastAllElements.cancel(); this.queueSaveToFirebase.cancel(); this.loadImageFiles.cancel(); + this.resetErrorIndicator(true); this.saveCollabRoomToFirebase( getSyncableElements( @@ -464,7 +486,7 @@ class Collab extends PureComponent { this.portal.socket.once("connect_error", fallbackInitializationHandler); } catch (error: any) { console.error(error); - this.setState({ errorMessage: error.message }); + this.setErrorDialog(error.message); return null; } @@ -923,8 +945,26 @@ class Collab extends PureComponent { getActiveRoomLink = () => this.state.activeRoomLink; - setErrorMessage = (errorMessage: string | null) => { - this.setState({ errorMessage }); + setErrorIndicator = (errorMessage: string | null) => { + appJotaiStore.set(collabErrorIndicatorAtom, { + message: errorMessage, + nonce: Date.now(), + }); + }; + + resetErrorIndicator = (resetDialogNotifiedErrors = false) => { + appJotaiStore.set(collabErrorIndicatorAtom, { message: null, nonce: 0 }); + if (resetDialogNotifiedErrors) { + this.setState({ + dialogNotifiedErrors: {}, + }); + } + }; + + setErrorDialog = (errorMessage: string | null) => { + this.setState({ + errorMessage, + }); }; render() { @@ -933,7 +973,7 @@ class Collab extends PureComponent { return ( <> {errorMessage != null && ( - this.setState({ errorMessage: null })}> + this.setErrorDialog(null)}> {errorMessage} )} diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss new file mode 100644 index 000000000..085dc5609 --- /dev/null +++ b/excalidraw-app/collab/CollabError.scss @@ -0,0 +1,35 @@ +@import "../../packages/excalidraw/css/variables.module.scss"; + +.excalidraw { + .collab-errors-button { + width: 26px; + height: 26px; + margin-inline-end: 1rem; + + color: var(--color-danger); + + flex-shrink: 0; + } + + .collab-errors-button-shake { + animation: strong-shake 0.15s 6; + } + + @keyframes strong-shake { + 0% { + transform: rotate(0deg); + } + 25% { + transform: rotate(10deg); + } + 50% { + transform: rotate(0eg); + } + 75% { + transform: rotate(-10deg); + } + 100% { + transform: rotate(0deg); + } + } +} diff --git a/excalidraw-app/collab/CollabError.tsx b/excalidraw-app/collab/CollabError.tsx new file mode 100644 index 000000000..45a98ac8d --- /dev/null +++ b/excalidraw-app/collab/CollabError.tsx @@ -0,0 +1,54 @@ +import { Tooltip } from "../../packages/excalidraw/components/Tooltip"; +import { warning } from "../../packages/excalidraw/components/icons"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "react"; + +import "./CollabError.scss"; +import { atom } from "jotai"; + +type ErrorIndicator = { + message: string | null; + /** used to rerun the useEffect responsible for animation */ + nonce: number; +}; + +export const collabErrorIndicatorAtom = atom({ + message: null, + nonce: 0, +}); + +const CollabError = ({ collabError }: { collabError: ErrorIndicator }) => { + const [isAnimating, setIsAnimating] = useState(false); + const clearAnimationRef = useRef(); + + useEffect(() => { + setIsAnimating(true); + clearAnimationRef.current = setTimeout(() => { + setIsAnimating(false); + }, 1000); + + return () => { + clearTimeout(clearAnimationRef.current); + }; + }, [collabError.message, collabError.nonce]); + + if (!collabError.message) { + return null; + } + + return ( + +
+ {warning} +
+
+ ); +}; + +CollabError.displayName = "CollabError"; + +export default CollabError; diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index d7ab79836..021442753 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -4,6 +4,13 @@ &.theme--dark { --color-primary-contrast-offset: #726dff; // to offset Chubb illusion } + + .top-right-ui { + display: flex; + justify-content: center; + align-items: center; + } + .footer-center { justify-content: flex-end; margin-top: auto; diff --git a/excalidraw-app/share/ShareDialog.tsx b/excalidraw-app/share/ShareDialog.tsx index 85e500dae..68096417b 100644 --- a/excalidraw-app/share/ShareDialog.tsx +++ b/excalidraw-app/share/ShareDialog.tsx @@ -70,7 +70,7 @@ const ActiveRoomDialog = ({ try { await copyTextToSystemClipboard(activeRoomLink); } catch (e) { - collabAPI.setErrorMessage(t("errors.copyToSystemClipboardFailed")); + collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed")); } setJustCopied(true); diff --git a/packages/excalidraw/components/Toast.tsx b/packages/excalidraw/components/Toast.tsx index be0c46663..2f0852a5d 100644 --- a/packages/excalidraw/components/Toast.tsx +++ b/packages/excalidraw/components/Toast.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from "react"; +import { CSSProperties, useCallback, useEffect, useRef } from "react"; import { CloseIcon } from "./icons"; import "./Toast.scss"; import { ToolButton } from "./ToolButton"; @@ -11,11 +11,13 @@ export const Toast = ({ closable = false, // To prevent autoclose, pass duration as Infinity duration = DEFAULT_TOAST_TIMEOUT, + style, }: { message: string; onClose: () => void; closable?: boolean; duration?: number; + style?: CSSProperties; }) => { const timerRef = useRef(0); const shouldAutoClose = duration !== Infinity; @@ -43,6 +45,7 @@ export const Toast = ({ className="Toast" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} + style={style} >

{message}

{closable && ( diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index fcf8df4a6..967ae1976 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -604,6 +604,10 @@ export const share = createIcon( modifiedTablerIconProps, ); +export const warning = createIcon( + "M256 32c14.2 0 27.3 7.5 34.5 19.8l216 368c7.3 12.4 7.3 27.7 .2 40.1S486.3 480 472 480H40c-14.3 0-27.6-7.7-34.7-20.1s-7-27.8 .2-40.1l216-368C228.7 39.5 241.8 32 256 32zm0 128c-13.3 0-24 10.7-24 24V296c0 13.3 10.7 24 24 24s24-10.7 24-24V184c0-13.3-10.7-24-24-24zm32 224a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z", +); + export const shareIOS = createIcon( "M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z", { width: 24, height: 24 }, From 7e471b55ebd45663c7708f3c2e29eebe43fe6d97 Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Tue, 5 Mar 2024 19:33:27 +0000 Subject: [PATCH 16/66] feat: text measurements based on font metrics (#7693) * Introduced vertical offset based on harcoded font metrics * Unified usage of alphabetic baseline for both canvas & svg export * Removed baseline property * Removed font-size rounding on Safari * Removed artificial width offset --- packages/excalidraw/CHANGELOG.md | 2 + .../excalidraw/actions/actionBoundText.tsx | 3 +- .../data/__snapshots__/transform.test.ts.snap | 19 --- packages/excalidraw/data/restore.ts | 9 +- packages/excalidraw/element/newElement.ts | 13 +- packages/excalidraw/element/resizeElements.ts | 35 +----- packages/excalidraw/element/textElement.ts | 112 ++++++++---------- packages/excalidraw/element/textWysiwyg.tsx | 17 +-- packages/excalidraw/element/types.ts | 1 - packages/excalidraw/renderer/renderElement.ts | 12 +- .../excalidraw/renderer/staticSvgScene.ts | 10 +- .../linearElementEditor.test.tsx.snap | 2 +- .../data/__snapshots__/restore.test.ts.snap | 2 - 13 files changed, 83 insertions(+), 154 deletions(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 3759b44c4..d88bae8ab 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -27,6 +27,8 @@ Please add the latest change on the top under the correct section. - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) +- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. + - Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed #### Bundler diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index e0ea95cd4..daefa5691 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -49,7 +49,7 @@ export const actionUnbindText = register({ selectedElements.forEach((element) => { const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { - const { width, height, baseline } = measureText( + const { width, height } = measureText( boundTextElement.originalText, getFontString(boundTextElement), boundTextElement.lineHeight, @@ -67,7 +67,6 @@ export const actionUnbindText = register({ containerId: null, width, height, - baseline, text: boundTextElement.originalText, x, y, diff --git a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap index 450fce7de..2ae7eced8 100644 --- a/packages/excalidraw/data/__snapshots__/transform.test.ts.snap +++ b/packages/excalidraw/data/__snapshots__/transform.test.ts.snap @@ -224,7 +224,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id48", @@ -269,7 +268,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id48", @@ -373,7 +371,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id48", "customData": undefined, @@ -472,7 +469,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id37", "customData": undefined, @@ -643,7 +639,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id41", "customData": undefined, @@ -683,7 +678,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id41", @@ -728,7 +722,6 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [ { "id": "id41", @@ -1174,7 +1167,6 @@ exports[`Test Transform > should transform text element 1`] = ` { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": null, "customData": undefined, @@ -1214,7 +1206,6 @@ exports[`Test Transform > should transform text element 2`] = ` { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": null, "customData": undefined, @@ -1458,7 +1449,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id25", "customData": undefined, @@ -1498,7 +1488,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id26", "customData": undefined, @@ -1538,7 +1527,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id27", "customData": undefined, @@ -1579,7 +1567,6 @@ exports[`Test Transform > should transform to labelled arrows when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id28", "customData": undefined, @@ -1836,7 +1823,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id13", "customData": undefined, @@ -1876,7 +1862,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id14", "customData": undefined, @@ -1917,7 +1902,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id15", "customData": undefined, @@ -1960,7 +1944,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id16", "customData": undefined, @@ -2001,7 +1984,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id17", "customData": undefined, @@ -2043,7 +2025,6 @@ exports[`Test Transform > should transform to text containers when label provide { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": null, "containerId": "id18", "customData": undefined, diff --git a/packages/excalidraw/data/restore.ts b/packages/excalidraw/data/restore.ts index 022457f01..0ff0203dc 100644 --- a/packages/excalidraw/data/restore.ts +++ b/packages/excalidraw/data/restore.ts @@ -35,14 +35,13 @@ import { import { getDefaultAppState } from "../appState"; import { LinearElementEditor } from "../element/linearElementEditor"; import { bumpVersion } from "../element/mutateElement"; -import { getFontString, getUpdatedTimestamp, updateActiveTool } from "../utils"; +import { getUpdatedTimestamp, updateActiveTool } from "../utils"; import { arrayToMap } from "../utils"; import { MarkOptional, Mutable } from "../utility-types"; import { detectLineHeight, getContainerElement, getDefaultLineHeight, - measureBaseline, } from "../element/textElement"; import { normalizeLink } from "./url"; @@ -207,11 +206,6 @@ const restoreElement = ( : // no element height likely means programmatic use, so default // to a fixed line height getDefaultLineHeight(element.fontFamily)); - const baseline = measureBaseline( - element.text, - getFontString(element), - lineHeight, - ); element = restoreElementWithProperties(element, { fontSize, fontFamily, @@ -222,7 +216,6 @@ const restoreElement = ( originalText: element.originalText || text, lineHeight, - baseline, }); // if empty text, mark as deleted. We keep in array diff --git a/packages/excalidraw/element/newElement.ts b/packages/excalidraw/element/newElement.ts index 076f64722..967359c5a 100644 --- a/packages/excalidraw/element/newElement.ts +++ b/packages/excalidraw/element/newElement.ts @@ -249,7 +249,6 @@ export const newTextElement = ( y: opts.y - offsets.y, width: metrics.width, height: metrics.height, - baseline: metrics.baseline, containerId: opts.containerId || null, originalText: text, lineHeight, @@ -268,13 +267,12 @@ const getAdjustedDimensions = ( y: number; width: number; height: number; - baseline: number; } => { - const { - width: nextWidth, - height: nextHeight, - baseline: nextBaseline, - } = measureText(nextText, getFontString(element), element.lineHeight); + const { width: nextWidth, height: nextHeight } = measureText( + nextText, + getFontString(element), + element.lineHeight, + ); const { textAlign, verticalAlign } = element; let x: number; let y: number; @@ -328,7 +326,6 @@ const getAdjustedDimensions = ( return { width: nextWidth, height: nextHeight, - baseline: nextBaseline, x: Number.isFinite(x) ? x : element.x, y: Number.isFinite(y) ? y : element.y, }; diff --git a/packages/excalidraw/element/resizeElements.ts b/packages/excalidraw/element/resizeElements.ts index 49724d9eb..e18a4ed25 100644 --- a/packages/excalidraw/element/resizeElements.ts +++ b/packages/excalidraw/element/resizeElements.ts @@ -52,8 +52,6 @@ import { handleBindTextResize, getBoundTextMaxWidth, getApproxMinLineHeight, - measureText, - getBoundTextMaxHeight, } from "./textElement"; import { LinearElementEditor } from "./linearElementEditor"; @@ -213,8 +211,7 @@ const measureFontSizeFromWidth = ( element: NonDeleted, elementsMap: ElementsMap, nextWidth: number, - nextHeight: number, -): { size: number; baseline: number } | null => { +): { size: number } | null => { // We only use width to scale font on resize let width = element.width; @@ -229,14 +226,9 @@ const measureFontSizeFromWidth = ( if (nextFontSize < MIN_FONT_SIZE) { return null; } - const metrics = measureText( - element.text, - getFontString({ fontSize: nextFontSize, fontFamily: element.fontFamily }), - element.lineHeight, - ); + return { size: nextFontSize, - baseline: metrics.baseline + (nextHeight - metrics.height), }; }; @@ -309,12 +301,7 @@ const resizeSingleTextElement = ( if (scale > 0) { const nextWidth = element.width * scale; const nextHeight = element.height * scale; - const metrics = measureFontSizeFromWidth( - element, - elementsMap, - nextWidth, - nextHeight, - ); + const metrics = measureFontSizeFromWidth(element, elementsMap, nextWidth); if (metrics === null) { return; } @@ -342,7 +329,6 @@ const resizeSingleTextElement = ( fontSize: metrics.size, width: nextWidth, height: nextHeight, - baseline: metrics.baseline, x: nextElementX, y: nextElementY, }); @@ -396,7 +382,7 @@ export const resizeSingleElement = ( let scaleX = atStartBoundsWidth / boundsCurrentWidth; let scaleY = atStartBoundsHeight / boundsCurrentHeight; - let boundTextFont: { fontSize?: number; baseline?: number } = {}; + let boundTextFont: { fontSize?: number } = {}; const boundTextElement = getBoundTextElement(element, elementsMap); if (transformHandleDirection.includes("e")) { @@ -448,7 +434,6 @@ export const resizeSingleElement = ( if (stateOfBoundTextElementAtResize) { boundTextFont = { fontSize: stateOfBoundTextElementAtResize.fontSize, - baseline: stateOfBoundTextElementAtResize.baseline, }; } if (shouldMaintainAspectRatio) { @@ -462,14 +447,12 @@ export const resizeSingleElement = ( boundTextElement, elementsMap, getBoundTextMaxWidth(updatedElement, boundTextElement), - getBoundTextMaxHeight(updatedElement, boundTextElement), ); if (nextFont === null) { return; } boundTextFont = { fontSize: nextFont.size, - baseline: nextFont.baseline, }; } else { const minWidth = getApproxMinLineWidth( @@ -638,7 +621,6 @@ export const resizeSingleElement = ( if (boundTextElement && boundTextFont != null) { mutateElement(boundTextElement, { fontSize: boundTextFont.fontSize, - baseline: boundTextFont.baseline, }); } handleBindTextResize( @@ -769,7 +751,6 @@ export const resizeMultipleElements = ( > & { points?: ExcalidrawLinearElement["points"]; fontSize?: ExcalidrawTextElement["fontSize"]; - baseline?: ExcalidrawTextElement["baseline"]; scale?: ExcalidrawImageElement["scale"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"]; }; @@ -844,17 +825,11 @@ export const resizeMultipleElements = ( } if (isTextElement(orig)) { - const metrics = measureFontSizeFromWidth( - orig, - elementsMap, - width, - height, - ); + const metrics = measureFontSizeFromWidth(orig, elementsMap, width); if (!metrics) { return; } update.fontSize = metrics.size; - update.baseline = metrics.baseline; } const boundTextElement = originalElements.get( diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 4aa0868d7..102ed681c 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -18,7 +18,6 @@ import { DEFAULT_FONT_FAMILY, DEFAULT_FONT_SIZE, FONT_FAMILY, - isSafari, TEXT_ALIGN, VERTICAL_ALIGN, } from "../constants"; @@ -62,7 +61,6 @@ export const redrawTextBoundingBox = ( text: textElement.text, width: textElement.width, height: textElement.height, - baseline: textElement.baseline, }; boundTextUpdates.text = textElement.text; @@ -83,7 +81,6 @@ export const redrawTextBoundingBox = ( boundTextUpdates.width = metrics.width; boundTextUpdates.height = metrics.height; - boundTextUpdates.baseline = metrics.baseline; if (container) { const maxContainerHeight = getBoundTextMaxHeight( @@ -188,7 +185,6 @@ export const handleBindTextResize = ( const maxWidth = getBoundTextMaxWidth(container, textElement); const maxHeight = getBoundTextMaxHeight(container, textElement); let containerHeight = container.height; - let nextBaseLine = textElement.baseline; if ( shouldMaintainAspectRatio || (transformHandleType !== "n" && transformHandleType !== "s") @@ -207,7 +203,6 @@ export const handleBindTextResize = ( ); nextHeight = metrics.height; nextWidth = metrics.width; - nextBaseLine = metrics.baseline; } // increase height in case text element height exceeds if (nextHeight > maxHeight) { @@ -235,7 +230,6 @@ export const handleBindTextResize = ( text, width: nextWidth, height: nextHeight, - baseline: nextBaseLine, }); if (!isArrowElement(container)) { @@ -285,8 +279,6 @@ export const computeBoundTextPosition = ( return { x, y }; }; -// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js - export const measureText = ( text: string, font: FontString, @@ -301,59 +293,7 @@ export const measureText = ( const fontSize = parseFloat(font); const height = getTextHeight(text, fontSize, lineHeight); const width = getTextWidth(text, font); - const baseline = measureBaseline(text, font, lineHeight); - return { width, height, baseline }; -}; - -export const measureBaseline = ( - text: string, - font: FontString, - lineHeight: ExcalidrawTextElement["lineHeight"], - wrapInContainer?: boolean, -) => { - const container = document.createElement("div"); - container.style.position = "absolute"; - container.style.whiteSpace = "pre"; - container.style.font = font; - container.style.minHeight = "1em"; - if (wrapInContainer) { - container.style.overflow = "hidden"; - container.style.wordBreak = "break-word"; - container.style.whiteSpace = "pre-wrap"; - } - - container.style.lineHeight = String(lineHeight); - - container.innerText = text; - - // Baseline is important for positioning text on canvas - document.body.appendChild(container); - - const span = document.createElement("span"); - span.style.display = "inline-block"; - span.style.overflow = "hidden"; - span.style.width = "1px"; - span.style.height = "1px"; - container.appendChild(span); - let baseline = span.offsetTop + span.offsetHeight; - const height = container.offsetHeight; - - if (isSafari) { - const canvasHeight = getTextHeight(text, parseFloat(font), lineHeight); - const fontSize = parseFloat(font); - // In Safari the font size gets rounded off when rendering hence calculating the safari height and shifting the baseline if it differs - // from the actual canvas height - const domHeight = getTextHeight(text, Math.round(fontSize), lineHeight); - if (canvasHeight > height) { - baseline += canvasHeight - domHeight; - } - - if (height > canvasHeight) { - baseline -= domHeight - canvasHeight; - } - } - document.body.removeChild(container); - return baseline; + return { width, height }; }; /** @@ -378,6 +318,23 @@ export const getLineHeightInPx = ( return fontSize * lineHeight; }; +/** + * Calculates vertical offset for a text with alphabetic baseline. + */ +export const getVerticalOffset = ( + fontFamily: ExcalidrawTextElement["fontFamily"], + fontSize: ExcalidrawTextElement["fontSize"], + lineHeightPx: number, +) => { + const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily]; + + const fontSizeEm = fontSize / unitsPerEm; + const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender; + + const verticalOffset = fontSizeEm * ascender + lineGap; + return verticalOffset; +}; + // FIXME rename to getApproxMinContainerHeight export const getApproxMinLineHeight = ( fontSize: ExcalidrawTextElement["fontSize"], @@ -964,13 +921,40 @@ const DEFAULT_LINE_HEIGHT = { // ~1.25 is the average for Virgil in WebKit and Blink. // Gecko (FF) uses ~1.28. [FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"], - // ~1.15 is the average for Virgil in WebKit and Blink. - // Gecko if all over the place. + // ~1.15 is the average for Helvetica in WebKit and Blink. [FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"], - // ~1.2 is the average for Virgil in WebKit and Blink, and kinda Gecko too + // ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], }; +type FontMetrics = { + unitsPerEm: number; // head.unitsPerEm + ascender: number; // sTypoAscender + descender: number; // sTypoDescender +}; + +/** + * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html. + * For custom fonts, read these metrics on load and extend this object. + */ +const FONT_METRICS = { + [FONT_FAMILY.Virgil]: { + unitsPerEm: 1000, + ascender: 886, + descender: -374, + }, + [FONT_FAMILY.Helvetica]: { + unitsPerEm: 2048, + ascender: 1577, + descender: -471, + }, + [FONT_FAMILY.Cascadia]: { + unitsPerEm: 2048, + ascender: 1977, + descender: -480, + }, +} as Record; + export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { if (fontFamily in DEFAULT_LINE_HEIGHT) { return DEFAULT_LINE_HEIGHT[fontFamily]; diff --git a/packages/excalidraw/element/textWysiwyg.tsx b/packages/excalidraw/element/textWysiwyg.tsx index ae30be4e9..7dfdbc615 100644 --- a/packages/excalidraw/element/textWysiwyg.tsx +++ b/packages/excalidraw/element/textWysiwyg.tsx @@ -11,7 +11,7 @@ import { isBoundToContainer, isTextElement, } from "./typeChecks"; -import { CLASSES, isSafari } from "../constants"; +import { CLASSES } from "../constants"; import { ExcalidrawElement, ExcalidrawLinearElement, @@ -31,7 +31,6 @@ import { getBoundTextMaxHeight, getBoundTextMaxWidth, computeContainerDimensionForBoundText, - detectLineHeight, computeBoundTextPosition, getBoundTextElement, } from "./textElement"; @@ -227,18 +226,6 @@ export const textWysiwyg = ({ if (!container) { maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value; textElementWidth = Math.min(textElementWidth, maxWidth); - } else { - textElementWidth += 0.5; - } - - let lineHeight = updatedTextElement.lineHeight; - - // In Safari the font size gets rounded off when rendering hence calculating the line height by rounding off font size - if (isSafari) { - lineHeight = detectLineHeight({ - ...updatedTextElement, - fontSize: Math.round(updatedTextElement.fontSize), - }); } // Make sure text editor height doesn't go beyond viewport @@ -247,7 +234,7 @@ export const textWysiwyg = ({ Object.assign(editable.style, { font: getFontString(updatedTextElement), // must be defined *after* font ¯\_(ツ)_/¯ - lineHeight, + lineHeight: updatedTextElement.lineHeight, width: `${textElementWidth}px`, height: `${textElementHeight}px`, left: `${viewportX}px`, diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index aae0a8a30..85f42adcf 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -176,7 +176,6 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase & fontSize: number; fontFamily: FontFamilyValues; text: string; - baseline: number; textAlign: TextAlign; verticalAlign: VerticalAlign; containerId: ExcalidrawGenericElement["id"] | null; diff --git a/packages/excalidraw/renderer/renderElement.ts b/packages/excalidraw/renderer/renderElement.ts index a40e3d398..df3e20efe 100644 --- a/packages/excalidraw/renderer/renderElement.ts +++ b/packages/excalidraw/renderer/renderElement.ts @@ -50,6 +50,7 @@ import { getLineHeightInPx, getBoundTextMaxHeight, getBoundTextMaxWidth, + getVerticalOffset, } from "../element/textElement"; import { LinearElementEditor } from "../element/linearElementEditor"; @@ -383,16 +384,23 @@ const drawElementOnCanvas = ( : element.textAlign === "right" ? element.width : 0; + const lineHeightPx = getLineHeightInPx( element.fontSize, element.lineHeight, ); - const verticalOffset = element.height - element.baseline; + + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); + for (let index = 0; index < lines.length; index++) { context.fillText( lines[index], horizontalOffset, - (index + 1) * lineHeightPx - verticalOffset, + index * lineHeightPx + verticalOffset, ); } context.restore(); diff --git a/packages/excalidraw/renderer/staticSvgScene.ts b/packages/excalidraw/renderer/staticSvgScene.ts index de026300e..7b3917d4e 100644 --- a/packages/excalidraw/renderer/staticSvgScene.ts +++ b/packages/excalidraw/renderer/staticSvgScene.ts @@ -17,6 +17,7 @@ import { getBoundTextElement, getContainerElement, getLineHeightInPx, + getVerticalOffset, } from "../element/textElement"; import { isArrowElement, @@ -556,6 +557,11 @@ const renderElementToSvg = ( : element.textAlign === "right" ? element.width : 0; + const verticalOffset = getVerticalOffset( + element.fontFamily, + element.fontSize, + lineHeightPx, + ); const direction = isRTL(element.text) ? "rtl" : "ltr"; const textAnchor = element.textAlign === "center" @@ -567,14 +573,14 @@ const renderElementToSvg = ( const text = svgRoot.ownerDocument!.createElementNS(SVG_NS, "text"); text.textContent = lines[i]; text.setAttribute("x", `${horizontalOffset}`); - text.setAttribute("y", `${i * lineHeightPx}`); + text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`); text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("fill", element.strokeColor); text.setAttribute("text-anchor", textAnchor); text.setAttribute("style", "white-space: pre;"); text.setAttribute("direction", direction); - text.setAttribute("dominant-baseline", "text-before-edge"); + text.setAttribute("dominant-baseline", "alphabetic"); node.appendChild(text); } diff --git a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap index 41e9f2b12..1fd7106bd 100644 --- a/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/linearElementEditor.test.tsx.snap @@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo class="excalidraw-wysiwyg" data-type="wysiwyg" dir="auto" - style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" + style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;" tabindex="0" wrap="off" /> diff --git a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap index 457ed4f14..156e839a3 100644 --- a/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap +++ b/packages/excalidraw/tests/data/__snapshots__/restore.test.ts.snap @@ -296,7 +296,6 @@ exports[`restoreElements > should restore text element correctly passing value f { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [], "containerId": null, "customData": undefined, @@ -338,7 +337,6 @@ exports[`restoreElements > should restore text element correctly with unknown fo { "angle": 0, "backgroundColor": "transparent", - "baseline": 0, "boundElements": [], "containerId": null, "customData": undefined, From a07f6e9e3a6c4c38b7955825bef28d9f90580bf6 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:22:25 +0100 Subject: [PATCH 17/66] feat: show ai badge for discovery (#7749) --- packages/excalidraw/components/Actions.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/excalidraw/components/Actions.tsx b/packages/excalidraw/components/Actions.tsx index c11d64d04..acff6aaa3 100644 --- a/packages/excalidraw/components/Actions.tsx +++ b/packages/excalidraw/components/Actions.tsx @@ -306,6 +306,25 @@ export const ShapesSwitcher = ({ title={t("toolBar.extraTools")} > {extraToolsIcon} + {app.props.aiEnabled !== false && ( +
+ AI +
+ )} setIsExtraToolsMenuOpen(false)} From a38e82f99902131982eae15bcaa16e406d3cbafa Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Tue, 5 Mar 2024 23:22:34 +0100 Subject: [PATCH 18/66] feat: close dropdown on escape (#7750) --- .../dropdownMenu/DropdownMenu.test.tsx | 20 ++++++++++++++ .../dropdownMenu/DropdownMenuContent.tsx | 27 +++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx new file mode 100644 index 000000000..3aae1d0c7 --- /dev/null +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenu.test.tsx @@ -0,0 +1,20 @@ +import { Excalidraw } from "../../index"; +import { KEYS } from "../../keys"; +import { Keyboard } from "../../tests/helpers/ui"; +import { render, waitFor, getByTestId } from "../../tests/test-utils"; + +describe("Test ", () => { + it("should", async () => { + const { container } = await render(); + + expect(window.h.state.openMenu).toBe(null); + + getByTestId(container, "main-menu-trigger").click(); + expect(window.h.state.openMenu).toBe("canvas"); + + await waitFor(() => { + Keyboard.keyDown(KEYS.ESCAPE); + expect(window.h.state.openMenu).toBe(null); + }); + }); +}); diff --git a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx index e266cecf4..374e5c05d 100644 --- a/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx +++ b/packages/excalidraw/components/dropdownMenu/DropdownMenuContent.tsx @@ -2,9 +2,12 @@ import { Island } from "../Island"; import { useDevice } from "../App"; import clsx from "clsx"; import Stack from "../Stack"; -import React, { useRef } from "react"; +import React, { useEffect, useRef } from "react"; import { DropdownMenuContentPropsContext } from "./common"; import { useOutsideClick } from "../../hooks/useOutsideClick"; +import { KEYS } from "../../keys"; +import { EVENT } from "../../constants"; +import { useStable } from "../../hooks/useStable"; const MenuContent = ({ children, @@ -25,10 +28,30 @@ const MenuContent = ({ const device = useDevice(); const menuRef = useRef(null); + const callbacksRef = useStable({ onClickOutside }); + useOutsideClick(menuRef, () => { - onClickOutside?.(); + callbacksRef.onClickOutside?.(); }); + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === KEYS.ESCAPE) { + event.stopImmediatePropagation(); + callbacksRef.onClickOutside?.(); + } + }; + + document.addEventListener(EVENT.KEYDOWN, onKeyDown, { + // so that we can stop propagation of the event before it reaches + // event handlers that were bound before this one + capture: true, + }); + return () => { + document.removeEventListener(EVENT.KEYDOWN, onKeyDown); + }; + }, [callbacksRef]); + const classNames = clsx(`dropdown-menu ${className}`, { "dropdown-menu--mobile": device.editor.isMobile, }).trim(); From 68b1fdb20ebe09fdba8764fbd91b4b8e26d7d011 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 6 Mar 2024 10:53:37 +0100 Subject: [PATCH 19/66] fix: add missing font metrics for Assistant (#7752) --- packages/excalidraw/element/textElement.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 102ed681c..630afd392 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -326,7 +326,8 @@ export const getVerticalOffset = ( fontSize: ExcalidrawTextElement["fontSize"], lineHeightPx: number, ) => { - const { unitsPerEm, ascender, descender } = FONT_METRICS[fontFamily]; + const { unitsPerEm, ascender, descender } = + FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica]; const fontSizeEm = fontSize / unitsPerEm; const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender; @@ -953,6 +954,11 @@ const FONT_METRICS = { ascender: 1977, descender: -480, }, + [FONT_FAMILY.Assistant]: { + unitsPerEm: 1000, + ascender: 1050, + descender: -500, + }, } as Record; export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { From 480572f89350fa6b596ebbfb2e2fbdd00c07a9cf Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Thu, 7 Mar 2024 15:54:36 +0000 Subject: [PATCH 20/66] fix: correcting Assistant metrics (#7758) * Changed Assistant metrics to the corrrect ones from OS/2 table * Adding more information about font metrics * Adding branded types to avoid future mistakes --- packages/excalidraw/CHANGELOG.md | 2 +- packages/excalidraw/element/textElement.ts | 54 +++++++++++++--------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index d88bae8ab..4984a9936 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -27,7 +27,7 @@ Please add the latest change on the top under the correct section. - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) -- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. +- `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. - Create an `ESM` build for `@excalidraw/excalidraw`. The API is in progress and subject to change before stable release. There are some changes on how the package will be consumed diff --git a/packages/excalidraw/element/textElement.ts b/packages/excalidraw/element/textElement.ts index 630afd392..b54cc1c8e 100644 --- a/packages/excalidraw/element/textElement.ts +++ b/packages/excalidraw/element/textElement.ts @@ -31,7 +31,7 @@ import { getElementAbsoluteCoords } from "."; import { getSelectedElements } from "../scene"; import { isHittingElementNotConsideringBoundingBox } from "./collision"; -import { ExtractSetType } from "../utility-types"; +import { ExtractSetType, MakeBrand } from "../utility-types"; import { resetOriginalContainerCache, updateOriginalContainerCache, @@ -928,38 +928,50 @@ const DEFAULT_LINE_HEIGHT = { [FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"], }; -type FontMetrics = { - unitsPerEm: number; // head.unitsPerEm - ascender: number; // sTypoAscender - descender: number; // sTypoDescender -}; +/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */ +type sTypoAscender = number & MakeBrand<"sTypoAscender">; + +/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */ +type sTypoDescender = number & MakeBrand<"sTypoDescender">; + +/** head.unitsPerEm, usually either 1000 or 2048 */ +type unitsPerEm = number & MakeBrand<"unitsPerEm">; /** * Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html. - * For custom fonts, read these metrics on load and extend this object. + * For custom fonts, read these metrics from OS/2 table and extend this object. + * + * WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first. */ -const FONT_METRICS = { +export const FONT_METRICS: Record< + number, + { + unitsPerEm: number; + ascender: sTypoAscender; + descender: sTypoDescender; + } +> = { [FONT_FAMILY.Virgil]: { - unitsPerEm: 1000, - ascender: 886, - descender: -374, + unitsPerEm: 1000 as unitsPerEm, + ascender: 886 as sTypoAscender, + descender: -374 as sTypoDescender, }, [FONT_FAMILY.Helvetica]: { - unitsPerEm: 2048, - ascender: 1577, - descender: -471, + unitsPerEm: 2048 as unitsPerEm, + ascender: 1577 as sTypoAscender, + descender: -471 as sTypoDescender, }, [FONT_FAMILY.Cascadia]: { - unitsPerEm: 2048, - ascender: 1977, - descender: -480, + unitsPerEm: 2048 as unitsPerEm, + ascender: 1977 as sTypoAscender, + descender: -480 as sTypoDescender, }, [FONT_FAMILY.Assistant]: { - unitsPerEm: 1000, - ascender: 1050, - descender: -500, + unitsPerEm: 1000 as unitsPerEm, + ascender: 1021 as sTypoAscender, + descender: -287 as sTypoDescender, }, -} as Record; +}; export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => { if (fontFamily in DEFAULT_LINE_HEIGHT) { From 2382fad4f6a3a5b85b304b41ae88d0d235c55901 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 8 Mar 2024 22:29:19 +0100 Subject: [PATCH 21/66] feat: store library to IndexedDB & support storage adapters (#7655) --- excalidraw-app/App.tsx | 30 +- excalidraw-app/app_constants.ts | 6 +- excalidraw-app/data/LocalData.ts | 62 +++- excalidraw-app/data/localStorage.ts | 18 +- packages/excalidraw/CHANGELOG.md | 4 + packages/excalidraw/data/library.ts | 511 ++++++++++++++++++++++++--- packages/excalidraw/element/index.ts | 27 ++ packages/excalidraw/index.tsx | 4 +- packages/excalidraw/locales/en.json | 1 + packages/excalidraw/queue.test.ts | 62 ++++ packages/excalidraw/queue.ts | 45 +++ packages/excalidraw/types.ts | 24 +- packages/excalidraw/utility-types.ts | 3 + packages/excalidraw/utils.ts | 16 +- 14 files changed, 718 insertions(+), 95 deletions(-) create mode 100644 packages/excalidraw/queue.test.ts create mode 100644 packages/excalidraw/queue.ts diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 7517bb379..1ab776aea 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -30,7 +30,6 @@ import { } from "../packages/excalidraw/index"; import { AppState, - LibraryItems, ExcalidrawImperativeAPI, BinaryFiles, ExcalidrawInitialDataState, @@ -64,7 +63,6 @@ import { loadScene, } from "./data"; import { - getLibraryItemsFromStorage, importFromLocalStorage, importUsernameFromLocalStorage, } from "./data/localStorage"; @@ -82,7 +80,11 @@ import { updateStaleImageStatuses } from "./data/FileManager"; import { newElementWith } from "../packages/excalidraw/element/mutateElement"; import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks"; import { loadFilesFromFirebase } from "./data/firebase"; -import { LocalData } from "./data/LocalData"; +import { + LibraryIndexedDBAdapter, + LibraryLocalStorageMigrationAdapter, + LocalData, +} from "./data/LocalData"; import { isBrowserStorageStateNewer } from "./data/tabSync"; import clsx from "clsx"; import { reconcileElements } from "./collab/reconciliation"; @@ -315,7 +317,9 @@ const ExcalidrawWrapper = () => { useHandleLibrary({ excalidrawAPI, - getInitialLibraryItems: getLibraryItemsFromStorage, + adapter: LibraryIndexedDBAdapter, + // TODO maybe remove this in several months (shipped: 24-02-07) + migrationAdapter: LibraryLocalStorageMigrationAdapter, }); useEffect(() => { @@ -445,8 +449,12 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...localDataState, }); - excalidrawAPI.updateLibrary({ - libraryItems: getLibraryItemsFromStorage(), + LibraryIndexedDBAdapter.load().then((data) => { + if (data) { + excalidrawAPI.updateLibrary({ + libraryItems: data.libraryItems, + }); + } }); collabAPI?.setUsername(username || ""); } @@ -658,15 +666,6 @@ const ExcalidrawWrapper = () => { ); }; - const onLibraryChange = async (items: LibraryItems) => { - if (!items.length) { - localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); - return; - } - const serializedItems = JSON.stringify(items); - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems); - }; - const isOffline = useAtomValue(isOfflineAtom); const onCollabDialogOpen = useCallback( @@ -742,7 +741,6 @@ const ExcalidrawWrapper = () => { renderCustomStats={renderCustomStats} detectScroll={false} handleKeyboardGlobally={true} - onLibraryChange={onLibraryChange} autoFocus={true} theme={theme} renderTopRightUI={(isMobile) => { diff --git a/excalidraw-app/app_constants.ts b/excalidraw-app/app_constants.ts index 3402bf106..f4b56496d 100644 --- a/excalidraw-app/app_constants.ts +++ b/excalidraw-app/app_constants.ts @@ -39,10 +39,14 @@ export const STORAGE_KEYS = { LOCAL_STORAGE_ELEMENTS: "excalidraw", LOCAL_STORAGE_APP_STATE: "excalidraw-state", LOCAL_STORAGE_COLLAB: "excalidraw-collab", - LOCAL_STORAGE_LIBRARY: "excalidraw-library", LOCAL_STORAGE_THEME: "excalidraw-theme", VERSION_DATA_STATE: "version-dataState", VERSION_FILES: "version-files", + + IDB_LIBRARY: "excalidraw-library", + + // do not use apart from migrations + __LEGACY_LOCAL_STORAGE_LIBRARY: "excalidraw-library", } as const; export const COOKIES = { diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index a8a6c41b2..9d19e073b 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -10,8 +10,18 @@ * (localStorage, indexedDB). */ -import { createStore, entries, del, getMany, set, setMany } from "idb-keyval"; +import { + createStore, + entries, + del, + getMany, + set, + setMany, + get, +} from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; +import { LibraryPersistedData } from "../../packages/excalidraw/data/library"; +import { ImportedDataState } from "../../packages/excalidraw/data/types"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { ExcalidrawElement, @@ -22,6 +32,7 @@ import { BinaryFileData, BinaryFiles, } from "../../packages/excalidraw/types"; +import { MaybePromise } from "../../packages/excalidraw/utility-types"; import { debounce } from "../../packages/excalidraw/utils"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { FileManager } from "./FileManager"; @@ -183,3 +194,52 @@ export class LocalData { }, }); } +export class LibraryIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = STORAGE_KEYS.IDB_LIBRARY; + /** library data store key */ + private static key = "libraryData"; + + private static store = createStore( + `${LibraryIndexedDBAdapter.idb_name}-db`, + `${LibraryIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get( + LibraryIndexedDBAdapter.key, + LibraryIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: LibraryPersistedData): MaybePromise { + return set( + LibraryIndexedDBAdapter.key, + data, + LibraryIndexedDBAdapter.store, + ); + } +} + +/** LS Adapter used only for migrating LS library data + * to indexedDB */ +export class LibraryLocalStorageMigrationAdapter { + static load() { + const LSData = localStorage.getItem( + STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY, + ); + if (LSData != null) { + const libraryItems: ImportedDataState["libraryItems"] = + JSON.parse(LSData); + if (libraryItems) { + return { libraryItems }; + } + } + return null; + } + static clear() { + localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); + } +} diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index ce4258f4e..0a6a16081 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -6,7 +6,6 @@ import { } from "../../packages/excalidraw/appState"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import { STORAGE_KEYS } from "../app_constants"; -import { ImportedDataState } from "../../packages/excalidraw/data/types"; export const saveUsernameToLocalStorage = (username: string) => { try { @@ -88,28 +87,13 @@ export const getTotalStorageSize = () => { try { const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); - const library = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY); const appStateSize = appState?.length || 0; const collabSize = collab?.length || 0; - const librarySize = library?.length || 0; - return appStateSize + collabSize + librarySize + getElementsStorageSize(); + return appStateSize + collabSize + getElementsStorageSize(); } catch (error: any) { console.error(error); return 0; } }; - -export const getLibraryItemsFromStorage = () => { - try { - const libraryItems: ImportedDataState["libraryItems"] = JSON.parse( - localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY) as string, - ); - - return libraryItems || []; - } catch (error) { - console.error(error); - return []; - } -}; diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index 4984a9936..00d6ad527 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,6 +15,10 @@ Please add the latest change on the top under the correct section. ### Features +- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) +- Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655) +- Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). - Expose `getVisibleSceneBounds` helper to get scene bounds of visible canvas area. [#7450](https://github.com/excalidraw/excalidraw/pull/7450) diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 7b936efc1..525eecb57 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -4,6 +4,7 @@ import { LibraryItem, ExcalidrawImperativeAPI, LibraryItemsSource, + LibraryItems_anyVersion, } from "../types"; import { restoreLibraryItems } from "./restore"; import type App from "../components/App"; @@ -23,13 +24,72 @@ import { LIBRARY_SIDEBAR_TAB, } from "../constants"; import { libraryItemSvgsCache } from "../hooks/useLibraryItemSvg"; -import { cloneJSON } from "../utils"; +import { + arrayToMap, + cloneJSON, + preventUnload, + promiseTry, + resolvablePromise, +} from "../utils"; +import { MaybePromise } from "../utility-types"; +import { Emitter } from "../emitter"; +import { Queue } from "../queue"; +import { hashElementsVersion, hashString } from "../element"; + +type LibraryUpdate = { + /** deleted library items since last onLibraryChange event */ + deletedItems: Map; + /** newly added items in the library */ + addedItems: Map; +}; + +// an object so that we can later add more properties to it without breaking, +// such as schema version +export type LibraryPersistedData = { libraryItems: LibraryItems }; + +const onLibraryUpdateEmitter = new Emitter< + [update: LibraryUpdate, libraryItems: LibraryItems] +>(); + +export interface LibraryPersistenceAdapter { + /** + * Should load data that were previously saved into the database using the + * `save` method. Should throw if saving fails. + * + * Will be used internally in multiple places, such as during save to + * in order to reconcile changes with latest store data. + */ + load(metadata: { + /** + * Priority 1 indicates we're loading latest data with intent + * to reconcile with before save. + * Priority 2 indicates we're loading for read-only purposes, so + * host app can implement more aggressive caching strategy. + */ + priority: 1 | 2; + }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + /** Should persist to the database as is (do no change the data structure). */ + save(libraryData: LibraryPersistedData): MaybePromise; +} + +export interface LibraryMigrationAdapter { + /** + * loads data from legacy data source. Returns `null` if no data is + * to be migrated. + */ + load(): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; + + /** clears entire storage afterwards */ + clear(): MaybePromise; +} export const libraryItemsAtom = atom<{ status: "loading" | "loaded"; + /** indicates whether library is initialized with library items (has gone + * through at least one update). Used in UI. Specific to this atom only. */ isInitialized: boolean; libraryItems: LibraryItems; -}>({ status: "loaded", isInitialized: true, libraryItems: [] }); +}>({ status: "loaded", isInitialized: false, libraryItems: [] }); const cloneLibraryItems = (libraryItems: LibraryItems): LibraryItems => cloneJSON(libraryItems); @@ -74,12 +134,45 @@ export const mergeLibraryItems = ( return [...newItems, ...localItems]; }; +/** + * Returns { deletedItems, addedItems } maps of all added and deleted items + * since last onLibraryChange event. + * + * Host apps are recommended to diff with the latest state they have. + */ +const createLibraryUpdate = ( + prevLibraryItems: LibraryItems, + nextLibraryItems: LibraryItems, +): LibraryUpdate => { + const nextItemsMap = arrayToMap(nextLibraryItems); + + const update: LibraryUpdate = { + deletedItems: new Map(), + addedItems: new Map(), + }; + + for (const item of prevLibraryItems) { + if (!nextItemsMap.has(item.id)) { + update.deletedItems.set(item.id, item); + } + } + + const prevItemsMap = arrayToMap(prevLibraryItems); + + for (const item of nextLibraryItems) { + if (!prevItemsMap.has(item.id)) { + update.addedItems.set(item.id, item); + } + } + + return update; +}; + class Library { /** latest libraryItems */ - private lastLibraryItems: LibraryItems = []; - /** indicates whether library is initialized with library items (has gone - * though at least one update) */ - private isInitialized = false; + private currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ + private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); private app: App; @@ -95,21 +188,29 @@ class Library { private notifyListeners = () => { if (this.updateQueue.length > 0) { - jotaiStore.set(libraryItemsAtom, { + jotaiStore.set(libraryItemsAtom, (s) => ({ status: "loading", - libraryItems: this.lastLibraryItems, - isInitialized: this.isInitialized, - }); + libraryItems: this.currLibraryItems, + isInitialized: s.isInitialized, + })); } else { - this.isInitialized = true; jotaiStore.set(libraryItemsAtom, { status: "loaded", - libraryItems: this.lastLibraryItems, - isInitialized: this.isInitialized, + libraryItems: this.currLibraryItems, + isInitialized: true, }); try { - this.app.props.onLibraryChange?.( - cloneLibraryItems(this.lastLibraryItems), + const prevLibraryItems = this.prevLibraryItems; + this.prevLibraryItems = cloneLibraryItems(this.currLibraryItems); + + const nextLibraryItems = cloneLibraryItems(this.currLibraryItems); + + this.app.props.onLibraryChange?.(nextLibraryItems); + + // for internal use in `useHandleLibrary` hook + onLibraryUpdateEmitter.trigger( + createLibraryUpdate(prevLibraryItems, nextLibraryItems), + nextLibraryItems, ); } catch (error) { console.error(error); @@ -119,9 +220,8 @@ class Library { /** call on excalidraw instance unmount */ destroy = () => { - this.isInitialized = false; this.updateQueue = []; - this.lastLibraryItems = []; + this.currLibraryItems = []; jotaiStore.set(libraryItemSvgsCache, new Map()); // TODO uncomment after/if we make jotai store scoped to each excal instance // jotaiStore.set(libraryItemsAtom, { @@ -142,14 +242,14 @@ class Library { return new Promise(async (resolve) => { try { const libraryItems = await (this.getLastUpdateTask() || - this.lastLibraryItems); + this.currLibraryItems); if (this.updateQueue.length > 0) { resolve(this.getLatestLibrary()); } else { resolve(cloneLibraryItems(libraryItems)); } } catch (error) { - return resolve(this.lastLibraryItems); + return resolve(this.currLibraryItems); } }); }; @@ -181,7 +281,7 @@ class Library { try { const source = await (typeof libraryItems === "function" && !(libraryItems instanceof Blob) - ? libraryItems(this.lastLibraryItems) + ? libraryItems(this.currLibraryItems) : libraryItems); let nextItems; @@ -207,7 +307,7 @@ class Library { } if (merge) { - resolve(mergeLibraryItems(this.lastLibraryItems, nextItems)); + resolve(mergeLibraryItems(this.currLibraryItems, nextItems)); } else { resolve(nextItems); } @@ -244,12 +344,12 @@ class Library { await this.getLastUpdateTask(); if (typeof libraryItems === "function") { - libraryItems = libraryItems(this.lastLibraryItems); + libraryItems = libraryItems(this.currLibraryItems); } - this.lastLibraryItems = cloneLibraryItems(await libraryItems); + this.currLibraryItems = cloneLibraryItems(await libraryItems); - resolve(this.lastLibraryItems); + resolve(this.currLibraryItems); } catch (error: any) { reject(error); } @@ -257,7 +357,7 @@ class Library { .catch((error) => { if (error.name === "AbortError") { console.warn("Library update aborted by user"); - return this.lastLibraryItems; + return this.currLibraryItems; } throw error; }) @@ -382,20 +482,165 @@ export const parseLibraryTokensFromUrl = () => { return libraryUrl ? { libraryUrl, idToken } : null; }; -export const useHandleLibrary = ({ - excalidrawAPI, - getInitialLibraryItems, -}: { - excalidrawAPI: ExcalidrawImperativeAPI | null; - getInitialLibraryItems?: () => LibraryItemsSource; -}) => { - const getInitialLibraryRef = useRef(getInitialLibraryItems); +class AdapterTransaction { + static queue = new Queue(); + + static async getLibraryItems( + adapter: LibraryPersistenceAdapter, + priority: 1 | 2, + _queue = true, + ): Promise { + const task = () => + new Promise(async (resolve, reject) => { + try { + const data = await adapter.load({ priority }); + resolve(restoreLibraryItems(data?.libraryItems || [], "published")); + } catch (error: any) { + reject(error); + } + }); + + if (_queue) { + return AdapterTransaction.queue.push(task); + } + + return task(); + } + + static run = async ( + adapter: LibraryPersistenceAdapter, + fn: (transaction: AdapterTransaction) => Promise, + ) => { + const transaction = new AdapterTransaction(adapter); + return AdapterTransaction.queue.push(() => fn(transaction)); + }; + + // ------------------ + + private adapter: LibraryPersistenceAdapter; + + constructor(adapter: LibraryPersistenceAdapter) { + this.adapter = adapter; + } + + getLibraryItems(priority: 1 | 2) { + return AdapterTransaction.getLibraryItems(this.adapter, priority, false); + } +} + +let lastSavedLibraryItemsHash = 0; +let librarySaveCounter = 0; + +export const getLibraryItemsHash = (items: LibraryItems) => { + return hashString( + items + .map((item) => { + return `${item.id}:${hashElementsVersion(item.elements)}`; + }) + .sort() + .join(), + ); +}; + +const persistLibraryUpdate = async ( + adapter: LibraryPersistenceAdapter, + update: LibraryUpdate, +): Promise => { + try { + librarySaveCounter++; + + return await AdapterTransaction.run(adapter, async (transaction) => { + const nextLibraryItemsMap = arrayToMap( + await transaction.getLibraryItems(1), + ); + + for (const [id] of update.deletedItems) { + nextLibraryItemsMap.delete(id); + } + + const addedItems: LibraryItem[] = []; + + // we want to merge current library items with the ones stored in the + // DB so that we don't lose any elements that for some reason aren't + // in the current editor library, which could happen when: + // + // 1. we haven't received an update deleting some elements + // (in which case it's still better to keep them in the DB lest + // it was due to a different reason) + // 2. we keep a single DB for all active editors, but the editors' + // libraries aren't synced or there's a race conditions during + // syncing + // 3. some other race condition, e.g. during init where emit updates + // for partial updates (e.g. you install a 3rd party library and + // init from DB only after — we emit events for both updates) + for (const [id, item] of update.addedItems) { + if (nextLibraryItemsMap.has(id)) { + // replace item with latest version + // TODO we could prefer the newer item instead + nextLibraryItemsMap.set(id, item); + } else { + // we want to prepend the new items with the ones that are already + // in DB to preserve the ordering we do in editor (newly added + // items are added to the beginning) + addedItems.push(item); + } + } + + const nextLibraryItems = addedItems.concat( + Array.from(nextLibraryItemsMap.values()), + ); + + const version = getLibraryItemsHash(nextLibraryItems); + + if (version !== lastSavedLibraryItemsHash) { + await adapter.save({ libraryItems: nextLibraryItems }); + } + + lastSavedLibraryItemsHash = version; + + return nextLibraryItems; + }); + } finally { + librarySaveCounter--; + } +}; + +export const useHandleLibrary = ( + opts: { + excalidrawAPI: ExcalidrawImperativeAPI | null; + } & ( + | { + /** @deprecated we recommend using `opts.adapter` instead */ + getInitialLibraryItems?: () => MaybePromise; + } + | { + adapter: LibraryPersistenceAdapter; + /** + * Adapter that takes care of loading data from legacy data store. + * Supply this if you want to migrate data on initial load from legacy + * data store. + * + * Can be a different LibraryPersistenceAdapter. + */ + migrationAdapter?: LibraryMigrationAdapter; + } + ), +) => { + const { excalidrawAPI } = opts; + + const optsRef = useRef(opts); + optsRef.current = opts; + + const isLibraryLoadedRef = useRef(false); useEffect(() => { if (!excalidrawAPI) { return; } + // reset on editor remount (excalidrawAPI changed) + isLibraryLoadedRef.current = false; + const importLibraryFromURL = async ({ libraryUrl, idToken, @@ -463,23 +708,209 @@ export const useHandleLibrary = ({ }; // ------------------------------------------------------------------------- - // ------ init load -------------------------------------------------------- - if (getInitialLibraryRef.current) { - excalidrawAPI.updateLibrary({ - libraryItems: getInitialLibraryRef.current(), - }); - } + // ---------------------------------- init --------------------------------- + // ------------------------------------------------------------------------- const libraryUrlTokens = parseLibraryTokensFromUrl(); if (libraryUrlTokens) { importLibraryFromURL(libraryUrlTokens); } + + // ------ (A) init load (legacy) ------------------------------------------- + if ( + "getInitialLibraryItems" in optsRef.current && + optsRef.current.getInitialLibraryItems + ) { + console.warn( + "useHandleLibrar `opts.getInitialLibraryItems` is deprecated. Use `opts.adapter` instead.", + ); + + Promise.resolve(optsRef.current.getInitialLibraryItems()) + .then((libraryItems) => { + excalidrawAPI.updateLibrary({ + libraryItems, + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }); + }) + .catch((error: any) => { + console.error( + `UseHandeLibrary getInitialLibraryItems failed: ${error?.message}`, + ); + }); + } + + // ------------------------------------------------------------------------- // --------------------------------------------------------- init load ----- + // ------------------------------------------------------------------------- + + // ------ (B) data source adapter ------------------------------------------ + + if ("adapter" in optsRef.current && optsRef.current.adapter) { + const adapter = optsRef.current.adapter; + const migrationAdapter = optsRef.current.migrationAdapter; + + const initDataPromise = resolvablePromise(); + + // migrate from old data source if needed + // (note, if `migrate` function is defined, we always migrate even + // if the data has already been migrated. In that case it'll be a no-op, + // though with several unnecessary steps — we will still load latest + // DB data during the `persistLibraryChange()` step) + // ----------------------------------------------------------------------- + if (migrationAdapter) { + initDataPromise.resolve( + promiseTry(migrationAdapter.load) + .then(async (libraryData) => { + try { + // if no library data to migrate, assume no migration needed + // and skip persisting to new data store, as well as well + // clearing the old store via `migrationAdapter.clear()` + if (!libraryData) { + return AdapterTransaction.getLibraryItems(adapter, 2); + } + + // we don't queue this operation because it's running inside + // a promise that's running inside Library update queue itself + const nextItems = await persistLibraryUpdate( + adapter, + createLibraryUpdate( + [], + restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ), + ), + ); + try { + await migrationAdapter.clear(); + } catch (error: any) { + console.error( + `couldn't delete legacy library data: ${error.message}`, + ); + } + // migration suceeded, load migrated data + return nextItems; + } catch (error: any) { + console.error( + `couldn't migrate legacy library data: ${error.message}`, + ); + // migration failed, load empty library + return []; + } + }) + // errors caught during `migrationAdapter.load()` + .catch((error: any) => { + console.error(`error during library migration: ${error.message}`); + // as a default, load latest library from current data source + return AdapterTransaction.getLibraryItems(adapter, 2); + }), + ); + } else { + initDataPromise.resolve( + promiseTry(AdapterTransaction.getLibraryItems, adapter, 2), + ); + } + + // load initial (or migrated) library + excalidrawAPI + .updateLibrary({ + libraryItems: initDataPromise.then((libraryItems) => { + const _libraryItems = libraryItems || []; + lastSavedLibraryItemsHash = getLibraryItemsHash(_libraryItems); + return _libraryItems; + }), + // merge with current library items because we may have already + // populated it (e.g. by installing 3rd party library which can + // happen before the DB data is loaded) + merge: true, + }) + .finally(() => { + isLibraryLoadedRef.current = true; + }); + } + // ---------------------------------------------- data source datapter ----- window.addEventListener(EVENT.HASHCHANGE, onHashChange); return () => { window.removeEventListener(EVENT.HASHCHANGE, onHashChange); }; - }, [excalidrawAPI]); + }, [ + // important this useEffect only depends on excalidrawAPI so it only reruns + // on editor remounts (the excalidrawAPI changes) + excalidrawAPI, + ]); + + // This effect is run without excalidrawAPI dependency so that host apps + // can run this hook outside of an active editor instance and the library + // update queue/loop survives editor remounts + // + // This effect is still only meant to be run if host apps supply an persitence + // adapter. If we don't have access to it, it the update listener doesn't + // do anything. + useEffect( + () => { + // on update, merge with current library items and persist + // ----------------------------------------------------------------------- + const unsubOnLibraryUpdate = onLibraryUpdateEmitter.on( + async (update, nextLibraryItems) => { + const isLoaded = isLibraryLoadedRef.current; + // we want to operate with the latest adapter, but we don't want this + // effect to rerun on every adapter change in case host apps' adapter + // isn't stable + const adapter = + ("adapter" in optsRef.current && optsRef.current.adapter) || null; + try { + if (adapter) { + if ( + // if nextLibraryItems hash identical to previously saved hash, + // exit early, even if actual upstream state ends up being + // different (e.g. has more data than we have locally), as it'd + // be low-impact scenario. + lastSavedLibraryItemsHash !== + getLibraryItemsHash(nextLibraryItems) + ) { + await persistLibraryUpdate(adapter, update); + } + } + } catch (error: any) { + console.error( + `couldn't persist library update: ${error.message}`, + update, + ); + + // currently we only show error if an editor is loaded + if (isLoaded && optsRef.current.excalidrawAPI) { + optsRef.current.excalidrawAPI.updateScene({ + appState: { + errorMessage: t("errors.saveLibraryError"), + }, + }); + } + } + }, + ); + + const onUnload = (event: Event) => { + if (librarySaveCounter) { + preventUnload(event); + } + }; + + window.addEventListener(EVENT.BEFORE_UNLOAD, onUnload); + + return () => { + window.removeEventListener(EVENT.BEFORE_UNLOAD, onUnload); + unsubOnLibraryUpdate(); + lastSavedLibraryItemsHash = 0; + librarySaveCounter = 0; + }; + }, + [ + // this effect must not have any deps so it doesn't rerun + ], + ); }; diff --git a/packages/excalidraw/element/index.ts b/packages/excalidraw/element/index.ts index 093ef4829..7e9769d83 100644 --- a/packages/excalidraw/element/index.ts +++ b/packages/excalidraw/element/index.ts @@ -60,9 +60,36 @@ export { } from "./sizeHelpers"; export { showSelectedShapeActions } from "./showSelectedShapeActions"; +/** + * @deprecated unsafe, use hashElementsVersion instead + */ export const getSceneVersion = (elements: readonly ExcalidrawElement[]) => elements.reduce((acc, el) => acc + el.version, 0); +/** + * Hashes elements' versionNonce (using djb2 algo). Order of elements matters. + */ +export const hashElementsVersion = ( + elements: readonly ExcalidrawElement[], +): number => { + let hash = 5381; + for (let i = 0; i < elements.length; i++) { + hash = (hash << 5) + hash + elements[i].versionNonce; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + +// string hash function (using djb2). Not cryptographically secure, use only +// for versioning and such. +export const hashString = (s: string): number => { + let hash: number = 5381; + for (let i = 0; i < s.length; i++) { + const char: number = s.charCodeAt(i); + hash = (hash << 5) + hash + char; + } + return hash >>> 0; // Ensure unsigned 32-bit integer +}; + export const getVisibleElements = (elements: readonly ExcalidrawElement[]) => elements.filter( (el) => !el.isDeleted && !isInvisiblySmallElement(el), diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 2dae37c6b..66eb91044 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -207,6 +207,8 @@ Excalidraw.displayName = "Excalidraw"; export { getSceneVersion, + hashElementsVersion, + hashString, isInvisiblySmallElement, getNonDeletedElements, } from "./element"; @@ -232,7 +234,7 @@ export { loadLibraryFromBlob, } from "./data/blob"; export { getFreeDrawSvgPath } from "./renderer/renderElement"; -export { mergeLibraryItems } from "./data/library"; +export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index 924d5c8ae..ac9108a32 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -216,6 +216,7 @@ "failedToFetchImage": "Failed to fetch image.", "cannotResolveCollabServer": "Couldn't connect to the collab server. Please reload the page and try again.", "importLibraryError": "Couldn't load library", + "saveLibraryError": "Couldn't save library to storage. Please save your library to a file locally to make sure you don't lose changes.", "collabSaveFailed": "Couldn't save to the backend database. If problems persist, you should save your file locally to ensure you don't lose your work.", "collabSaveFailed_sizeExceeded": "Couldn't save to the backend database, the canvas seems to be too big. You should save the file locally to ensure you don't lose your work.", "imageToolNotSupported": "Images are disabled.", diff --git a/packages/excalidraw/queue.test.ts b/packages/excalidraw/queue.test.ts new file mode 100644 index 000000000..66a10583e --- /dev/null +++ b/packages/excalidraw/queue.test.ts @@ -0,0 +1,62 @@ +import { Queue } from "./queue"; + +describe("Queue", () => { + const calls: any[] = []; + + const createJobFactory = + ( + // for purpose of this test, Error object will become a rejection value + resolutionOrRejectionValue: T, + ms = 1, + ) => + () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + if (resolutionOrRejectionValue instanceof Error) { + reject(resolutionOrRejectionValue); + } else { + resolve(resolutionOrRejectionValue); + } + }, ms); + }).then((x) => { + calls.push(x); + return x; + }); + }; + + beforeEach(() => { + calls.length = 0; + }); + + it("should await and resolve values in order of enqueueing", async () => { + const queue = new Queue(); + + const p1 = queue.push(createJobFactory("A", 50)); + const p2 = queue.push(createJobFactory("B")); + const p3 = queue.push(createJobFactory("C")); + + expect(await p3).toBe("C"); + expect(await p2).toBe("B"); + expect(await p1).toBe("A"); + + expect(calls).toEqual(["A", "B", "C"]); + }); + + it("should reject a job if it throws, and not affect other jobs", async () => { + const queue = new Queue(); + + const err = new Error("B"); + + queue.push(createJobFactory("A", 50)); + const p2 = queue.push(createJobFactory(err)); + const p3 = queue.push(createJobFactory("C")); + + const p2err = p2.catch((err) => err); + + await p3; + + expect(await p2err).toBe(err); + + expect(calls).toEqual(["A", "C"]); + }); +}); diff --git a/packages/excalidraw/queue.ts b/packages/excalidraw/queue.ts new file mode 100644 index 000000000..408e945ba --- /dev/null +++ b/packages/excalidraw/queue.ts @@ -0,0 +1,45 @@ +import { MaybePromise } from "./utility-types"; +import { promiseTry, ResolvablePromise, resolvablePromise } from "./utils"; + +type Job = (...args: TArgs) => MaybePromise; + +type QueueJob = { + jobFactory: Job; + promise: ResolvablePromise; + args: TArgs; +}; + +export class Queue { + private jobs: QueueJob[] = []; + private running = false; + + private tick() { + if (this.running) { + return; + } + const job = this.jobs.shift(); + if (job) { + this.running = true; + job.promise.resolve( + promiseTry(job.jobFactory, ...job.args).finally(() => { + this.running = false; + this.tick(); + }), + ); + } else { + this.running = false; + } + } + + push( + jobFactory: Job, + ...args: TArgs + ): Promise { + const promise = resolvablePromise(); + this.jobs.push({ jobFactory, promise, args }); + + this.tick(); + + return promise; + } +} diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 1eaa04449..fefb82c2c 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -38,7 +38,7 @@ import type { FileSystemHandle } from "./data/filesystem"; import type { IMAGE_MIME_TYPES, MIME_TYPES } from "./constants"; import { ContextMenuItems } from "./components/ContextMenu"; import { SnapLine } from "./snapping"; -import { Merge, ValueOf } from "./utility-types"; +import { Merge, MaybePromise, ValueOf } from "./utility-types"; export type Point = Readonly; @@ -380,21 +380,14 @@ export type LibraryItems_anyVersion = LibraryItems | LibraryItems_v1; export type LibraryItemsSource = | (( currentLibraryItems: LibraryItems, - ) => - | Blob - | LibraryItems_anyVersion - | Promise) - | Blob - | LibraryItems_anyVersion - | Promise; + ) => MaybePromise) + | MaybePromise; // ----------------------------------------------------------------------------- export type ExcalidrawInitialDataState = Merge< ImportedDataState, { - libraryItems?: - | Required["libraryItems"] - | Promise["libraryItems"]>; + libraryItems?: MaybePromise["libraryItems"]>; } >; @@ -409,10 +402,7 @@ export interface ExcalidrawProps { appState: AppState, files: BinaryFiles, ) => void; - initialData?: - | ExcalidrawInitialDataState - | null - | Promise; + initialData?: MaybePromise; excalidrawAPI?: (api: ExcalidrawImperativeAPI) => void; isCollaborating?: boolean; onPointerUpdate?: (payload: { @@ -643,7 +633,7 @@ export type PointerDownState = Readonly<{ export type UnsubscribeCallback = () => void; -export type ExcalidrawImperativeAPI = { +export interface ExcalidrawImperativeAPI { updateScene: InstanceType["updateScene"]; updateLibrary: InstanceType["updateLibrary"]; resetScene: InstanceType["resetScene"]; @@ -700,7 +690,7 @@ export type ExcalidrawImperativeAPI = { onUserFollow: ( callback: (payload: OnUserFollowedPayload) => void, ) => UnsubscribeCallback; -}; +} export type Device = Readonly<{ viewport: { diff --git a/packages/excalidraw/utility-types.ts b/packages/excalidraw/utility-types.ts index 576769634..f7872393e 100644 --- a/packages/excalidraw/utility-types.ts +++ b/packages/excalidraw/utility-types.ts @@ -62,3 +62,6 @@ export type MakeBrand = { /** @private using ~ to sort last in intellisense */ [K in `~brand~${T}`]: T; }; + +/** Maybe just promise or already fulfilled one! */ +export type MaybePromise = T | Promise; diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index 525652e6b..d27445dfa 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -14,7 +14,7 @@ import { UnsubscribeCallback, Zoom, } from "./types"; -import { ResolutionType } from "./utility-types"; +import { MaybePromise, ResolutionType } from "./utility-types"; let mockDateTime: string | null = null; @@ -538,7 +538,9 @@ export const isTransparent = (color: string) => { }; export type ResolvablePromise = Promise & { - resolve: [T] extends [undefined] ? (value?: T) => void : (value: T) => void; + resolve: [T] extends [undefined] + ? (value?: MaybePromise>) => void + : (value: MaybePromise>) => void; reject: (error: Error) => void; }; export const resolvablePromise = () => { @@ -1090,3 +1092,13 @@ export const toBrandedType = ( }; // ----------------------------------------------------------------------------- + +// Promise.try, adapted from https://github.com/sindresorhus/p-try +export const promiseTry = async ( + fn: (...args: TArgs) => PromiseLike | TValue, + ...args: TArgs +): Promise => { + return new Promise((resolve) => { + resolve(fn(...args)); + }); +}; From 6a385d6663cc8a47ee8e31cf593da8cf4a171953 Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:40:51 +0100 Subject: [PATCH 22/66] feat: change LibraryPersistenceAdapter `load()` `source` -> `priority` to clarify the semantics --- excalidraw-app/App.tsx | 2 +- packages/excalidraw/data/library.ts | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 1ab776aea..703599634 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -318,7 +318,7 @@ const ExcalidrawWrapper = () => { useHandleLibrary({ excalidrawAPI, adapter: LibraryIndexedDBAdapter, - // TODO maybe remove this in several months (shipped: 24-02-07) + // TODO maybe remove this in several months (shipped: 24-03-11) migrationAdapter: LibraryLocalStorageMigrationAdapter, }); diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 525eecb57..b2170b29a 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -51,6 +51,8 @@ const onLibraryUpdateEmitter = new Emitter< [update: LibraryUpdate, libraryItems: LibraryItems] >(); +export type LibraryAdatapterSource = "load" | "save"; + export interface LibraryPersistenceAdapter { /** * Should load data that were previously saved into the database using the @@ -61,12 +63,10 @@ export interface LibraryPersistenceAdapter { */ load(metadata: { /** - * Priority 1 indicates we're loading latest data with intent - * to reconcile with before save. - * Priority 2 indicates we're loading for read-only purposes, so - * host app can implement more aggressive caching strategy. + * Indicates whether we're loading data for save purposes, or reading + * purposes, in which case host app can implement more aggressive caching. */ - priority: 1 | 2; + source: LibraryAdatapterSource; }): MaybePromise<{ libraryItems: LibraryItems_anyVersion } | null>; /** Should persist to the database as is (do no change the data structure). */ save(libraryData: LibraryPersistedData): MaybePromise; @@ -487,13 +487,13 @@ class AdapterTransaction { static async getLibraryItems( adapter: LibraryPersistenceAdapter, - priority: 1 | 2, + source: LibraryAdatapterSource, _queue = true, ): Promise { const task = () => new Promise(async (resolve, reject) => { try { - const data = await adapter.load({ priority }); + const data = await adapter.load({ source }); resolve(restoreLibraryItems(data?.libraryItems || [], "published")); } catch (error: any) { reject(error); @@ -523,8 +523,8 @@ class AdapterTransaction { this.adapter = adapter; } - getLibraryItems(priority: 1 | 2) { - return AdapterTransaction.getLibraryItems(this.adapter, priority, false); + getLibraryItems(source: LibraryAdatapterSource) { + return AdapterTransaction.getLibraryItems(this.adapter, source, false); } } @@ -551,7 +551,7 @@ const persistLibraryUpdate = async ( return await AdapterTransaction.run(adapter, async (transaction) => { const nextLibraryItemsMap = arrayToMap( - await transaction.getLibraryItems(1), + await transaction.getLibraryItems("save"), ); for (const [id] of update.deletedItems) { @@ -770,7 +770,7 @@ export const useHandleLibrary = ( // and skip persisting to new data store, as well as well // clearing the old store via `migrationAdapter.clear()` if (!libraryData) { - return AdapterTransaction.getLibraryItems(adapter, 2); + return AdapterTransaction.getLibraryItems(adapter, "load"); } // we don't queue this operation because it's running inside @@ -806,12 +806,12 @@ export const useHandleLibrary = ( .catch((error: any) => { console.error(`error during library migration: ${error.message}`); // as a default, load latest library from current data source - return AdapterTransaction.getLibraryItems(adapter, 2); + return AdapterTransaction.getLibraryItems(adapter, "load"); }), ); } else { initDataPromise.resolve( - promiseTry(AdapterTransaction.getLibraryItems, adapter, 2), + promiseTry(AdapterTransaction.getLibraryItems, adapter, "load"), ); } From b7babe554b16201f55d2636f365e9161805c47ce Mon Sep 17 00:00:00 2001 From: dwelle <5153846+dwelle@users.noreply.github.com> Date: Mon, 11 Mar 2024 09:57:01 +0100 Subject: [PATCH 23/66] feat: load old library if migration fails --- packages/excalidraw/data/library.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index b2170b29a..5e1af6c22 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -765,6 +765,7 @@ export const useHandleLibrary = ( initDataPromise.resolve( promiseTry(migrationAdapter.load) .then(async (libraryData) => { + let restoredData: LibraryItems | null = null; try { // if no library data to migrate, assume no migration needed // and skip persisting to new data store, as well as well @@ -773,17 +774,16 @@ export const useHandleLibrary = ( return AdapterTransaction.getLibraryItems(adapter, "load"); } + restoredData = restoreLibraryItems( + libraryData.libraryItems || [], + "published", + ); + // we don't queue this operation because it's running inside // a promise that's running inside Library update queue itself const nextItems = await persistLibraryUpdate( adapter, - createLibraryUpdate( - [], - restoreLibraryItems( - libraryData.libraryItems || [], - "published", - ), - ), + createLibraryUpdate([], restoredData), ); try { await migrationAdapter.clear(); @@ -798,8 +798,8 @@ export const useHandleLibrary = ( console.error( `couldn't migrate legacy library data: ${error.message}`, ); - // migration failed, load empty library - return []; + // migration failed, load data from previous store, if any + return restoredData; } }) // errors caught during `migrationAdapter.load()` From 068895db0eed082505788a2db0c6d63664e857df Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:20:07 +0100 Subject: [PATCH 24/66] feat: expose more collaborator status icons (#7777) --- excalidraw-app/index.scss | 2 +- .../excalidraw/actions/actionNavigate.tsx | 92 +++++-- packages/excalidraw/clients.ts | 229 +++++++++++++++++- packages/excalidraw/components/App.tsx | 4 +- packages/excalidraw/components/Avatar.tsx | 15 +- packages/excalidraw/components/LayerUI.scss | 7 + packages/excalidraw/components/UserList.scss | 108 +++++++-- packages/excalidraw/components/UserList.tsx | 228 ++++++++++------- .../components/canvases/InteractiveCanvas.tsx | 52 ++-- packages/excalidraw/components/icons.tsx | 25 +- packages/excalidraw/constants.ts | 8 + packages/excalidraw/css/variables.module.scss | 14 +- packages/excalidraw/laser-trails.ts | 2 +- packages/excalidraw/locales/en.json | 5 +- .../excalidraw/renderer/interactiveScene.ts | 168 ++----------- packages/excalidraw/scene/types.ts | 13 +- packages/excalidraw/types.ts | 7 +- packages/excalidraw/utils.ts | 8 + 18 files changed, 652 insertions(+), 335 deletions(-) diff --git a/excalidraw-app/index.scss b/excalidraw-app/index.scss index 021442753..24741b062 100644 --- a/excalidraw-app/index.scss +++ b/excalidraw-app/index.scss @@ -8,7 +8,7 @@ .top-right-ui { display: flex; justify-content: center; - align-items: center; + align-items: flex-start; } .footer-center { diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index ea65584fe..5c60a029d 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -1,10 +1,15 @@ import { getClientColor } from "../clients"; import { Avatar } from "../components/Avatar"; import { GoToCollaboratorComponentProps } from "../components/UserList"; -import { eyeIcon } from "../components/icons"; +import { + eyeIcon, + microphoneIcon, + microphoneMutedIcon, +} from "../components/icons"; import { t } from "../i18n"; import { Collaborator } from "../types"; import { register } from "./register"; +import clsx from "clsx"; export const actionGoToCollaborator = register({ name: "goToCollaborator", @@ -39,14 +44,45 @@ export const actionGoToCollaborator = register({ }; }, PanelComponent: ({ updateData, data, appState }) => { - const { clientId, collaborator, withName, isBeingFollowed } = + const { socketId, collaborator, withName, isBeingFollowed } = data as GoToCollaboratorComponentProps; - const background = getClientColor(clientId); + const background = getClientColor(socketId, collaborator); + + const statusClassNames = clsx({ + "is-followed": isBeingFollowed, + "is-current-user": collaborator.isCurrentUser === true, + "is-speaking": collaborator.isSpeaking, + "is-in-call": collaborator.isInCall, + "is-muted": collaborator.isMuted, + }); + + const statusIconJSX = collaborator.isInCall ? ( + collaborator.isSpeaking ? ( +
+
+
+
+
+ ) : collaborator.isMuted ? ( +
+ {microphoneMutedIcon} +
+ ) : ( +
{microphoneIcon}
+ ) + ) : null; return withName ? (
updateData(collaborator)} > {}} name={collaborator.username || ""} src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} + className={statusClassNames} />
{collaborator.username}
-
- {eyeIcon} +
+ {isBeingFollowed && ( +
+ {eyeIcon} +
+ )} + {statusIconJSX}
) : ( - { - updateData(collaborator); - }} - name={collaborator.username || ""} - src={collaborator.avatarUrl} - isBeingFollowed={isBeingFollowed} - isCurrentUser={collaborator.isCurrentUser === true} - /> +
+ { + updateData(collaborator); + }} + name={collaborator.username || ""} + src={collaborator.avatarUrl} + className={statusClassNames} + /> + {statusIconJSX && ( +
+ {statusIconJSX} +
+ )} +
); }, }); diff --git a/packages/excalidraw/clients.ts b/packages/excalidraw/clients.ts index 354098918..439080bd5 100644 --- a/packages/excalidraw/clients.ts +++ b/packages/excalidraw/clients.ts @@ -1,3 +1,18 @@ +import { + COLOR_CHARCOAL_BLACK, + COLOR_VOICE_CALL, + COLOR_WHITE, + THEME, +} from "./constants"; +import { roundRect } from "./renderer/roundRect"; +import { InteractiveCanvasRenderConfig } from "./scene/types"; +import { + Collaborator, + InteractiveCanvasAppState, + SocketId, + UserIdleState, +} from "./types"; + function hashToInteger(id: string) { let hash = 0; if (id.length === 0) { @@ -11,14 +26,12 @@ function hashToInteger(id: string) { } export const getClientColor = ( - /** - * any uniquely identifying key, such as user id or socket id - */ - id: string, + socketId: SocketId, + collaborator: Collaborator | undefined, ) => { // to get more even distribution in case `id` is not uniformly distributed to // begin with, we hash it - const hash = Math.abs(hashToInteger(id)); + const hash = Math.abs(hashToInteger(collaborator?.id || socketId)); // we want to get a multiple of 10 number in the range of 0-360 (in other // words a hue value of step size 10). There are 37 such values including 0. const hue = (hash % 37) * 10; @@ -38,3 +51,209 @@ export const getNameInitial = (name?: string | null) => { firstCodePoint ? String.fromCodePoint(firstCodePoint) : "?" ).toUpperCase(); }; + +export const renderRemoteCursors = ({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, +}: { + context: CanvasRenderingContext2D; + renderConfig: InteractiveCanvasRenderConfig; + appState: InteractiveCanvasAppState; + normalizedWidth: number; + normalizedHeight: number; +}) => { + // Paint remote pointers + for (const [socketId, pointer] of renderConfig.remotePointerViewportCoords) { + let { x, y } = pointer; + + const collaborator = appState.collaborators.get(socketId); + + x -= appState.offsetLeft; + y -= appState.offsetTop; + + const width = 11; + const height = 14; + + const isOutOfBounds = + x < 0 || + x > normalizedWidth - width || + y < 0 || + y > normalizedHeight - height; + + x = Math.max(x, 0); + x = Math.min(x, normalizedWidth - width); + y = Math.max(y, 0); + y = Math.min(y, normalizedHeight - height); + + const background = getClientColor(socketId, collaborator); + + context.save(); + context.strokeStyle = background; + context.fillStyle = background; + + const userState = renderConfig.remotePointerUserStates.get(socketId); + const isInactive = + isOutOfBounds || + userState === UserIdleState.IDLE || + userState === UserIdleState.AWAY; + + if (isInactive) { + context.globalAlpha = 0.3; + } + + if (renderConfig.remotePointerButton.get(socketId) === "down") { + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 3; + context.strokeStyle = "#ffffff88"; + context.stroke(); + context.closePath(); + + context.beginPath(); + context.arc(x, y, 15, 0, 2 * Math.PI, false); + context.lineWidth = 1; + context.strokeStyle = background; + context.stroke(); + context.closePath(); + } + + // TODO remove the dark theme color after we stop inverting canvas colors + const IS_SPEAKING_COLOR = + appState.theme === THEME.DARK ? "#2f6330" : COLOR_VOICE_CALL; + + const isSpeaking = collaborator?.isSpeaking; + + if (isSpeaking) { + // cursor outline for currently speaking user + context.fillStyle = IS_SPEAKING_COLOR; + context.strokeStyle = IS_SPEAKING_COLOR; + context.lineWidth = 10; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + } + + // Background (white outline) for arrow + context.fillStyle = COLOR_WHITE; + context.strokeStyle = COLOR_WHITE; + context.lineWidth = 6; + context.lineJoin = "round"; + context.beginPath(); + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.stroke(); + context.fill(); + + // Arrow + context.fillStyle = background; + context.strokeStyle = background; + context.lineWidth = 2; + context.lineJoin = "round"; + context.beginPath(); + if (isInactive) { + context.moveTo(x - 1, y - 1); + context.lineTo(x - 1, y + 15); + context.lineTo(x + 5, y + 10); + context.lineTo(x + 12, y + 9); + context.closePath(); + context.fill(); + } else { + context.moveTo(x, y); + context.lineTo(x + 0, y + 14); + context.lineTo(x + 4, y + 9); + context.lineTo(x + 11, y + 8); + context.closePath(); + context.fill(); + context.stroke(); + } + + const username = renderConfig.remotePointerUsernames.get(socketId) || ""; + + if (!isOutOfBounds && username) { + context.font = "600 12px sans-serif"; // font has to be set before context.measureText() + + const offsetX = (isSpeaking ? x + 0 : x) + width / 2; + const offsetY = (isSpeaking ? y + 0 : y) + height + 2; + const paddingHorizontal = 5; + const paddingVertical = 3; + const measure = context.measureText(username); + const measureHeight = + measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; + const finalHeight = Math.max(measureHeight, 12); + + const boxX = offsetX - 1; + const boxY = offsetY - 1; + const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; + const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; + if (context.roundRect) { + context.beginPath(); + context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); + context.fillStyle = background; + context.fill(); + context.strokeStyle = COLOR_WHITE; + context.stroke(); + + if (isSpeaking) { + context.beginPath(); + context.roundRect(boxX - 2, boxY - 2, boxWidth + 4, boxHeight + 4, 8); + context.strokeStyle = IS_SPEAKING_COLOR; + context.stroke(); + } + } else { + roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, COLOR_WHITE); + } + context.fillStyle = COLOR_CHARCOAL_BLACK; + + context.fillText( + username, + offsetX + paddingHorizontal + 1, + offsetY + + paddingVertical + + measure.actualBoundingBoxAscent + + Math.floor((finalHeight - measureHeight) / 2) + + 2, + ); + + // draw three vertical bars signalling someone is speaking + if (isSpeaking) { + context.fillStyle = IS_SPEAKING_COLOR; + const barheight = 8; + const margin = 8; + const gap = 5; + context.fillRect( + boxX + boxWidth + margin, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + context.fillRect( + boxX + boxWidth + margin + gap, + boxY + (boxHeight / 2 - (barheight * 2) / 2), + 2, + barheight * 2, + ); + context.fillRect( + boxX + boxWidth + margin + gap * 2, + boxY + (boxHeight / 2 - barheight / 2), + 2, + barheight, + ); + } + } + + context.restore(); + context.closePath(); + } +}; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 97ce14662..b02d919d4 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -89,6 +89,7 @@ import { TOOL_TYPE, EDITOR_LS_KEYS, isIOS, + supportsResizeObserver, } from "../constants"; import { ExportedElements, exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; @@ -476,9 +477,6 @@ export const useExcalidrawSetAppState = () => export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); -const supportsResizeObserver = - typeof window !== "undefined" && "ResizeObserver" in window; - let didTapTwice: boolean = false; let tappedTwiceTimer = 0; let isHoldingSpace: boolean = false; diff --git a/packages/excalidraw/components/Avatar.tsx b/packages/excalidraw/components/Avatar.tsx index b7b1bf962..9ddc319c6 100644 --- a/packages/excalidraw/components/Avatar.tsx +++ b/packages/excalidraw/components/Avatar.tsx @@ -9,8 +9,7 @@ type AvatarProps = { color: string; name: string; src?: string; - isBeingFollowed?: boolean; - isCurrentUser: boolean; + className?: string; }; export const Avatar = ({ @@ -18,22 +17,14 @@ export const Avatar = ({ onClick, name, src, - isBeingFollowed, - isCurrentUser, + className, }: AvatarProps) => { const shortName = getNameInitial(name); const [error, setError] = useState(false); const loadImg = !error && src; const style = loadImg ? undefined : { background: color }; return ( -
+
{loadImg ? ( * { + pointer-events: var(--ui-pointerEvents); + } } &__footer { diff --git a/packages/excalidraw/components/UserList.scss b/packages/excalidraw/components/UserList.scss index fceb1e7c4..86c3179ad 100644 --- a/packages/excalidraw/components/UserList.scss +++ b/packages/excalidraw/components/UserList.scss @@ -1,16 +1,25 @@ @import "../css/variables.module"; .excalidraw { + --avatar-size: 1.75rem; + --avatarList-gap: 0.625rem; + --userList-padding: var(--space-factor); + + .UserList-wrapper { + display: flex; + width: 100%; + justify-content: flex-end; + pointer-events: none !important; + } + .UserList { pointer-events: none; - /*github corner*/ - padding: var(--space-factor) var(--space-factor) var(--space-factor) - var(--space-factor); + padding: var(--userList-padding); display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; - gap: 0.625rem; + gap: var(--avatarList-gap); &:empty { display: none; @@ -18,15 +27,16 @@ box-sizing: border-box; - // can fit max 4 avatars (3 avatars + show more) in a column - max-height: 120px; + --max-size: calc( + var(--avatar-size) * var(--max-avatars, 2) + var(--avatarList-gap) * + (var(--max-avatars, 2) - 1) + var(--userList-padding) * 2 + ); - // can fit max 4 avatars (3 avatars + show more) when there's enough space - max-width: 120px; + // max width & height set to fix the max-avatars + max-height: var(--max-size); + max-width: var(--max-size); // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^ - - overflow: hidden; } .UserList > * { @@ -45,10 +55,11 @@ @include avatarStyles; background-color: var(--color-gray-20); border: 0 !important; - font-size: 0.5rem; + font-size: 0.625rem; font-weight: 400; flex-shrink: 0; color: var(--color-gray-100); + font-weight: bold; } .UserList__collaborator-name { @@ -57,13 +68,82 @@ white-space: nowrap; } - .UserList__collaborator-follow-status-icon { + .UserList__collaborator--avatar-only { + position: relative; + display: flex; + flex: 0 0 auto; + .UserList__collaborator-status-icon { + --size: 14px; + position: absolute; + display: flex; + flex: 0 0 auto; + bottom: -0.25rem; + right: -0.25rem; + width: var(--size); + height: var(--size); + svg { + flex: 0 0 auto; + width: var(--size); + height: var(--size); + } + } + } + + .UserList__collaborator-status-icons { margin-left: auto; flex: 0 0 auto; - width: 1rem; + min-width: 2.25rem; + gap: 0.25rem; + justify-content: flex-end; display: flex; } + .UserList__collaborator.is-muted + .UserList__collaborator-status-icon-microphone-muted { + color: var(--color-danger); + filter: drop-shadow(0px 0px 0px rgba(0, 0, 0, 0.5)); + } + + .UserList__collaborator-status-icon-speaking-indicator { + display: flex; + flex-flow: row nowrap; + align-items: center; + justify-content: space-between; + width: 1rem; + padding: 0 3px; + box-sizing: border-box; + + div { + width: 0.125rem; + height: 0.4rem; + // keep this in sync with constants.ts + background-color: #a2f1a6; + } + + div:nth-of-type(1) { + animation: speaking-indicator-anim 1s -0.45s ease-in-out infinite; + } + + div:nth-of-type(2) { + animation: speaking-indicator-anim 1s -0.9s ease-in-out infinite; + } + + div:nth-of-type(3) { + animation: speaking-indicator-anim 1s -0.15s ease-in-out infinite; + } + } + + @keyframes speaking-indicator-anim { + 0%, + 100% { + transform: scaleY(1); + } + + 50% { + transform: scaleY(2); + } + } + --userlist-hint-bg-color: var(--color-gray-10); --userlist-hint-heading-color: var(--color-gray-80); --userlist-hint-text-color: var(--color-gray-60); @@ -80,7 +160,7 @@ position: static; top: auto; margin-top: 0; - max-height: 12rem; + max-height: 50vh; overflow-y: auto; padding: 0.25rem 0.5rem; border-top: 1px solid var(--userlist-collaborators-border-color); diff --git a/packages/excalidraw/components/UserList.tsx b/packages/excalidraw/components/UserList.tsx index ba01b52dc..ced759333 100644 --- a/packages/excalidraw/components/UserList.tsx +++ b/packages/excalidraw/components/UserList.tsx @@ -1,6 +1,6 @@ import "./UserList.scss"; -import React from "react"; +import React, { useLayoutEffect } from "react"; import clsx from "clsx"; import { Collaborator, SocketId } from "../types"; import { Tooltip } from "./Tooltip"; @@ -12,9 +12,11 @@ import { Island } from "./Island"; import { searchIcon } from "./icons"; import { t } from "../i18n"; import { isShallowEqual } from "../utils"; +import { supportsResizeObserver } from "../constants"; +import { MarkRequired } from "../utility-types"; export type GoToCollaboratorComponentProps = { - clientId: ClientId; + socketId: SocketId; collaborator: Collaborator; withName: boolean; isBeingFollowed: boolean; @@ -23,45 +25,41 @@ export type GoToCollaboratorComponentProps = { /** collaborator user id or socket id (fallback) */ type ClientId = string & { _brand: "UserId" }; -const FIRST_N_AVATARS = 3; +const DEFAULT_MAX_AVATARS = 4; const SHOW_COLLABORATORS_FILTER_AT = 8; const ConditionalTooltipWrapper = ({ shouldWrap, children, - clientId, username, }: { shouldWrap: boolean; children: React.ReactNode; username?: string | null; - clientId: ClientId; }) => shouldWrap ? ( - - {children} - + {children} ) : ( - {children} + {children} ); const renderCollaborator = ({ actionManager, collaborator, - clientId, + socketId, withName = false, shouldWrapWithTooltip = false, isBeingFollowed, }: { actionManager: ActionManager; collaborator: Collaborator; - clientId: ClientId; + socketId: SocketId; withName?: boolean; shouldWrapWithTooltip?: boolean; isBeingFollowed: boolean; }) => { const data: GoToCollaboratorComponentProps = { - clientId, + socketId, collaborator, withName, isBeingFollowed, @@ -70,8 +68,7 @@ const renderCollaborator = ({ return ( @@ -82,7 +79,13 @@ const renderCollaborator = ({ type UserListUserObject = Pick< Collaborator, - "avatarUrl" | "id" | "socketId" | "username" + | "avatarUrl" + | "id" + | "socketId" + | "username" + | "isInCall" + | "isSpeaking" + | "isMuted" >; type UserListProps = { @@ -97,13 +100,19 @@ const collaboratorComparatorKeys = [ "id", "socketId", "username", + "isInCall", + "isSpeaking", + "isMuted", ] as const; export const UserList = React.memo( ({ className, mobile, collaborators, userToFollow }: UserListProps) => { const actionManager = useExcalidrawActionManager(); - const uniqueCollaboratorsMap = new Map(); + const uniqueCollaboratorsMap = new Map< + ClientId, + MarkRequired + >(); collaborators.forEach((collaborator, socketId) => { const userId = (collaborator.id || socketId) as ClientId; @@ -114,115 +123,147 @@ export const UserList = React.memo( ); }); - const uniqueCollaboratorsArray = Array.from(uniqueCollaboratorsMap).filter( - ([_, collaborator]) => collaborator.username?.trim(), - ); + const uniqueCollaboratorsArray = Array.from( + uniqueCollaboratorsMap.values(), + ).filter((collaborator) => collaborator.username?.trim()); const [searchTerm, setSearchTerm] = React.useState(""); - if (uniqueCollaboratorsArray.length === 0) { - return null; - } + const userListWrapper = React.useRef(null); + + useLayoutEffect(() => { + if (userListWrapper.current) { + const updateMaxAvatars = (width: number) => { + const maxAvatars = Math.max(1, Math.min(8, Math.floor(width / 38))); + setMaxAvatars(maxAvatars); + }; + + updateMaxAvatars(userListWrapper.current.clientWidth); + + if (!supportsResizeObserver) { + return; + } + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + updateMaxAvatars(width); + } + }); + + resizeObserver.observe(userListWrapper.current); + + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS); const searchTermNormalized = searchTerm.trim().toLowerCase(); const filteredCollaborators = searchTermNormalized - ? uniqueCollaboratorsArray.filter(([, collaborator]) => + ? uniqueCollaboratorsArray.filter((collaborator) => collaborator.username?.toLowerCase().includes(searchTerm), ) : uniqueCollaboratorsArray; const firstNCollaborators = uniqueCollaboratorsArray.slice( 0, - FIRST_N_AVATARS, + maxAvatars - 1, ); - const firstNAvatarsJSX = firstNCollaborators.map( - ([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - shouldWrapWithTooltip: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), + const firstNAvatarsJSX = firstNCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + shouldWrapWithTooltip: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), ); return mobile ? (
- {uniqueCollaboratorsArray.map(([clientId, collaborator]) => + {uniqueCollaboratorsArray.map((collaborator) => renderCollaborator({ actionManager, collaborator, - clientId, + socketId: collaborator.socketId, shouldWrapWithTooltip: true, isBeingFollowed: collaborator.socketId === userToFollow, }), )}
) : ( -
- {firstNAvatarsJSX} +
+
+ {firstNAvatarsJSX} - {uniqueCollaboratorsArray.length > FIRST_N_AVATARS && ( - { - if (!isOpen) { - setSearchTerm(""); - } - }} - > - - +{uniqueCollaboratorsArray.length - FIRST_N_AVATARS} - - maxAvatars - 1 && ( + { + if (!isOpen) { + setSearchTerm(""); + } }} - align="end" - sideOffset={10} > - - {uniqueCollaboratorsArray.length >= - SHOW_COLLABORATORS_FILTER_AT && ( -
- {searchIcon} - { - setSearchTerm(e.target.value); - }} - /> -
- )} -
- {filteredCollaborators.length === 0 && ( -
- {t("userList.search.empty")} + + +{uniqueCollaboratorsArray.length - maxAvatars + 1} + + + + {uniqueCollaboratorsArray.length >= + SHOW_COLLABORATORS_FILTER_AT && ( +
+ {searchIcon} + { + setSearchTerm(e.target.value); + }} + />
)} -
- {t("userList.hint.text")} +
+ {filteredCollaborators.length === 0 && ( +
+ {t("userList.search.empty")} +
+ )} +
+ {t("userList.hint.text")} +
+ {filteredCollaborators.map((collaborator) => + renderCollaborator({ + actionManager, + collaborator, + socketId: collaborator.socketId, + withName: true, + isBeingFollowed: collaborator.socketId === userToFollow, + }), + )}
- {filteredCollaborators.map(([clientId, collaborator]) => - renderCollaborator({ - actionManager, - collaborator, - clientId, - withName: true, - isBeingFollowed: collaborator.socketId === userToFollow, - }), - )} -
-
-
- - )} + + + + )} +
); }, @@ -236,10 +277,15 @@ export const UserList = React.memo( return false; } + const nextCollaboratorSocketIds = next.collaborators.keys(); + for (const [socketId, collaborator] of prev.collaborators) { const nextCollaborator = next.collaborators.get(socketId); if ( !nextCollaborator || + // this checks order of collaborators in the map is the same + // as previous render + socketId !== nextCollaboratorSocketIds.next().value || !isShallowEqual( collaborator, nextCollaborator, diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index e76d8ae68..163756d57 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -66,42 +66,46 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { return; } - const cursorButton: { - [id: string]: string | undefined; - } = {}; - const pointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = - {}; + const remotePointerButton: InteractiveCanvasRenderConfig["remotePointerButton"] = + new Map(); + const remotePointerViewportCoords: InteractiveCanvasRenderConfig["remotePointerViewportCoords"] = + new Map(); const remoteSelectedElementIds: InteractiveCanvasRenderConfig["remoteSelectedElementIds"] = - {}; - const pointerUsernames: { [id: string]: string } = {}; - const pointerUserStates: { [id: string]: string } = {}; + new Map(); + const remotePointerUsernames: InteractiveCanvasRenderConfig["remotePointerUsernames"] = + new Map(); + const remotePointerUserStates: InteractiveCanvasRenderConfig["remotePointerUserStates"] = + new Map(); props.appState.collaborators.forEach((user, socketId) => { if (user.selectedElementIds) { for (const id of Object.keys(user.selectedElementIds)) { - if (!(id in remoteSelectedElementIds)) { - remoteSelectedElementIds[id] = []; + if (!remoteSelectedElementIds.has(id)) { + remoteSelectedElementIds.set(id, []); } - remoteSelectedElementIds[id].push(socketId); + remoteSelectedElementIds.get(id)!.push(socketId); } } if (!user.pointer) { return; } if (user.username) { - pointerUsernames[socketId] = user.username; + remotePointerUsernames.set(socketId, user.username); } if (user.userState) { - pointerUserStates[socketId] = user.userState; + remotePointerUserStates.set(socketId, user.userState); } - pointerViewportCoords[socketId] = sceneCoordsToViewportCoords( - { - sceneX: user.pointer.x, - sceneY: user.pointer.y, - }, - props.appState, + remotePointerViewportCoords.set( + socketId, + sceneCoordsToViewportCoords( + { + sceneX: user.pointer.x, + sceneY: user.pointer.y, + }, + props.appState, + ), ); - cursorButton[socketId] = user.button; + remotePointerButton.set(socketId, user.button); }); const selectionColor = @@ -120,11 +124,11 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { scale: window.devicePixelRatio, appState: props.appState, renderConfig: { - remotePointerViewportCoords: pointerViewportCoords, - remotePointerButton: cursorButton, + remotePointerViewportCoords, + remotePointerButton, remoteSelectedElementIds, - remotePointerUsernames: pointerUsernames, - remotePointerUserStates: pointerUserStates, + remotePointerUsernames, + remotePointerUserStates, selectionColor, renderScrollbars: false, }, diff --git a/packages/excalidraw/components/icons.tsx b/packages/excalidraw/components/icons.tsx index 967ae1976..063253f69 100644 --- a/packages/excalidraw/components/icons.tsx +++ b/packages/excalidraw/components/icons.tsx @@ -1798,7 +1798,7 @@ export const fullscreenIcon = createIcon( ); export const eyeIcon = createIcon( - + @@ -1837,3 +1837,26 @@ export const searchIcon = createIcon( , tablerIconProps, ); + +export const microphoneIcon = createIcon( + + + + + + + , + tablerIconProps, +); + +export const microphoneMutedIcon = createIcon( + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index 09e497564..ad87cb9e1 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -20,6 +20,9 @@ export const isIOS = export const isBrave = () => (navigator as any).brave?.isBrave?.name === "isBrave"; +export const supportsResizeObserver = + typeof window !== "undefined" && "ResizeObserver" in window; + export const APP_NAME = "Excalidraw"; export const DRAGGING_THRESHOLD = 10; // px @@ -144,6 +147,11 @@ export const DEFAULT_VERTICAL_ALIGN = "top"; export const DEFAULT_VERSION = "{version}"; export const DEFAULT_TRANSFORM_HANDLE_SPACING = 2; +export const COLOR_WHITE = "#ffffff"; +export const COLOR_CHARCOAL_BLACK = "#1e1e1e"; +// keep this in sync with CSS +export const COLOR_VOICE_CALL = "#a2f1a6"; + export const CANVAS_ONLY_ACTIONS = ["selectAll"]; export const GRID_SIZE = 20; // TODO make it configurable? diff --git a/packages/excalidraw/css/variables.module.scss b/packages/excalidraw/css/variables.module.scss index 247e3f840..71097ba3e 100644 --- a/packages/excalidraw/css/variables.module.scss +++ b/packages/excalidraw/css/variables.module.scss @@ -116,8 +116,8 @@ } @mixin avatarStyles { - width: 1.25rem; - height: 1.25rem; + width: var(--avatar-size, 1.5rem); + height: var(--avatar-size, 1.5rem); position: relative; border-radius: 100%; outline-offset: 2px; @@ -131,6 +131,10 @@ color: var(--color-gray-90); flex: 0 0 auto; + &:active { + transform: scale(0.94); + } + &-img { width: 100%; height: 100%; @@ -144,14 +148,14 @@ right: -3px; bottom: -3px; left: -3px; - border: 1px solid var(--avatar-border-color); border-radius: 100%; } - &--is-followed::before { + &.is-followed::before { border-color: var(--color-primary-hover); + box-shadow: 0 0 0 1px var(--color-primary-hover); } - &--is-current-user { + &.is-current-user { cursor: auto; } } diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index 49a0de5be..a58efddef 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -84,7 +84,7 @@ export class LaserTrails implements Trail { if (!this.collabTrails.has(key)) { trail = new AnimatedTrail(this.animationFrameHandler, this.app, { ...this.getTrailOptions(), - fill: () => getClientColor(key), + fill: () => getClientColor(key, collabolator), }); trail.start(this.container); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index ac9108a32..1213bc318 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -534,7 +534,10 @@ }, "hint": { "text": "Click on user to follow", - "followStatus": "You're currently following this user" + "followStatus": "You're currently following this user", + "inCall": "User is in a voice call", + "micMuted": "User's microphone is muted", + "isSpeaking": "User is speaking" } } } diff --git a/packages/excalidraw/renderer/interactiveScene.ts b/packages/excalidraw/renderer/interactiveScene.ts index a6d997770..0fd814e89 100644 --- a/packages/excalidraw/renderer/interactiveScene.ts +++ b/packages/excalidraw/renderer/interactiveScene.ts @@ -15,7 +15,7 @@ import { } from "../scene/scrollbars"; import { renderSelectionElement } from "../renderer/renderElement"; -import { getClientColor } from "../clients"; +import { getClientColor, renderRemoteCursors } from "../clients"; import { isSelectedViaGroup, getSelectedGroupIds, @@ -29,7 +29,7 @@ import { TransformHandleType, } from "../element/transformHandles"; import { arrayToMap, throttleRAF } from "../utils"; -import { InteractiveCanvasAppState, Point, UserIdleState } from "../types"; +import { InteractiveCanvasAppState, Point } from "../types"; import { DEFAULT_TRANSFORM_HANDLE_SPACING, FRAME_STYLE } from "../constants"; import { renderSnaps } from "../renderer/renderSnaps"; @@ -726,14 +726,18 @@ const _renderInteractiveScene = ({ selectionColors.push(selectionColor); } // remote users - if (renderConfig.remoteSelectedElementIds[element.id]) { + const remoteClients = renderConfig.remoteSelectedElementIds.get( + element.id, + ); + if (remoteClients) { selectionColors.push( - ...renderConfig.remoteSelectedElementIds[element.id].map( - (socketId: string) => { - const background = getClientColor(socketId); - return background; - }, - ), + ...remoteClients.map((socketId) => { + const background = getClientColor( + socketId, + appState.collaborators.get(socketId), + ); + return background; + }), ); } @@ -747,7 +751,7 @@ const _renderInteractiveScene = ({ elementX2, elementY2, selectionColors, - dashed: !!renderConfig.remoteSelectedElementIds[element.id], + dashed: !!remoteClients, cx, cy, activeEmbeddable: @@ -858,143 +862,13 @@ const _renderInteractiveScene = ({ // Reset zoom context.restore(); - // Paint remote pointers - for (const clientId in renderConfig.remotePointerViewportCoords) { - let { x, y } = renderConfig.remotePointerViewportCoords[clientId]; - - x -= appState.offsetLeft; - y -= appState.offsetTop; - - const width = 11; - const height = 14; - - const isOutOfBounds = - x < 0 || - x > normalizedWidth - width || - y < 0 || - y > normalizedHeight - height; - - x = Math.max(x, 0); - x = Math.min(x, normalizedWidth - width); - y = Math.max(y, 0); - y = Math.min(y, normalizedHeight - height); - - const background = getClientColor(clientId); - - context.save(); - context.strokeStyle = background; - context.fillStyle = background; - - const userState = renderConfig.remotePointerUserStates[clientId]; - const isInactive = - isOutOfBounds || - userState === UserIdleState.IDLE || - userState === UserIdleState.AWAY; - - if (isInactive) { - context.globalAlpha = 0.3; - } - - if ( - renderConfig.remotePointerButton && - renderConfig.remotePointerButton[clientId] === "down" - ) { - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 3; - context.strokeStyle = "#ffffff88"; - context.stroke(); - context.closePath(); - - context.beginPath(); - context.arc(x, y, 15, 0, 2 * Math.PI, false); - context.lineWidth = 1; - context.strokeStyle = background; - context.stroke(); - context.closePath(); - } - - // Background (white outline) for arrow - context.fillStyle = oc.white; - context.strokeStyle = oc.white; - context.lineWidth = 6; - context.lineJoin = "round"; - context.beginPath(); - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.stroke(); - context.fill(); - - // Arrow - context.fillStyle = background; - context.strokeStyle = background; - context.lineWidth = 2; - context.lineJoin = "round"; - context.beginPath(); - if (isInactive) { - context.moveTo(x - 1, y - 1); - context.lineTo(x - 1, y + 15); - context.lineTo(x + 5, y + 10); - context.lineTo(x + 12, y + 9); - context.closePath(); - context.fill(); - } else { - context.moveTo(x, y); - context.lineTo(x + 0, y + 14); - context.lineTo(x + 4, y + 9); - context.lineTo(x + 11, y + 8); - context.closePath(); - context.fill(); - context.stroke(); - } - - const username = renderConfig.remotePointerUsernames[clientId] || ""; - - if (!isOutOfBounds && username) { - context.font = "600 12px sans-serif"; // font has to be set before context.measureText() - - const offsetX = x + width / 2; - const offsetY = y + height + 2; - const paddingHorizontal = 5; - const paddingVertical = 3; - const measure = context.measureText(username); - const measureHeight = - measure.actualBoundingBoxDescent + measure.actualBoundingBoxAscent; - const finalHeight = Math.max(measureHeight, 12); - - const boxX = offsetX - 1; - const boxY = offsetY - 1; - const boxWidth = measure.width + 2 + paddingHorizontal * 2 + 2; - const boxHeight = finalHeight + 2 + paddingVertical * 2 + 2; - if (context.roundRect) { - context.beginPath(); - context.roundRect(boxX, boxY, boxWidth, boxHeight, 8); - context.fillStyle = background; - context.fill(); - context.strokeStyle = oc.white; - context.stroke(); - } else { - roundRect(context, boxX, boxY, boxWidth, boxHeight, 8, oc.white); - } - context.fillStyle = oc.black; - - context.fillText( - username, - offsetX + paddingHorizontal + 1, - offsetY + - paddingVertical + - measure.actualBoundingBoxAscent + - Math.floor((finalHeight - measureHeight) / 2) + - 2, - ); - } - - context.restore(); - context.closePath(); - } + renderRemoteCursors({ + context, + renderConfig, + appState, + normalizedWidth, + normalizedHeight, + }); // Paint scrollbars let scrollBars; diff --git a/packages/excalidraw/scene/types.ts b/packages/excalidraw/scene/types.ts index 02aa3b7bf..63a49fec5 100644 --- a/packages/excalidraw/scene/types.ts +++ b/packages/excalidraw/scene/types.ts @@ -1,6 +1,7 @@ import type { RoughCanvas } from "roughjs/bin/canvas"; import { Drawable } from "roughjs/bin/core"; import { + ExcalidrawElement, ExcalidrawTextElement, NonDeletedElementsMap, NonDeletedExcalidrawElement, @@ -13,6 +14,8 @@ import { ElementsPendingErasure, InteractiveCanvasAppState, StaticCanvasAppState, + SocketId, + UserIdleState, } from "../types"; import { MakeBrand } from "../utility-types"; @@ -46,11 +49,11 @@ export type SVGRenderConfig = { export type InteractiveCanvasRenderConfig = { // collab-related state // --------------------------------------------------------------------------- - remoteSelectedElementIds: { [elementId: string]: string[] }; - remotePointerViewportCoords: { [id: string]: { x: number; y: number } }; - remotePointerUserStates: { [id: string]: string }; - remotePointerUsernames: { [id: string]: string }; - remotePointerButton?: { [id: string]: string | undefined }; + remoteSelectedElementIds: Map; + remotePointerViewportCoords: Map; + remotePointerUserStates: Map; + remotePointerUsernames: Map; + remotePointerButton: Map; selectionColor?: string; // extra options passed to the renderer // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index fefb82c2c..2729bc037 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -61,6 +61,9 @@ export type Collaborator = Readonly<{ id?: string; socketId?: SocketId; isCurrentUser?: boolean; + isInCall?: boolean; + isSpeaking?: boolean; + isMuted?: boolean; }>; export type CollaboratorPointer = { @@ -319,9 +322,9 @@ export interface AppState { y: number; } | null; objectsSnapModeEnabled: boolean; - /** the user's clientId & username who is being followed on the canvas */ + /** the user's socket id & username who is being followed on the canvas */ userToFollow: UserToFollow | null; - /** the clientIds of the users following the current user */ + /** the socket ids of the users following the current user */ followedBy: Set; } diff --git a/packages/excalidraw/utils.ts b/packages/excalidraw/utils.ts index d27445dfa..493dce340 100644 --- a/packages/excalidraw/utils.ts +++ b/packages/excalidraw/utils.ts @@ -791,6 +791,14 @@ export const isShallowEqual = < const aKeys = Object.keys(objA); const bKeys = Object.keys(objB); if (aKeys.length !== bKeys.length) { + if (debug) { + console.warn( + `%cisShallowEqual: objects don't have same properties ->`, + "color: #8B4000", + objA, + objB, + ); + } return false; } From 15bfa626b41c3981aed557666f67be3453243b29 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Mon, 18 Mar 2024 10:41:06 +0100 Subject: [PATCH 25/66] feat: support to not render remote cursor & username (#7130) --- .../components/canvases/InteractiveCanvas.tsx | 2 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/index.tsx | 8 +++++- packages/excalidraw/laser-trails.ts | 25 +++++++++++-------- packages/excalidraw/types.ts | 13 ++++++++++ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx index 163756d57..e5cd60f62 100644 --- a/packages/excalidraw/components/canvases/InteractiveCanvas.tsx +++ b/packages/excalidraw/components/canvases/InteractiveCanvas.tsx @@ -86,7 +86,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => { remoteSelectedElementIds.get(id)!.push(socketId); } } - if (!user.pointer) { + if (!user.pointer || user.pointer.renderCursor === false) { return; } if (user.username) { diff --git a/packages/excalidraw/constants.ts b/packages/excalidraw/constants.ts index ad87cb9e1..29659f86a 100644 --- a/packages/excalidraw/constants.ts +++ b/packages/excalidraw/constants.ts @@ -31,6 +31,7 @@ export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5; export const ELEMENT_TRANSLATE_AMOUNT = 1; export const TEXT_TO_CENTER_SNAP_THRESHOLD = 30; export const SHIFT_LOCKING_ANGLE = Math.PI / 12; +export const DEFAULT_LASER_COLOR = "red"; export const CURSOR_TYPE = { TEXT: "text", CROSSHAIR: "crosshair", diff --git a/packages/excalidraw/index.tsx b/packages/excalidraw/index.tsx index 66eb91044..e1dc29e66 100644 --- a/packages/excalidraw/index.tsx +++ b/packages/excalidraw/index.tsx @@ -237,7 +237,13 @@ export { getFreeDrawSvgPath } from "./renderer/renderElement"; export { mergeLibraryItems, getLibraryItemsHash } from "./data/library"; export { isLinearElement } from "./element/typeChecks"; -export { FONT_FAMILY, THEME, MIME_TYPES, ROUNDNESS } from "./constants"; +export { + FONT_FAMILY, + THEME, + MIME_TYPES, + ROUNDNESS, + DEFAULT_LASER_COLOR, +} from "./constants"; export { mutateElement, diff --git a/packages/excalidraw/laser-trails.ts b/packages/excalidraw/laser-trails.ts index a58efddef..e2ef258b0 100644 --- a/packages/excalidraw/laser-trails.ts +++ b/packages/excalidraw/laser-trails.ts @@ -5,6 +5,7 @@ import type App from "./components/App"; import { SocketId } from "./types"; import { easeOut } from "./utils"; import { getClientColor } from "./clients"; +import { DEFAULT_LASER_COLOR } from "./constants"; export class LaserTrails implements Trail { public localTrail: AnimatedTrail; @@ -20,7 +21,7 @@ export class LaserTrails implements Trail { this.localTrail = new AnimatedTrail(animationFrameHandler, app, { ...this.getTrailOptions(), - fill: () => "red", + fill: () => DEFAULT_LASER_COLOR, }); } @@ -78,13 +79,15 @@ export class LaserTrails implements Trail { return; } - for (const [key, collabolator] of this.app.state.collaborators.entries()) { + for (const [key, collaborator] of this.app.state.collaborators.entries()) { let trail!: AnimatedTrail; if (!this.collabTrails.has(key)) { trail = new AnimatedTrail(this.animationFrameHandler, this.app, { ...this.getTrailOptions(), - fill: () => getClientColor(key, collabolator), + fill: () => + collaborator.pointer?.laserColor || + getClientColor(key, collaborator), }); trail.start(this.container); @@ -93,21 +96,21 @@ export class LaserTrails implements Trail { trail = this.collabTrails.get(key)!; } - if (collabolator.pointer && collabolator.pointer.tool === "laser") { - if (collabolator.button === "down" && !trail.hasCurrentTrail) { - trail.startPath(collabolator.pointer.x, collabolator.pointer.y); + if (collaborator.pointer && collaborator.pointer.tool === "laser") { + if (collaborator.button === "down" && !trail.hasCurrentTrail) { + trail.startPath(collaborator.pointer.x, collaborator.pointer.y); } if ( - collabolator.button === "down" && + collaborator.button === "down" && trail.hasCurrentTrail && - !trail.hasLastPoint(collabolator.pointer.x, collabolator.pointer.y) + !trail.hasLastPoint(collaborator.pointer.x, collaborator.pointer.y) ) { - trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); } - if (collabolator.button === "up" && trail.hasCurrentTrail) { - trail.addPointToPath(collabolator.pointer.x, collabolator.pointer.y); + if (collaborator.button === "up" && trail.hasCurrentTrail) { + trail.addPointToPath(collaborator.pointer.x, collaborator.pointer.y); trail.endPath(); } } diff --git a/packages/excalidraw/types.ts b/packages/excalidraw/types.ts index 2729bc037..d7f701ff8 100644 --- a/packages/excalidraw/types.ts +++ b/packages/excalidraw/types.ts @@ -70,6 +70,19 @@ export type CollaboratorPointer = { x: number; y: number; tool: "pointer" | "laser"; + /** + * Whether to render cursor + username. Useful when you only want to render + * laser trail. + * + * @default true + */ + renderCursor?: boolean; + /** + * Explicit laser color. + * + * @default string collaborator's cursor color + */ + laserColor?: string; }; export type DataURL = string & { _brand: "DataURL" }; From 7949aa1f1c0010866206ac4e7109139e2589aeb3 Mon Sep 17 00:00:00 2001 From: Aakansha Doshi Date: Thu, 28 Mar 2024 16:44:29 +0530 Subject: [PATCH 26/66] feat: upgrade mermaid-to-excalidraw to 0.3.0 (#7819) --- packages/excalidraw/package.json | 2 +- yarn.lock | 132 ++++++++++++++++++++----------- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/packages/excalidraw/package.json b/packages/excalidraw/package.json index 5e5c52b21..0b12d46fa 100644 --- a/packages/excalidraw/package.json +++ b/packages/excalidraw/package.json @@ -58,7 +58,7 @@ "dependencies": { "@braintree/sanitize-url": "6.0.2", "@excalidraw/laser-pointer": "1.3.1", - "@excalidraw/mermaid-to-excalidraw": "0.2.0", + "@excalidraw/mermaid-to-excalidraw": "0.3.0", "@excalidraw/random-username": "1.1.0", "@radix-ui/react-popover": "1.0.3", "@radix-ui/react-tabs": "1.0.2", diff --git a/yarn.lock b/yarn.lock index 61def89e7..e9dc642c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1980,7 +1980,7 @@ resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.2.tgz#6110f918d273fe2af8ea1c4398a88774bb9fc12f" integrity sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg== -"@braintree/sanitize-url@^6.0.2": +"@braintree/sanitize-url@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz#923ca57e173c6b232bbbb07347b1be982f03e783" integrity sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A== @@ -2147,13 +2147,13 @@ resolved "https://registry.yarnpkg.com/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb" integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg== -"@excalidraw/mermaid-to-excalidraw@0.2.0": - version "0.2.0" - resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.2.0.tgz#1e0395cd2b1ce9f6898109f5dbf545f558f159cc" - integrity sha512-FR+Lw9dt+mQxsrmRL7YNU2wrlNXD16ZLyuNoKrPzPy+Ds3utzY1+/2UNeNu7FMSUO4hKdkrmyO+PDp9OvOhuKw== +"@excalidraw/mermaid-to-excalidraw@0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.3.0.tgz#94c438133fc66db6b920e237abda5152b62e6cb0" + integrity sha512-eyFN8y2ES3HFtETZWZZBakkSB5ROfnHJeCLeBlMgrIk1fxbXpPtxlu2VwGNpqPjDiCfV5FYnx7FaZ4CRiVRVMg== dependencies: "@excalidraw/markdown-to-text" "0.1.2" - mermaid "10.2.3" + mermaid "10.9.0" nanoid "4.0.2" "@excalidraw/prettier-config@1.0.2": @@ -3295,6 +3295,23 @@ resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.0.tgz#23509ebc1fa32f1b4d50d6a66c4032d5b8eaabdc" integrity sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw== +"@types/d3-scale-chromatic@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz#fc0db9c10e789c351f4c42d96f31f2e4df8f5644" + integrity sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw== + +"@types/d3-scale@^4.0.3": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" + integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-time@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" + integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw== + "@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" @@ -4986,13 +5003,6 @@ cose-base@^1.0.0: dependencies: layout-base "^1.0.0" -cose-base@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-2.2.0.tgz#1c395c35b6e10bb83f9769ca8b817d614add5c01" - integrity sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g== - dependencies: - layout-base "^2.0.0" - cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz#1443b9afa596b670082ea46cbd8f6a62b84635f6" @@ -5094,21 +5104,21 @@ cytoscape-cose-bilkent@^4.1.0: dependencies: cose-base "^1.0.0" -cytoscape-fcose@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cytoscape-fcose/-/cytoscape-fcose-2.2.0.tgz#e4d6f6490df4fab58ae9cea9e5c3ab8d7472f471" - integrity sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ== - dependencies: - cose-base "^2.2.0" - -cytoscape@^3.23.0: - version "3.27.0" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.27.0.tgz#5141cd694570807c91075b609181bce102e0bb88" - integrity sha512-pPZJilfX9BxESwujODz5pydeGi+FBrXq1rcaB1mfhFXXFJ9GjE6CNndAk+8jPzoXGD+16LtSS4xlYEIUiW4Abg== +cytoscape@^3.28.1: + version "3.28.1" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.28.1.tgz#f32c3e009bdf32d47845a16a4cd2be2bbc01baf7" + integrity sha512-xyItz4O/4zp9/239wCcH8ZcFuuZooEeF8KHRmzjDfGdXsj3OG9MFSMA0pJE0uX3uCN/ygof6hHf4L7lst+JaDg== dependencies: heap "^0.2.6" lodash "^4.17.21" +"d3-array@1 - 2": + version "2.12.1" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" + integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== + dependencies: + internmap "^1.0.0" + "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: version "3.2.4" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" @@ -5225,6 +5235,11 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" +d3-path@1: + version "1.0.9" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" + integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== + "d3-path@1 - 3", d3-path@3, d3-path@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" @@ -5245,6 +5260,14 @@ d3-random@3: resolved "https://registry.yarnpkg.com/d3-random/-/d3-random-3.0.1.tgz#d4926378d333d9c0bfd1e6fa0194d30aebaa20f4" integrity sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ== +d3-sankey@^0.12.3: + version "0.12.3" + resolved "https://registry.yarnpkg.com/d3-sankey/-/d3-sankey-0.12.3.tgz#b3c268627bd72e5d80336e8de6acbfec9d15d01d" + integrity sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ== + dependencies: + d3-array "1 - 2" + d3-shape "^1.2.0" + d3-scale-chromatic@3: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz#15b4ceb8ca2bb0dcb6d1a641ee03d59c3b62376a" @@ -5276,6 +5299,13 @@ d3-shape@3: dependencies: d3-path "^3.1.0" +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + "d3-time-format@2 - 4", d3-time-format@4: version "4.1.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" @@ -5565,10 +5595,10 @@ domexception@^4.0.0: dependencies: webidl-conversions "^7.0.0" -dompurify@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.3.tgz#4b115d15a091ddc96f232bcef668550a2f6f1430" - integrity sha512-axQ9zieHLnAnHh0sfAamKYiqXMJAVwu+LM/alQ7WDagoWessyWvMSFyW65CqF3owufNu8HBcE4cM2Vflu7YWcQ== +dompurify@^3.0.5: + version "3.0.11" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.0.11.tgz#c163f5816eaac6aeef35dae2b77fca0504564efe" + integrity sha512-Fan4uMuyB26gFV3ovPoEoQbxRRPfTu3CvImyZnhGq5fsIEO+gEFLp45ISFt+kQBWsK5ulDdT0oV28jS1UrwQLg== dotenv@16.0.1: version "16.0.1" @@ -5602,10 +5632,10 @@ electron-to-chromium@^1.4.601: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.608.tgz#ff567c51dde4892ae330860c7d9f19571e9e1d69" integrity sha512-J2f/3iIIm3Mo0npneITZ2UPe4B1bg8fTNrFjD8715F/k1BvbviRuqYGkET1PgprrczXYTHFvotbBOmUp6KE0uA== -elkjs@^0.8.2: - version "0.8.2" - resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.8.2.tgz#c37763c5a3e24e042e318455e0147c912a7c248e" - integrity sha512-L6uRgvZTH+4OF5NE/MBbzQx/WYpru1xCBE9respNj6qznEewGUIfhzmm7horWWxbNO2M0WckQypGctR8lH79xQ== +elkjs@^0.9.0: + version "0.9.2" + resolved "https://registry.yarnpkg.com/elkjs/-/elkjs-0.9.2.tgz#3d4ef6f17fde06a5d7eaa3063bb875e25e59e972" + integrity sha512-2Y/RaA1pdgSHpY0YG4TYuYCD2wh97CRvu22eLG3Kz0pgQ/6KbIFTxsTnDc4MH/6hFlg2L/9qXrDMG0nMjP63iw== emoji-regex@^8.0.0: version "8.0.0" @@ -6871,6 +6901,11 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +internmap@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" + integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== + interpret@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9" @@ -7381,6 +7416,13 @@ jsonpointer@^5.0.0: array-includes "^3.1.5" object.assign "^4.1.3" +katex@^0.16.9: + version "0.16.10" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.10.tgz#6f81b71ac37ff4ec7556861160f53bc5f058b185" + integrity sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA== + dependencies: + commander "^8.3.0" + khroma@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1" @@ -7418,11 +7460,6 @@ layout-base@^1.0.0: resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-1.0.2.tgz#1291e296883c322a9dd4c5dd82063721b53e26e2" integrity sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg== -layout-base@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/layout-base/-/layout-base-2.0.1.tgz#d0337913586c90f9c2c075292069f5c2da5dd285" - integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== - leven@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" @@ -7710,20 +7747,23 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -mermaid@10.2.3: - version "10.2.3" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.2.3.tgz#789d3b582c5da8c69aa4a7c0e2b826562c8c8b12" - integrity sha512-cMVE5s9PlQvOwfORkyVpr5beMsLdInrycAosdr+tpZ0WFjG4RJ/bUHST7aTgHNJbujHkdBRAm+N50P3puQOfPw== +mermaid@10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-10.9.0.tgz#4d1272fbe434bd8f3c2c150554dc8a23a9bf9361" + integrity sha512-swZju0hFox/B/qoLKK0rOxxgh8Cf7rJSfAUc1u8fezVihYMvrJAS45GzAxTVf4Q+xn9uMgitBcmWk7nWGXOs/g== dependencies: - "@braintree/sanitize-url" "^6.0.2" - cytoscape "^3.23.0" + "@braintree/sanitize-url" "^6.0.1" + "@types/d3-scale" "^4.0.3" + "@types/d3-scale-chromatic" "^3.0.0" + cytoscape "^3.28.1" cytoscape-cose-bilkent "^4.1.0" - cytoscape-fcose "^2.1.0" d3 "^7.4.0" + d3-sankey "^0.12.3" dagre-d3-es "7.0.10" dayjs "^1.11.7" - dompurify "3.0.3" - elkjs "^0.8.2" + dompurify "^3.0.5" + elkjs "^0.9.0" + katex "^0.16.9" khroma "^2.0.0" lodash-es "^4.17.21" mdast-util-from-markdown "^1.3.0" From 65bc500598b70be00c8d770f49928ff66f77470b Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:52:23 +0100 Subject: [PATCH 27/66] fix: `excalidrawAPI.toggleSidebar` not switching between tabs correctly (#7821) --- packages/excalidraw/components/App.tsx | 18 +++- .../components/Sidebar/Sidebar.test.tsx | 82 ++++++++++++++++++- .../components/Sidebar/SidebarTab.tsx | 2 +- 3 files changed, 97 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b02d919d4..b920a1037 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -3684,17 +3684,29 @@ class App extends React.Component { tab, force, }: { - name: SidebarName; + name: SidebarName | null; tab?: SidebarTabName; force?: boolean; }): boolean => { let nextName; if (force === undefined) { - nextName = this.state.openSidebar?.name === name ? null : name; + nextName = + this.state.openSidebar?.name === name && + this.state.openSidebar?.tab === tab + ? null + : name; } else { nextName = force ? name : null; } - this.setState({ openSidebar: nextName ? { name: nextName, tab } : null }); + + const nextState: AppState["openSidebar"] = nextName + ? { name: nextName } + : null; + if (nextState && tab) { + nextState.tab = tab; + } + + this.setState({ openSidebar: nextState }); return !!nextName; }; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx index 9787f9a73..6b60418b5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.test.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.test.tsx @@ -85,7 +85,7 @@ describe("Sidebar", () => { }); }); - it("should toggle sidebar using props.toggleMenu()", async () => { + it("should toggle sidebar using excalidrawAPI.toggleSidebar()", async () => { const { container } = await render( @@ -158,6 +158,20 @@ describe("Sidebar", () => { const sidebars = container.querySelectorAll(".sidebar"); expect(sidebars.length).toBe(1); }); + + // closing sidebar using `{ name: null }` + // ------------------------------------------------------------------------- + expect(window.h.app.toggleSidebar({ name: "customSidebar" })).toBe(true); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).not.toBe(null); + }); + + expect(window.h.app.toggleSidebar({ name: null })).toBe(false); + await waitFor(() => { + const node = container.querySelector("#test-sidebar-content"); + expect(node).toBe(null); + }); }); }); @@ -329,4 +343,70 @@ describe("Sidebar", () => { ); }); }); + + describe("Sidebar.tab", () => { + it("should toggle sidebars tabs correctly", async () => { + const { container } = await render( + + + + Library + Comments + + + , + ); + + await withExcalidrawDimensions( + { width: 1920, height: 1080 }, + async () => { + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).toBeNull(); + + // open library sidebar + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "library" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=library]", + ), + ).not.toBeNull(); + + // switch to comments tab + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + + // toggle sidebar closed + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(false); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).toBeNull(); + + // toggle sidebar open + expect( + window.h.app.toggleSidebar({ name: "custom", tab: "comments" }), + ).toBe(true); + expect( + container.querySelector( + "[role=tabpanel][data-testid=comments]", + ), + ).not.toBeNull(); + }, + ); + }); + }); }); diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 741a69fd1..f7eacc1b1 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -10,7 +10,7 @@ export const SidebarTab = ({ children: React.ReactNode; } & React.HTMLAttributes) => { return ( - + {children} ); From 6b523563d804d48be260399fe45e30c394e07065 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Thu, 28 Mar 2024 14:58:47 +0100 Subject: [PATCH 28/66] fix: ejs support in html files (#7822) --- excalidraw-app/index.html | 8 +- excalidraw-app/package.json | 4 +- excalidraw-app/vite.config.mts | 4 + yarn.lock | 243 +++++++++++++++++++++++++++++++-- 4 files changed, 240 insertions(+), 19 deletions(-) diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 66f3afdab..2e1fa1adb 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -78,7 +78,7 @@ } - <% if ("%PROD%" === "true") { %> + <% if (typeof PROD != 'undefined' && PROD == true) { %> - <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %> + <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && + VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %> - <% if (typeof VITE_APP_DEV_DISABLE_LIVE_RELOAD != 'undefined' && - VITE_APP_DEV_DISABLE_LIVE_RELOAD != true) { %> - <% } %> From cd50aa719fa5dcb77beb9f736725fa744e5ba6ba Mon Sep 17 00:00:00 2001 From: Arnost Pleskot Date: Mon, 8 Apr 2024 16:46:24 +0200 Subject: [PATCH 35/66] feat: add system mode to the theme selector (#7853) Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/App.tsx | 37 ++++------ excalidraw-app/components/AppMainMenu.tsx | 9 ++- excalidraw-app/index.html | 28 ++++++-- excalidraw-app/useHandleAppTheme.ts | 70 +++++++++++++++++++ packages/excalidraw/CHANGELOG.md | 1 + packages/excalidraw/actions/actionCanvas.tsx | 4 +- packages/excalidraw/components/App.tsx | 6 +- .../excalidraw/components/DarkModeToggle.tsx | 4 +- packages/excalidraw/components/RadioGroup.tsx | 7 +- .../components/dropdownMenu/DropdownMenu.scss | 22 ++++++ .../DropdownMenuItemContentRadio.tsx | 51 ++++++++++++++ packages/excalidraw/components/icons.tsx | 17 +++-- .../components/main-menu/DefaultItems.tsx | 64 +++++++++++++++-- packages/excalidraw/data/magic.ts | 3 +- .../hooks/useCreatePortalContainer.ts | 3 +- packages/excalidraw/locales/en.json | 2 + packages/excalidraw/renderer/helpers.ts | 4 +- packages/excalidraw/renderer/renderElement.ts | 5 +- packages/excalidraw/renderer/renderSnaps.ts | 3 +- packages/excalidraw/scene/export.ts | 3 +- setupTests.ts | 14 ++++ 21 files changed, 301 insertions(+), 56 deletions(-) create mode 100644 excalidraw-app/useHandleAppTheme.ts create mode 100644 packages/excalidraw/components/dropdownMenu/DropdownMenuItemContentRadio.tsx diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 1bf126924..56033ec15 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -17,7 +17,6 @@ import { FileId, NonDeletedExcalidrawElement, OrderedExcalidrawElement, - Theme, } from "../packages/excalidraw/element/types"; import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState"; import { t } from "../packages/excalidraw/i18n"; @@ -124,6 +123,7 @@ import { exportToPlus, share, } from "../packages/excalidraw/components/icons"; +import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme"; polyfill(); @@ -303,6 +303,9 @@ const ExcalidrawWrapper = () => { const [langCode, setLangCode] = useAtom(appLangCodeAtom); const isCollabDisabled = isRunningInIframe(); + const [appTheme, setAppTheme] = useAtom(appThemeAtom); + const { editorTheme } = useHandleAppTheme(); + // initial state // --------------------------------------------------------------------------- @@ -566,23 +569,6 @@ const ExcalidrawWrapper = () => { languageDetector.cacheUserLanguage(langCode); }, [langCode]); - const [theme, setTheme] = useState( - () => - (localStorage.getItem( - STORAGE_KEYS.LOCAL_STORAGE_THEME, - ) as Theme | null) || - // FIXME migration from old LS scheme. Can be removed later. #5660 - importFromLocalStorage().appState?.theme || - THEME.LIGHT, - ); - - useEffect(() => { - localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme); - // currently only used for body styling during init (see public/index.html), - // but may change in the future - document.documentElement.classList.toggle("dark", theme === THEME.DARK); - }, [theme]); - const onChange = ( elements: readonly OrderedExcalidrawElement[], appState: AppState, @@ -592,8 +578,6 @@ const ExcalidrawWrapper = () => { collabAPI.syncElements(elements); } - setTheme(appState.theme); - // this check is redundant, but since this is a hot path, it's best // not to evaludate the nested expression every time if (!LocalData.isSavePaused()) { @@ -798,7 +782,7 @@ const ExcalidrawWrapper = () => { detectScroll={false} handleKeyboardGlobally={true} autoFocus={true} - theme={theme} + theme={editorTheme} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; @@ -820,6 +804,8 @@ const ExcalidrawWrapper = () => { onCollabDialogOpen={onCollabDialogOpen} isCollaborating={isCollaborating} isCollabEnabled={!isCollabDisabled} + theme={appTheme} + setTheme={(theme) => setAppTheme(theme)} /> { } }, }, - CommandPalette.defaultItems.toggleTheme, + { + ...CommandPalette.defaultItems.toggleTheme, + perform: () => { + setAppTheme( + editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK, + ); + }, + }, ]} /> diff --git a/excalidraw-app/components/AppMainMenu.tsx b/excalidraw-app/components/AppMainMenu.tsx index 813d620c8..fe3f36c9e 100644 --- a/excalidraw-app/components/AppMainMenu.tsx +++ b/excalidraw-app/components/AppMainMenu.tsx @@ -1,5 +1,6 @@ import React from "react"; import { PlusPromoIcon } from "../../packages/excalidraw/components/icons"; +import { Theme } from "../../packages/excalidraw/element/types"; import { MainMenu } from "../../packages/excalidraw/index"; import { LanguageList } from "./LanguageList"; @@ -7,6 +8,8 @@ export const AppMainMenu: React.FC<{ onCollabDialogOpen: () => any; isCollaborating: boolean; isCollabEnabled: boolean; + theme: Theme | "system"; + setTheme: (theme: Theme | "system") => void; }> = React.memo((props) => { return ( @@ -35,7 +38,11 @@ export const AppMainMenu: React.FC<{ - + diff --git a/excalidraw-app/index.html b/excalidraw-app/index.html index 4d0e2eaa5..db5bd6457 100644 --- a/excalidraw-app/index.html +++ b/excalidraw-app/index.html @@ -64,12 +64,30 @@ `), - intrinsicSize: { w: 550, h: 720 }, - }; - } + intrinsicSize: { w: 550, h: 720 }, + }; embeddedLinkCache.set(link, ret); return ret; } @@ -313,8 +290,8 @@ export const maybeParseEmbedSrc = (str: string): string => { } const gistMatch = str.match(RE_GH_GIST_EMBED); - if (gistMatch && gistMatch.length === 2) { - return gistMatch[1]; + if (gistMatch && gistMatch.length === 3) { + return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`; } if (RE_GIPHY.test(str)) { @@ -325,6 +302,7 @@ export const maybeParseEmbedSrc = (str: string): string => { if (match && match.length === 2) { return match[1]; } + return str; }; diff --git a/packages/excalidraw/element/types.ts b/packages/excalidraw/element/types.ts index 8d3a0d4ef..2ee9a12b0 100644 --- a/packages/excalidraw/element/types.ts +++ b/packages/excalidraw/element/types.ts @@ -111,6 +111,7 @@ export type IframeData = | { intrinsicSize: { w: number; h: number }; error?: Error; + sandbox?: { allowSameOrigin?: boolean }; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } From 4689a6b300d8756b54fdbaa7a3f1f8db60b9cc78 Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Fri, 12 Apr 2024 18:58:51 +0800 Subject: [PATCH 43/66] fix: hit test for closed sharp curves (#7881) --- packages/excalidraw/components/App.tsx | 1 + packages/utils/geometry/shape.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 322096d63..6a0fd1031 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -4383,6 +4383,7 @@ class App extends React.Component { return shouldTestInside(element) ? getClosedCurveShape( + element, roughShape, [element.x, element.y], element.angle, diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 1fbcd7935..87c0fe099 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -20,6 +20,7 @@ import { ExcalidrawFreeDrawElement, ExcalidrawIframeElement, ExcalidrawImageElement, + ExcalidrawLinearElement, ExcalidrawRectangleElement, ExcalidrawSelectionElement, ExcalidrawTextElement, @@ -233,12 +234,12 @@ export const getFreedrawShape = ( }; export const getClosedCurveShape = ( + element: ExcalidrawLinearElement, roughShape: Drawable, startingPoint: Point = [0, 0], angleInRadian: number, center: Point, ): GeometricShape => { - const ops = getCurvePathOps(roughShape); const transform = (p: Point) => pointRotate( [p[0] + startingPoint[0], p[1] + startingPoint[1]], @@ -246,6 +247,15 @@ export const getClosedCurveShape = ( center, ); + if (element.roundness === null) { + return { + type: "polygon", + data: close(element.points.map((p) => transform(p as Point))), + }; + } + + const ops = getCurvePathOps(roughShape); + const points: Point[] = []; let odd = false; for (const operation of ops) { From afcde542f9e991b4b671df191aa155e1ebee6006 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Fri, 12 Apr 2024 20:51:17 +0200 Subject: [PATCH 44/66] fix: parse embeddable srcdoc urls strictly (#7884) --- packages/excalidraw/data/url.ts | 6 ++++- packages/excalidraw/element/embeddable.ts | 30 ++++++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/packages/excalidraw/data/url.ts b/packages/excalidraw/data/url.ts index 2655c141d..dae576068 100644 --- a/packages/excalidraw/data/url.ts +++ b/packages/excalidraw/data/url.ts @@ -1,11 +1,15 @@ import { sanitizeUrl } from "@braintree/sanitize-url"; +export const sanitizeHTMLAttribute = (html: string) => { + return html.replace(/"/g, """); +}; + export const normalizeLink = (link: string) => { link = link.trim(); if (!link) { return link; } - return sanitizeUrl(link); + return sanitizeUrl(sanitizeHTMLAttribute(link)); }; export const isLocalLink = (link: string | null) => { diff --git a/packages/excalidraw/element/embeddable.ts b/packages/excalidraw/element/embeddable.ts index 40213aff8..8b55a5441 100644 --- a/packages/excalidraw/element/embeddable.ts +++ b/packages/excalidraw/element/embeddable.ts @@ -11,6 +11,7 @@ import { ExcalidrawIframeLikeElement, IframeData, } from "./types"; +import { sanitizeHTMLAttribute } from "../data/url"; const embeddedLinkCache = new Map(); @@ -21,12 +22,13 @@ const RE_VIMEO = /^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/; const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/; -const RE_GH_GIST = /^https:\/\/gist\.github\.com/; +const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/; const RE_GH_GIST_EMBED = - /https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i; + /^ twitter embeds -const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/; +const RE_TWITTER = + /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/; const RE_TWITTER_EMBED = /^ createSrcDoc( - ` `, + ` `, ), intrinsicSize: { w: 480, h: 480 }, sandbox: { allowSameOrigin: true }, @@ -167,11 +175,15 @@ export const getEmbedLink = ( } if (RE_GH_GIST.test(link)) { + const [, user, gistId] = link.match(RE_GH_GIST)!; + const safeURL = sanitizeHTMLAttribute( + `https://gist.github.com/${user}/${gistId}`, + ); const ret: IframeData = { type: "document", srcdoc: () => createSrcDoc(` - + `), intrinsicSize: { w: 550, h: 720 }, + sandbox: { allowSameOrigin }, }; embeddedLinkCache.set(link, ret); return ret; } - embeddedLinkCache.set(link, { link, intrinsicSize: aspectRatio, type }); - return { link, intrinsicSize: aspectRatio, type }; + embeddedLinkCache.set(link, { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }); + return { + link, + intrinsicSize: aspectRatio, + type, + sandbox: { allowSameOrigin }, + }; }; export const createPlaceholderEmbeddableLabel = ( @@ -265,34 +319,39 @@ export const actionSetEmbeddableAsActiveTool = register({ }, }); -const validateHostname = ( +const matchHostname = ( url: string, /** using a Set assumes it already contains normalized bare domains */ allowedHostnames: Set | string, -): boolean => { +): string | null => { try { const { hostname } = new URL(url); const bareDomain = hostname.replace(/^www\./, ""); - const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( - /^([^.]+)/, - "*", - ); if (allowedHostnames instanceof Set) { - return ( - ALLOWED_DOMAINS.has(bareDomain) || - ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded) + if (ALLOWED_DOMAINS.has(bareDomain)) { + return bareDomain; + } + + const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace( + /^([^.]+)/, + "*", ); + if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) { + return bareDomainWithFirstSubdomainWildcarded; + } + return null; } - if (bareDomain === allowedHostnames.replace(/^www\./, "")) { - return true; + const bareAllowedHostname = allowedHostnames.replace(/^www\./, ""); + if (bareDomain === bareAllowedHostname) { + return bareAllowedHostname; } } catch (error) { // ignore } - return false; + return null; }; export const maybeParseEmbedSrc = (str: string): string => { @@ -342,7 +401,7 @@ export const embeddableURLValidator = ( if (url.match(domain)) { return true; } - } else if (validateHostname(url, domain)) { + } else if (matchHostname(url, domain)) { return true; } } @@ -350,5 +409,5 @@ export const embeddableURLValidator = ( } } - return validateHostname(url, ALLOWED_DOMAINS); + return !!matchHostname(url, ALLOWED_DOMAINS); }; From 890ed9f31fd95f302ad4f7a9e0bb64b75bd93a7e Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 13 Apr 2024 19:12:29 +0200 Subject: [PATCH 47/66] feat: add "toggle grid" to command palette (#7887) --- .../actions/actionToggleGridMode.tsx | 5 ++- .../CommandPalette/CommandPalette.tsx | 1 + packages/excalidraw/components/HelpDialog.tsx | 2 +- packages/excalidraw/components/icons.tsx | 13 ++++++ packages/excalidraw/locales/en.json | 2 +- .../__snapshots__/contextmenu.test.tsx.snap | 45 ++++++++++++++++++- packages/excalidraw/tests/excalidraw.test.tsx | 2 +- 7 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 412da0119..46e1879d9 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -2,10 +2,13 @@ import { CODES, KEYS } from "../keys"; import { register } from "./register"; import { GRID_SIZE } from "../constants"; import { AppState } from "../types"; +import { gridIcon } from "../components/icons"; export const actionToggleGridMode = register({ name: "gridMode", - label: "labels.showGrid", + icon: gridIcon, + keywords: ["snap"], + label: "labels.toggleGrid", viewMode: true, trackEvent: { category: "canvas", diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index f021632b8..17480319f 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -309,6 +309,7 @@ function CommandPaletteInner({ actionManager.actions.zoomToFit, actionManager.actions.zenMode, actionManager.actions.viewMode, + actionManager.actions.gridMode, actionManager.actions.objectsSnapMode, actionManager.actions.toggleShortcuts, actionManager.actions.selectAll, diff --git a/packages/excalidraw/components/HelpDialog.tsx b/packages/excalidraw/components/HelpDialog.tsx index 23c9f8f47..c362889b3 100644 --- a/packages/excalidraw/components/HelpDialog.tsx +++ b/packages/excalidraw/components/HelpDialog.tsx @@ -273,7 +273,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { shortcuts={[getShortcutKey("Alt+S")]} /> , tablerIconProps, ); + +export const gridIcon = createIcon( + + + + + + + + + , + tablerIconProps, +); diff --git a/packages/excalidraw/locales/en.json b/packages/excalidraw/locales/en.json index a0a5a1958..fb5b148d3 100644 --- a/packages/excalidraw/locales/en.json +++ b/packages/excalidraw/locales/en.json @@ -87,7 +87,7 @@ "group": "Group selection", "ungroup": "Ungroup selection", "collaborators": "Collaborators", - "showGrid": "Show grid", + "toggleGrid": "Toggle grid", "addToLibrary": "Add to library", "removeFromLibrary": "Remove from library", "libraryLoadingMessage": "Loading library…", diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index 5723ebe60..579ce84d2 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -7870,8 +7870,51 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "separator", { "checked": [Function], + "icon": , "keyTest": [Function], - "label": "labels.showGrid", + "keywords": [ + "snap", + ], + "label": "labels.toggleGrid", "name": "gridMode", "perform": [Function], "predicate": [Function], diff --git a/packages/excalidraw/tests/excalidraw.test.tsx b/packages/excalidraw/tests/excalidraw.test.tsx index 98d0e9006..be7efc914 100644 --- a/packages/excalidraw/tests/excalidraw.test.tsx +++ b/packages/excalidraw/tests/excalidraw.test.tsx @@ -101,7 +101,7 @@ describe("", () => { clientY: 1, }); const contextMenu = document.querySelector(".context-menu"); - fireEvent.click(queryByText(contextMenu as HTMLElement, "Show grid")!); + fireEvent.click(queryByText(contextMenu as HTMLElement, "Toggle grid")!); expect(h.state.gridSize).toBe(GRID_SIZE); }); From f92f04c13c80375e5327204fec9a5388f785085d Mon Sep 17 00:00:00 2001 From: johnd99 <87199350+johnd99@users.noreply.github.com> Date: Mon, 15 Apr 2024 05:11:27 -0400 Subject: [PATCH 48/66] fix: Correct unit from 'eg' to 'deg' (#7891) --- excalidraw-app/collab/CollabError.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/excalidraw-app/collab/CollabError.scss b/excalidraw-app/collab/CollabError.scss index 085dc5609..bb774d00a 100644 --- a/excalidraw-app/collab/CollabError.scss +++ b/excalidraw-app/collab/CollabError.scss @@ -23,7 +23,7 @@ transform: rotate(10deg); } 50% { - transform: rotate(0eg); + transform: rotate(0deg); } 75% { transform: rotate(-10deg); From bbcca06b94550fcb03c0563be5f08cea7f4211fe Mon Sep 17 00:00:00 2001 From: Ryan Di Date: Wed, 17 Apr 2024 19:31:12 +0800 Subject: [PATCH 49/66] fix: collision regressions from vector geometry rewrite (#7902) --- packages/excalidraw/components/App.tsx | 24 ++++++++++++------- packages/utils/geometry/shape.ts | 33 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 6a0fd1031..8ceb362a5 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -230,6 +230,7 @@ import { getEllipseShape, getFreedrawShape, getPolygonShape, + getSelectionBoxShape, } from "../../utils/geometry/shape"; import { isPointInShape } from "../../utils/collision"; import { @@ -416,7 +417,6 @@ import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { getRenderOpacity } from "../renderer/renderElement"; import { hitElementBoundText, - hitElementBoundingBox, hitElementBoundingBoxOnly, hitElementItself, shouldTestInside, @@ -4462,10 +4462,18 @@ class App extends React.Component { // If we're hitting element with highest z-index only on its bounding box // while also hitting other element figure, the latter should be considered. - return isPointInShape( - [x, y], - this.getElementShape(elementWithHighestZIndex), - ) + return hitElementItself({ + x, + y, + element: elementWithHighestZIndex, + shape: this.getElementShape(elementWithHighestZIndex), + // when overlapping, we would like to be more precise + // this also avoids the need to update past tests + threshold: this.getHitThreshold() / 2, + frameNameBound: isFrameLikeElement(elementWithHighestZIndex) + ? this.frameNameBoundsCache.get(elementWithHighestZIndex) + : null, + }) ? elementWithHighestZIndex : allHitElements[allHitElements.length - 2]; } @@ -4540,13 +4548,13 @@ class App extends React.Component { this.state.selectedElementIds[element.id] && shouldShowBoundingBox([element], this.state) ) { - return hitElementBoundingBox( - x, - y, + const selectionShape = getSelectionBoxShape( element, this.scene.getNonDeletedElementsMap(), this.getHitThreshold(), ); + + return isPointInShape([x, y], selectionShape); } // take bound text element into consideration for hit collision as well diff --git a/packages/utils/geometry/shape.ts b/packages/utils/geometry/shape.ts index 87c0fe099..53ab9ff8e 100644 --- a/packages/utils/geometry/shape.ts +++ b/packages/utils/geometry/shape.ts @@ -12,8 +12,11 @@ * to pure shapes */ +import { getElementAbsoluteCoords } from "../../excalidraw/element"; import { + ElementsMap, ExcalidrawDiamondElement, + ExcalidrawElement, ExcalidrawEllipseElement, ExcalidrawEmbeddableElement, ExcalidrawFrameLikeElement, @@ -133,6 +136,36 @@ export const getPolygonShape = ( }; }; +// return the selection box for an element, possibly rotated as well +export const getSelectionBoxShape = ( + element: ExcalidrawElement, + elementsMap: ElementsMap, + padding = 10, +) => { + let [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( + element, + elementsMap, + true, + ); + + x1 -= padding; + x2 += padding; + y1 -= padding; + y2 += padding; + + const angleInDegrees = angleToDegrees(element.angle); + const center: Point = [cx, cy]; + const topLeft = pointRotate([x1, y1], angleInDegrees, center); + const topRight = pointRotate([x2, y1], angleInDegrees, center); + const bottomLeft = pointRotate([x1, y2], angleInDegrees, center); + const bottomRight = pointRotate([x2, y2], angleInDegrees, center); + + return { + type: "polygon", + data: [topLeft, topRight, bottomRight, bottomLeft], + } as GeometricShape; +}; + // ellipse export const getEllipseShape = ( element: ExcalidrawEllipseElement, From 5211b003b88fa5baf2620059ee21d5b864a13d97 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 17 Apr 2024 13:48:04 +0200 Subject: [PATCH 50/66] fix: double text rendering on edit (#7904) --- packages/excalidraw/renderer/staticScene.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/excalidraw/renderer/staticScene.ts b/packages/excalidraw/renderer/staticScene.ts index c2b5f218a..b7dcdd59a 100644 --- a/packages/excalidraw/renderer/staticScene.ts +++ b/packages/excalidraw/renderer/staticScene.ts @@ -281,7 +281,7 @@ const _renderStaticScene = ({ ); } - const boundTextElement = getBoundTextElement(element, allElementsMap); + const boundTextElement = getBoundTextElement(element, elementsMap); if (boundTextElement) { renderElement( boundTextElement, From 530617be90df8d41f93c7c885d3097565c4c7f6d Mon Sep 17 00:00:00 2001 From: Marcel Mraz Date: Wed, 17 Apr 2024 13:01:24 +0100 Subject: [PATCH 51/66] feat: multiplayer undo / redo (#7348) --- .../excalidraw/api/props/excalidraw-api.mdx | 4 +- excalidraw-app/App.tsx | 2 +- excalidraw-app/collab/Collab.tsx | 17 +- excalidraw-app/data/index.ts | 2 +- excalidraw-app/tests/collab.test.tsx | 210 +- packages/excalidraw/CHANGELOG.md | 11 +- .../excalidraw/actions/actionAddToLibrary.ts | 7 +- packages/excalidraw/actions/actionAlign.tsx | 13 +- .../excalidraw/actions/actionBoundText.tsx | 7 +- packages/excalidraw/actions/actionCanvas.tsx | 39 +- .../excalidraw/actions/actionClipboard.tsx | 27 +- .../actions/actionDeleteSelected.tsx | 11 +- .../excalidraw/actions/actionDistribute.tsx | 5 +- .../actions/actionDuplicateSelection.tsx | 12 +- .../excalidraw/actions/actionElementLock.ts | 5 +- packages/excalidraw/actions/actionExport.tsx | 26 +- .../excalidraw/actions/actionFinalize.tsx | 9 +- packages/excalidraw/actions/actionFlip.ts | 5 +- packages/excalidraw/actions/actionFrame.ts | 13 +- packages/excalidraw/actions/actionGroup.tsx | 31 +- packages/excalidraw/actions/actionHistory.tsx | 132 +- .../excalidraw/actions/actionLinearEditor.ts | 3 +- packages/excalidraw/actions/actionLink.tsx | 3 +- packages/excalidraw/actions/actionMenu.tsx | 7 +- .../excalidraw/actions/actionNavigate.tsx | 5 +- .../excalidraw/actions/actionProperties.tsx | 31 +- .../excalidraw/actions/actionSelectAll.ts | 3 +- packages/excalidraw/actions/actionStyles.ts | 7 +- .../actions/actionToggleGridMode.tsx | 3 +- .../actions/actionToggleObjectsSnapMode.tsx | 3 +- .../excalidraw/actions/actionToggleStats.tsx | 3 +- .../actions/actionToggleViewMode.tsx | 3 +- .../actions/actionToggleZenMode.tsx | 3 +- packages/excalidraw/actions/actionZindex.tsx | 9 +- packages/excalidraw/actions/manager.tsx | 6 +- packages/excalidraw/actions/types.ts | 8 +- packages/excalidraw/change.ts | 1529 ++ packages/excalidraw/components/Actions.scss | 1 + packages/excalidraw/components/App.tsx | 226 +- packages/excalidraw/components/ToolButton.tsx | 9 +- packages/excalidraw/components/ToolIcon.scss | 19 +- packages/excalidraw/constants.ts | 1 + packages/excalidraw/css/theme.scss | 4 + packages/excalidraw/css/variables.module.scss | 9 + packages/excalidraw/data/reconcile.ts | 2 +- packages/excalidraw/data/restore.ts | 2 +- packages/excalidraw/element/binding.ts | 497 +- packages/excalidraw/element/embeddable.ts | 3 +- .../excalidraw/element/linearElementEditor.ts | 6 +- packages/excalidraw/element/mutateElement.ts | 5 +- packages/excalidraw/element/sizeHelpers.ts | 3 + packages/excalidraw/element/textElement.ts | 8 +- packages/excalidraw/element/typeChecks.ts | 3 +- packages/excalidraw/element/types.ts | 12 +- packages/excalidraw/groups.ts | 18 + packages/excalidraw/history.ts | 427 +- packages/excalidraw/hooks/useEmitter.ts | 21 + packages/excalidraw/scene/Scene.ts | 8 +- packages/excalidraw/store.ts | 332 + .../__snapshots__/contextmenu.test.tsx.snap | 4468 ++-- .../tests/__snapshots__/history.test.tsx.snap | 18005 +++++++++++++++ .../tests/__snapshots__/move.test.tsx.snap | 10 +- .../regressionTests.test.tsx.snap | 18736 ++++++---------- .../excalidraw/tests/contextmenu.test.tsx | 2 +- packages/excalidraw/tests/helpers/api.ts | 13 +- packages/excalidraw/tests/helpers/ui.ts | 12 + packages/excalidraw/tests/history.test.tsx | 4615 +++- packages/excalidraw/tests/move.test.tsx | 20 +- .../excalidraw/tests/regressionTests.test.tsx | 19 +- packages/excalidraw/types.ts | 20 +- packages/excalidraw/utils.ts | 12 + 71 files changed, 34885 insertions(+), 14877 deletions(-) create mode 100644 packages/excalidraw/change.ts create mode 100644 packages/excalidraw/hooks/useEmitter.ts create mode 100644 packages/excalidraw/store.ts create mode 100644 packages/excalidraw/tests/__snapshots__/history.test.tsx.snap diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx index ffff19fb0..9f12c115d 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/props/excalidraw-api.mdx @@ -22,7 +22,7 @@ You can use this prop when you want to access some [Excalidraw APIs](https://git | API | Signature | Usage | | --- | --- | --- | | [updateScene](#updatescene) | `function` | updates the scene with the sceneData | -| [updateLibrary](#updatelibrary) | `function` | updates the scene with the sceneData | +| [updateLibrary](#updatelibrary) | `function` | updates the library | | [addFiles](#addfiles) | `function` | add files data to the appState | | [resetScene](#resetscene) | `function` | Resets the scene. If `resetLoadingState` is passed as true then it will also force set the loading state to false. | | [getSceneElementsIncludingDeleted](#getsceneelementsincludingdeleted) | `function` | Returns all the elements including the deleted in the scene | @@ -65,7 +65,7 @@ You can use this function to update the scene with the sceneData. It accepts the | `elements` | [`ImportedDataState["elements"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L38) | The `elements` to be updated in the scene | | `appState` | [`ImportedDataState["appState"]`](https://github.com/excalidraw/excalidraw/blob/master/packages/excalidraw/data/types.ts#L39) | The `appState` to be updated in the scene. | | `collaborators` | MapCollaborator> | The list of collaborators to be updated in the scene. | -| `commitToHistory` | `boolean` | Implies if the `history (undo/redo)` should be recorded. Defaults to `false`. | +| `commitToStore` | `boolean` | Implies if the change should be captured and commited to the `store`. Commited changes are emmitted and listened to by other components, such as `History` for undo / redo purposes. Defaults to `false`. | ```jsx live function App() { diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 90127d987..798791591 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -438,7 +438,7 @@ const ExcalidrawWrapper = () => { excalidrawAPI.updateScene({ ...data.scene, ...restore(data.scene, null, null, { repairBindings: true }), - commitToHistory: true, + commitToStore: true, }); } }); diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index def3810f9..1bf83da5e 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -356,7 +356,6 @@ class Collab extends PureComponent { this.excalidrawAPI.updateScene({ elements, - commitToHistory: false, }); } }; @@ -501,14 +500,12 @@ class Collab extends PureComponent { } return element; }); - // remove deleted elements from elements array & history to ensure we don't + // remove deleted elements from elements array to ensure we don't // expose potentially sensitive user data in case user manually deletes // existing elements (or clears scene), which would otherwise be persisted // to database even if deleted before creating the room. - this.excalidrawAPI.history.clear(); this.excalidrawAPI.updateScene({ elements, - commitToHistory: true, }); this.saveCollabRoomToFirebase(getSyncableElements(elements)); @@ -544,9 +541,7 @@ class Collab extends PureComponent { const remoteElements = decryptedData.payload.elements; const reconciledElements = this._reconcileElements(remoteElements); - this.handleRemoteSceneUpdate(reconciledElements, { - init: true, - }); + this.handleRemoteSceneUpdate(reconciledElements); // noop if already resolved via init from firebase scenePromise.resolve({ elements: reconciledElements, @@ -745,19 +740,11 @@ class Collab extends PureComponent { private handleRemoteSceneUpdate = ( elements: ReconciledExcalidrawElement[], - { init = false }: { init?: boolean } = {}, ) => { this.excalidrawAPI.updateScene({ elements, - commitToHistory: !!init, }); - // We haven't yet implemented multiplayer undo functionality, so we clear the undo stack - // when we receive any messages from another peer. This UX can be pretty rough -- if you - // undo, a user makes a change, and then try to redo, your element(s) will be lost. However, - // right now we think this is the right tradeoff. - this.excalidrawAPI.history.clear(); - this.loadImageFiles(); }; diff --git a/excalidraw-app/data/index.ts b/excalidraw-app/data/index.ts index 10c97fd20..6cf575412 100644 --- a/excalidraw-app/data/index.ts +++ b/excalidraw-app/data/index.ts @@ -269,7 +269,7 @@ export const loadScene = async ( // in the scene database/localStorage, and instead fetch them async // from a different database files: data.files, - commitToHistory: false, + commitToStore: false, }; }; diff --git a/excalidraw-app/tests/collab.test.tsx b/excalidraw-app/tests/collab.test.tsx index 2e2f1332a..1fd8ecdbc 100644 --- a/excalidraw-app/tests/collab.test.tsx +++ b/excalidraw-app/tests/collab.test.tsx @@ -1,13 +1,18 @@ import { vi } from "vitest"; import { + act, render, updateSceneData, waitFor, } from "../../packages/excalidraw/tests/test-utils"; import ExcalidrawApp from "../App"; import { API } from "../../packages/excalidraw/tests/helpers/api"; -import { createUndoAction } from "../../packages/excalidraw/actions/actionHistory"; import { syncInvalidIndices } from "../../packages/excalidraw/fractionalIndex"; +import { + createRedoAction, + createUndoAction, +} from "../../packages/excalidraw/actions/actionHistory"; +import { newElementWith } from "../../packages/excalidraw"; const { h } = window; @@ -58,39 +63,188 @@ vi.mock("socket.io-client", () => { }; }); +/** + * These test would deserve to be extended by testing collab with (at least) two clients simultanouesly, + * while having access to both scenes, appstates stores, histories and etc. + * i.e. multiplayer history tests could be a good first candidate, as we could test both history stacks simultaneously. + */ describe("collaboration", () => { - it("creating room should reset deleted elements", async () => { + it("should allow to undo / redo even on force-deleted elements", async () => { await render(); - // To update the scene with deleted elements before starting collab + const rect1Props = { + type: "rectangle", + id: "A", + height: 200, + width: 100, + } as const; + + const rect2Props = { + type: "rectangle", + id: "B", + width: 100, + height: 200, + } as const; + + const rect1 = API.createElement({ ...rect1Props }); + const rect2 = API.createElement({ ...rect2Props }); + updateSceneData({ - elements: syncInvalidIndices([ - API.createElement({ type: "rectangle", id: "A" }), - API.createElement({ - type: "rectangle", - id: "B", - isDeleted: true, - }), - ]), - }); - await waitFor(() => { - expect(h.elements).toEqual([ - expect.objectContaining({ id: "A" }), - expect.objectContaining({ id: "B", isDeleted: true }), - ]); - expect(API.getStateHistory().length).toBe(1); - }); - window.collab.startCollaboration(null); - await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + elements: syncInvalidIndices([rect1, rect2]), + commitToStore: true, + }); + + updateSceneData({ + elements: syncInvalidIndices([ + rect1, + newElementWith(h.elements[1], { isDeleted: true }), + ]), + commitToStore: true, }); - const undoAction = createUndoAction(h.history); - // noop - h.app.actionManager.executeAction(undoAction); await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]); - expect(API.getStateHistory().length).toBe(1); + expect(API.getUndoStack().length).toBe(2); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + }); + + // one form of force deletion happens when starting the collab, not to sync potentially sensitive data into the server + window.collab.startCollaboration(null); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + // we never delete from the local snapshot as it is used for correct diff calculation + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + const undoAction = createUndoAction(h.history, h.store); + act(() => h.app.actionManager.executeAction(undoAction)); + + // with explicit undo (as addition) we expect our item to be restored from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + const redoAction = createRedoAction(h.history, h.store); + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as removal) we again restore the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // simulate local update + updateSceneData({ + elements: syncInvalidIndices([ + h.elements[0], + newElementWith(h.elements[1], { x: 100 }), + ]), + commitToStore: true, + }); + + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 100 }), + ]); + }); + + act(() => h.app.actionManager.executeAction(undoAction)); + + // we expect to iterate the stack to the first visible change + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + expect(h.elements).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: false, x: 0 }), + ]); + }); + + // simulate force deleting the element remotely + updateSceneData({ + elements: syncInvalidIndices([rect1]), + }); + + // snapshot was correctly updated and marked the element as deleted + await waitFor(() => { + expect(API.getUndoStack().length).toBe(1); + expect(API.getRedoStack().length).toBe(1); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining(rect1Props), + expect.objectContaining({ ...rect2Props, isDeleted: true, x: 0 }), + ]); + expect(h.elements).toEqual([expect.objectContaining(rect1Props)]); + }); + + act(() => h.app.actionManager.executeAction(redoAction)); + + // with explicit redo (as update) we again restored the element from the snapshot! + await waitFor(() => { + expect(API.getUndoStack().length).toBe(2); + expect(API.getRedoStack().length).toBe(0); + expect(API.getSnapshot()).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); + expect(h.history.isRedoStackEmpty).toBeTruthy(); + expect(h.elements).toEqual([ + expect.objectContaining({ id: "A", isDeleted: false }), + expect.objectContaining({ id: "B", isDeleted: true, x: 100 }), + ]); }); }); }); diff --git a/packages/excalidraw/CHANGELOG.md b/packages/excalidraw/CHANGELOG.md index b6fcd36fa..9c5d4f564 100644 --- a/packages/excalidraw/CHANGELOG.md +++ b/packages/excalidraw/CHANGELOG.md @@ -15,9 +15,14 @@ Please add the latest change on the top under the correct section. ### Features +- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348) + - `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853) + - Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Add `useHandleLibrary`'s `opts.migrationAdapter` adapter to handle library migration during init, when migrating from one data store to another (e.g. from LocalStorage to IndexedDB). [#7655](https://github.com/excalidraw/excalidraw/pull/7655) + - Soft-deprecate `useHandleLibrary`'s `opts.getInitialLibraryItems` in favor of `opts.adapter`. [#7655](https://github.com/excalidraw/excalidraw/pull/7655) - Add `onPointerUp` prop [#7638](https://github.com/excalidraw/excalidraw/pull/7638). @@ -30,6 +35,10 @@ Please add the latest change on the top under the correct section. ### Breaking Changes +- Renamed required `updatedScene` parameter from `commitToHistory` into `commitToStore` [#7348](https://github.com/excalidraw/excalidraw/pull/7348). + +### Breaking Changes + - `ExcalidrawEmbeddableElement.validated` was removed and moved to private editor state. This should largely not affect your apps unless you were reading from this attribute. We keep validating embeddable urls internally, and the public [`props.validateEmbeddable`](https://docs.excalidraw.com/docs/@excalidraw/excalidraw/api/props#validateembeddable) still applies. [#7539](https://github.com/excalidraw/excalidraw/pull/7539) - `ExcalidrawTextElement.baseline` was removed and replaced with a vertical offset computation based on font metrics, performed on each text element re-render. In case of custom font usage, extend the `FONT_METRICS` object with the related properties. @@ -92,8 +101,6 @@ define: { - Disable caching bounds for arrow labels [#7343](https://github.com/excalidraw/excalidraw/pull/7343) ---- - ## 0.17.0 (2023-11-14) ### Features diff --git a/packages/excalidraw/actions/actionAddToLibrary.ts b/packages/excalidraw/actions/actionAddToLibrary.ts index ccb7fad62..93fddf0c4 100644 --- a/packages/excalidraw/actions/actionAddToLibrary.ts +++ b/packages/excalidraw/actions/actionAddToLibrary.ts @@ -3,6 +3,7 @@ import { deepCopyElement } from "../element/newElement"; import { randomId } from "../random"; import { t } from "../i18n"; import { LIBRARY_DISABLED_TYPES } from "../constants"; +import { StoreAction } from "../store"; export const actionAddToLibrary = register({ name: "addToLibrary", @@ -17,7 +18,7 @@ export const actionAddToLibrary = register({ for (const type of LIBRARY_DISABLED_TYPES) { if (selectedElements.some((element) => element.type === type)) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t(`errors.libraryElementTypeError.${type}`), @@ -41,7 +42,7 @@ export const actionAddToLibrary = register({ }) .then(() => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, toast: { message: t("toast.addedToLibrary") }, @@ -50,7 +51,7 @@ export const actionAddToLibrary = register({ }) .catch((error) => { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, diff --git a/packages/excalidraw/actions/actionAlign.tsx b/packages/excalidraw/actions/actionAlign.tsx index ddcb1415f..179b3e138 100644 --- a/packages/excalidraw/actions/actionAlign.tsx +++ b/packages/excalidraw/actions/actionAlign.tsx @@ -15,6 +15,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; +import { StoreAction } from "../store"; import { AppClassProperties, AppState, UIAppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -70,7 +71,7 @@ export const actionAlignTop = register({ position: "start", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -103,7 +104,7 @@ export const actionAlignBottom = register({ position: "end", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -136,7 +137,7 @@ export const actionAlignLeft = register({ position: "start", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -169,7 +170,7 @@ export const actionAlignRight = register({ position: "end", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -202,7 +203,7 @@ export const actionAlignVerticallyCentered = register({ position: "center", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( @@ -231,7 +232,7 @@ export const actionAlignHorizontallyCentered = register({ position: "center", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => ( diff --git a/packages/excalidraw/actions/actionBoundText.tsx b/packages/excalidraw/actions/actionBoundText.tsx index 1fcf80fd0..7d04b1afa 100644 --- a/packages/excalidraw/actions/actionBoundText.tsx +++ b/packages/excalidraw/actions/actionBoundText.tsx @@ -34,6 +34,7 @@ import { Mutable } from "../utility-types"; import { arrayToMap, getFontString } from "../utils"; import { register } from "./register"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionUnbindText = register({ name: "unbindText", @@ -85,7 +86,7 @@ export const actionUnbindText = register({ return { elements, appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -161,7 +162,7 @@ export const actionBindText = register({ return { elements: pushTextAboveContainer(elements, container, textElement), appState: { ...appState, selectedElementIds: { [container.id]: true } }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -320,7 +321,7 @@ export const actionWrapTextInContainer = register({ ...appState, selectedElementIds: containerIds, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionCanvas.tsx b/packages/excalidraw/actions/actionCanvas.tsx index 90492b321..0503e50f7 100644 --- a/packages/excalidraw/actions/actionCanvas.tsx +++ b/packages/excalidraw/actions/actionCanvas.tsx @@ -10,7 +10,13 @@ import { ZoomResetIcon, } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; -import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants"; +import { + CURSOR_TYPE, + MAX_ZOOM, + MIN_ZOOM, + THEME, + ZOOM_STEP, +} from "../constants"; import { getCommonBounds, getNonDeletedElements } from "../element"; import { ExcalidrawElement } from "../element/types"; import { t } from "../i18n"; @@ -31,6 +37,7 @@ import { import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors"; import { SceneBounds } from "../element/bounds"; import { setCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionChangeViewBackgroundColor = register({ name: "changeViewBackgroundColor", @@ -46,7 +53,9 @@ export const actionChangeViewBackgroundColor = register({ perform: (_, appState, value) => { return { appState: { ...appState, ...value }, - commitToHistory: !!value.viewBackgroundColor, + storeAction: !!value.viewBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => { @@ -102,7 +111,7 @@ export const actionClearCanvas = register({ ? { ...appState.activeTool, type: "selection" } : appState.activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, }); @@ -127,16 +136,17 @@ export const actionZoomIn = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( = MAX_ZOOM} onClick={() => { updateData(null); }} @@ -167,16 +177,17 @@ export const actionZoomOut = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, - PanelComponent: ({ updateData }) => ( + PanelComponent: ({ updateData, appState }) => ( { updateData(null); }} @@ -207,7 +218,7 @@ export const actionResetZoom = register({ ), userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, appState }) => ( @@ -282,8 +293,8 @@ export const zoomToFitBounds = ({ // Apply clamping to newZoomValue to be between 10% and 3000% newZoomValue = Math.min( - Math.max(newZoomValue, 0.1), - 30.0, + Math.max(newZoomValue, MIN_ZOOM), + MAX_ZOOM, ) as NormalizedZoomValue; let appStateWidth = appState.width; @@ -328,7 +339,7 @@ export const zoomToFitBounds = ({ scrollY, zoom: { value: newZoomValue }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }; @@ -447,7 +458,7 @@ export const actionToggleTheme = register({ theme: value || (appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D, @@ -485,7 +496,7 @@ export const actionToggleEraserTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.key === KEYS.E, @@ -524,7 +535,7 @@ export const actionToggleHandTool = register({ activeEmbeddable: null, activeTool, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionClipboard.tsx b/packages/excalidraw/actions/actionClipboard.tsx index bb488245c..e4f998d01 100644 --- a/packages/excalidraw/actions/actionClipboard.tsx +++ b/packages/excalidraw/actions/actionClipboard.tsx @@ -14,6 +14,7 @@ import { isTextElement } from "../element"; import { t } from "../i18n"; import { isFirefox } from "../constants"; import { DuplicateIcon, cutIcon, pngIcon, svgIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionCopy = register({ name: "copy", @@ -31,7 +32,7 @@ export const actionCopy = register({ await copyToClipboard(elementsToCopy, app.files, event); } catch (error: any) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: error.message, @@ -40,7 +41,7 @@ export const actionCopy = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, // don't supply a shortcut since we handle this conditionally via onCopy event @@ -66,7 +67,7 @@ export const actionPaste = register({ if (isFirefox) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("hints.firefox_clipboard_write"), @@ -75,7 +76,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnRead"), @@ -88,7 +89,7 @@ export const actionPaste = register({ } catch (error: any) { console.error(error); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, errorMessage: t("errors.asyncPasteFailedOnParse"), @@ -97,7 +98,7 @@ export const actionPaste = register({ } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, // don't supply a shortcut since we handle this conditionally via onCopy event @@ -124,7 +125,7 @@ export const actionCopyAsSvg = register({ perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -147,7 +148,7 @@ export const actionCopyAsSvg = register({ }, ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -156,7 +157,7 @@ export const actionCopyAsSvg = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -174,7 +175,7 @@ export const actionCopyAsPng = register({ perform: async (elements, appState, _data, app) => { if (!app.canvas) { return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; } const selectedElements = app.scene.getSelectedElements({ @@ -208,7 +209,7 @@ export const actionCopyAsPng = register({ }), }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } catch (error: any) { console.error(error); @@ -217,7 +218,7 @@ export const actionCopyAsPng = register({ ...appState, errorMessage: error.message, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -252,7 +253,7 @@ export const copyText = register({ throw new Error(t("errors.copyToSystemClipboardFailed")); } return { - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => { diff --git a/packages/excalidraw/actions/actionDeleteSelected.tsx b/packages/excalidraw/actions/actionDeleteSelected.tsx index 602d73725..4ab6fa411 100644 --- a/packages/excalidraw/actions/actionDeleteSelected.tsx +++ b/packages/excalidraw/actions/actionDeleteSelected.tsx @@ -13,6 +13,7 @@ import { fixBindingsAfterDeletion } from "../element/binding"; import { isBoundToContainer, isFrameLikeElement } from "../element/typeChecks"; import { updateActiveTool } from "../utils"; import { TrashIcon } from "../components/icons"; +import { StoreAction } from "../store"; const deleteSelectedElements = ( elements: readonly ExcalidrawElement[], @@ -112,7 +113,7 @@ export const actionDeleteSelected = register({ ...nextAppState, editingLinearElement: null, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } @@ -144,7 +145,7 @@ export const actionDeleteSelected = register({ : [0], }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } let { elements: nextElements, appState: nextAppState } = @@ -164,10 +165,12 @@ export const actionDeleteSelected = register({ multiElement: null, activeEmbeddable: null, }, - commitToHistory: isSomeElementSelected( + storeAction: isSomeElementSelected( getNonDeletedElements(elements), appState, - ), + ) + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, keyTest: (event, appState, elements) => diff --git a/packages/excalidraw/actions/actionDistribute.tsx b/packages/excalidraw/actions/actionDistribute.tsx index f3075e5a3..522fbb305 100644 --- a/packages/excalidraw/actions/actionDistribute.tsx +++ b/packages/excalidraw/actions/actionDistribute.tsx @@ -11,6 +11,7 @@ import { updateFrameMembershipOfSelectedElements } from "../frame"; import { t } from "../i18n"; import { CODES, KEYS } from "../keys"; import { isSomeElementSelected } from "../scene"; +import { StoreAction } from "../store"; import { AppClassProperties, AppState } from "../types"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; @@ -58,7 +59,7 @@ export const distributeHorizontally = register({ space: "between", axis: "x", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -89,7 +90,7 @@ export const distributeVertically = register({ space: "between", axis: "y", }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionDuplicateSelection.tsx b/packages/excalidraw/actions/actionDuplicateSelection.tsx index 46d021a21..0b4957f59 100644 --- a/packages/excalidraw/actions/actionDuplicateSelection.tsx +++ b/packages/excalidraw/actions/actionDuplicateSelection.tsx @@ -32,6 +32,7 @@ import { getSelectedElements, } from "../scene/selection"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; export const actionDuplicateSelection = register({ name: "duplicateSelection", @@ -54,13 +55,13 @@ export const actionDuplicateSelection = register({ return { elements, appState: ret.appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { ...duplicateElements(elements, appState), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.D, @@ -241,9 +242,10 @@ const duplicateElements = ( } // step (3) - const finalElements = finalElementsReversed.reverse(); - - syncMovedIndices(finalElements, arrayToMap([...oldElements, ...newElements])); + const finalElements = syncMovedIndices( + finalElementsReversed.reverse(), + arrayToMap(newElements), + ); // --------------------------------------------------------------------------- diff --git a/packages/excalidraw/actions/actionElementLock.ts b/packages/excalidraw/actions/actionElementLock.ts index 7200dca21..83600871e 100644 --- a/packages/excalidraw/actions/actionElementLock.ts +++ b/packages/excalidraw/actions/actionElementLock.ts @@ -4,6 +4,7 @@ import { isFrameLikeElement } from "../element/typeChecks"; import { ExcalidrawElement } from "../element/types"; import { KEYS } from "../keys"; import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; import { arrayToMap } from "../utils"; import { register } from "./register"; @@ -66,7 +67,7 @@ export const actionToggleElementLock = register({ ? null : appState.selectedLinearElement, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState, elements, app) => { @@ -111,7 +112,7 @@ export const actionUnlockAllElements = register({ lockedElements.map((el) => [el.id, true]), ), }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, label: "labels.elementLock.unlockAll", diff --git a/packages/excalidraw/actions/actionExport.tsx b/packages/excalidraw/actions/actionExport.tsx index eaa1d514f..7b767ecb8 100644 --- a/packages/excalidraw/actions/actionExport.tsx +++ b/packages/excalidraw/actions/actionExport.tsx @@ -19,13 +19,17 @@ import { nativeFileSystemSupported } from "../data/filesystem"; import { Theme } from "../element/types"; import "../components/ToolIcon.scss"; +import { StoreAction } from "../store"; export const actionChangeProjectName = register({ name: "changeProjectName", label: "labels.fileTitle", trackEvent: false, perform: (_elements, appState, value) => { - return { appState: { ...appState, name: value }, commitToHistory: false }; + return { + appState: { ...appState, name: value }, + storeAction: StoreAction.NONE, + }; }, PanelComponent: ({ appState, updateData, appProps, data, app }) => ( { return { appState: { ...appState, exportScale: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ elements: allElements, appState, updateData }) => { @@ -94,7 +98,7 @@ export const actionChangeExportBackground = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportBackground: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -114,7 +118,7 @@ export const actionChangeExportEmbedScene = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportEmbedScene: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( @@ -156,7 +160,7 @@ export const actionSaveToActiveFile = register({ : await saveAsJSON(elements, appState, app.files, app.getName()); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, fileHandle, @@ -178,7 +182,7 @@ export const actionSaveToActiveFile = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -203,7 +207,7 @@ export const actionSaveFileToDisk = register({ app.getName(), ); return { - commitToHistory: false, + storeAction: StoreAction.NONE, appState: { ...appState, openDialog: null, @@ -217,7 +221,7 @@ export const actionSaveFileToDisk = register({ } else { console.warn(error); } - return { commitToHistory: false }; + return { storeAction: StoreAction.NONE }; } }, keyTest: (event) => @@ -256,7 +260,7 @@ export const actionLoadScene = register({ elements: loadedElements, appState: loadedAppState, files, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } catch (error: any) { if (error?.name === "AbortError") { @@ -267,7 +271,7 @@ export const actionLoadScene = register({ elements, appState: { ...appState, errorMessage: error.message }, files: app.files, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } }, @@ -281,7 +285,7 @@ export const actionExportWithDarkMode = register({ perform: (_elements, appState, value) => { return { appState: { ...appState, exportWithDarkMode: value }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ appState, updateData }) => ( diff --git a/packages/excalidraw/actions/actionFinalize.tsx b/packages/excalidraw/actions/actionFinalize.tsx index 88ff366b6..e4b0861a6 100644 --- a/packages/excalidraw/actions/actionFinalize.tsx +++ b/packages/excalidraw/actions/actionFinalize.tsx @@ -15,6 +15,7 @@ import { import { isBindingElement, isLinearElement } from "../element/typeChecks"; import { AppState } from "../types"; import { resetCursor } from "../cursor"; +import { StoreAction } from "../store"; export const actionFinalize = register({ name: "finalize", @@ -48,8 +49,9 @@ export const actionFinalize = register({ ...appState, cursorButton: "up", editingLinearElement: null, + selectedLinearElement: null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } } @@ -90,7 +92,9 @@ export const actionFinalize = register({ }); } } + if (isInvisiblySmallElement(multiPointElement)) { + // TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want newElements = newElements.filter( (el) => el.id !== multiPointElement.id, ); @@ -186,7 +190,8 @@ export const actionFinalize = register({ : appState.selectedLinearElement, pendingImageElementId: null, }, - commitToHistory: appState.activeTool.type === "freedraw", + // TODO: #7348 we should not capture everything, but if we don't, it leads to incosistencies -> revisit + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event, appState) => diff --git a/packages/excalidraw/actions/actionFlip.ts b/packages/excalidraw/actions/actionFlip.ts index d821b200d..565756f94 100644 --- a/packages/excalidraw/actions/actionFlip.ts +++ b/packages/excalidraw/actions/actionFlip.ts @@ -18,6 +18,7 @@ import { } from "../element/binding"; import { updateFrameMembershipOfSelectedElements } from "../frame"; import { flipHorizontal, flipVertical } from "../components/icons"; +import { StoreAction } from "../store"; export const actionFlipHorizontal = register({ name: "flipHorizontal", @@ -38,7 +39,7 @@ export const actionFlipHorizontal = register({ app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event.shiftKey && event.code === CODES.H, @@ -63,7 +64,7 @@ export const actionFlipVertical = register({ app, ), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionFrame.ts b/packages/excalidraw/actions/actionFrame.ts index 019533c59..3471ed5b5 100644 --- a/packages/excalidraw/actions/actionFrame.ts +++ b/packages/excalidraw/actions/actionFrame.ts @@ -9,6 +9,7 @@ import { setCursorForShape } from "../cursor"; import { register } from "./register"; import { isFrameLikeElement } from "../element/typeChecks"; import { frameToolIcon } from "../components/icons"; +import { StoreAction } from "../store"; const isSingleFrameSelected = ( appState: UIAppState, @@ -44,14 +45,14 @@ export const actionSelectAllElementsInFrame = register({ return acc; }, {} as Record), }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => @@ -75,14 +76,14 @@ export const actionRemoveAllElementsFromFrame = register({ [selectedElement.id]: true, }, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; } return { elements, appState, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, predicate: (elements, appState, _, app) => @@ -104,7 +105,7 @@ export const actionupdateFrameRendering = register({ enabled: !appState.frameRendering.enabled, }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.frameRendering.enabled, @@ -134,7 +135,7 @@ export const actionSetFrameAsActiveTool = register({ type: "frame", }), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionGroup.tsx b/packages/excalidraw/actions/actionGroup.tsx index cda66ae5a..51f49ccea 100644 --- a/packages/excalidraw/actions/actionGroup.tsx +++ b/packages/excalidraw/actions/actionGroup.tsx @@ -17,7 +17,11 @@ import { import { getNonDeletedElements } from "../element"; import { randomId } from "../random"; import { ToolButton } from "../components/ToolButton"; -import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; +import { + ExcalidrawElement, + ExcalidrawTextElement, + OrderedExcalidrawElement, +} from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { isBoundToContainer } from "../element/typeChecks"; import { @@ -28,6 +32,7 @@ import { replaceAllElementsInFrame, } from "../frame"; import { syncMovedIndices } from "../fractionalIndex"; +import { StoreAction } from "../store"; const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => { if (elements.length >= 2) { @@ -72,7 +77,7 @@ export const actionGroup = register({ }); if (selectedElements.length < 2) { // nothing to group - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } // if everything is already grouped into 1 group, there is nothing to do const selectedGroupIds = getSelectedGroupIds(appState); @@ -92,7 +97,7 @@ export const actionGroup = register({ ]); if (combinedSet.size === elementIdsInGroup.size) { // no incremental ids in the selected ids - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } } @@ -134,19 +139,19 @@ export const actionGroup = register({ // to the z order of the highest element in the layer stack const elementsInGroup = getElementsInGroup(nextElements, newGroupId); const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1]; - const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup); + const lastGroupElementIndex = nextElements.lastIndexOf( + lastElementInGroup as OrderedExcalidrawElement, + ); const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1); const elementsBeforeGroup = nextElements .slice(0, lastGroupElementIndex) .filter( (updatedElement) => !isElementInGroup(updatedElement, newGroupId), ); - const reorderedElements = [ - ...elementsBeforeGroup, - ...elementsInGroup, - ...elementsAfterGroup, - ]; - syncMovedIndices(reorderedElements, arrayToMap(elementsInGroup)); + const reorderedElements = syncMovedIndices( + [...elementsBeforeGroup, ...elementsInGroup, ...elementsAfterGroup], + arrayToMap(elementsInGroup), + ); return { appState: { @@ -158,7 +163,7 @@ export const actionGroup = register({ ), }, elements: reorderedElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, predicate: (elements, appState, _, app) => @@ -188,7 +193,7 @@ export const actionUngroup = register({ const elementsMap = arrayToMap(elements); if (groupIds.length === 0) { - return { appState, elements, commitToHistory: false }; + return { appState, elements, storeAction: StoreAction.NONE }; } let nextElements = [...elements]; @@ -261,7 +266,7 @@ export const actionUngroup = register({ return { appState: { ...appState, ...updateAppState }, elements: nextElements, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionHistory.tsx b/packages/excalidraw/actions/actionHistory.tsx index fad459003..05c832fd2 100644 --- a/packages/excalidraw/actions/actionHistory.tsx +++ b/packages/excalidraw/actions/actionHistory.tsx @@ -2,113 +2,117 @@ import { Action, ActionResult } from "./types"; import { UndoIcon, RedoIcon } from "../components/icons"; import { ToolButton } from "../components/ToolButton"; import { t } from "../i18n"; -import History, { HistoryEntry } from "../history"; -import { ExcalidrawElement } from "../element/types"; +import { History, HistoryChangedEvent } from "../history"; import { AppState } from "../types"; import { KEYS } from "../keys"; -import { newElementWith } from "../element/mutateElement"; -import { fixBindingsAfterDeletion } from "../element/binding"; import { arrayToMap } from "../utils"; import { isWindows } from "../constants"; -import { syncInvalidIndices } from "../fractionalIndex"; +import { SceneElementsMap } from "../element/types"; +import { IStore, StoreAction } from "../store"; +import { useEmitter } from "../hooks/useEmitter"; const writeData = ( - prevElements: readonly ExcalidrawElement[], - appState: AppState, - updater: () => HistoryEntry | null, + appState: Readonly, + updater: () => [SceneElementsMap, AppState] | void, ): ActionResult => { - const commitToHistory = false; if ( !appState.multiElement && !appState.resizingElement && !appState.editingElement && !appState.draggingElement ) { - const data = updater(); - if (data === null) { - return { commitToHistory }; + const result = updater(); + + if (!result) { + return { storeAction: StoreAction.NONE }; } - const prevElementMap = arrayToMap(prevElements); - const nextElements = data.elements; - const nextElementMap = arrayToMap(nextElements); - - const deletedElements = prevElements.filter( - (prevElement) => !nextElementMap.has(prevElement.id), - ); - const elements = nextElements - .map((nextElement) => - newElementWith( - prevElementMap.get(nextElement.id) || nextElement, - nextElement, - ), - ) - .concat( - deletedElements.map((prevElement) => - newElementWith(prevElement, { isDeleted: true }), - ), - ); - fixBindingsAfterDeletion(elements, deletedElements); - // TODO: will be replaced in #7348 - syncInvalidIndices(elements); + const [nextElementsMap, nextAppState] = result; + const nextElements = Array.from(nextElementsMap.values()); return { - elements, - appState: { ...appState, ...data.appState }, - commitToHistory, - syncHistory: true, + appState: nextAppState, + elements: nextElements, + storeAction: StoreAction.UPDATE, }; } - return { commitToHistory }; + + return { storeAction: StoreAction.NONE }; }; -type ActionCreator = (history: History) => Action; +type ActionCreator = (history: History, store: IStore) => Action; -export const createUndoAction: ActionCreator = (history) => ({ +export const createUndoAction: ActionCreator = (history, store) => ({ name: "undo", label: "buttons.undo", icon: UndoIcon, trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.undoOnce()), + writeData(appState, () => + history.undo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key.toLowerCase() === KEYS.Z && !event.shiftKey, - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isUndoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); -export const createRedoAction: ActionCreator = (history) => ({ +export const createRedoAction: ActionCreator = (history, store) => ({ name: "redo", label: "buttons.redo", icon: RedoIcon, trackEvent: { category: "history" }, viewMode: false, perform: (elements, appState) => - writeData(elements, appState, () => history.redoOnce()), + writeData(appState, () => + history.redo( + arrayToMap(elements) as SceneElementsMap, // TODO: #7348 refactor action manager to already include `SceneElementsMap` + appState, + store.snapshot, + ), + ), keyTest: (event) => (event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key.toLowerCase() === KEYS.Z) || (isWindows && event.ctrlKey && !event.shiftKey && event.key === KEYS.Y), - PanelComponent: ({ updateData, data }) => ( - - ), - commitToHistory: () => false, + PanelComponent: ({ updateData, data }) => { + const { isRedoStackEmpty } = useEmitter( + history.onHistoryChangedEmitter, + new HistoryChangedEvent(), + ); + + return ( + + ); + }, }); diff --git a/packages/excalidraw/actions/actionLinearEditor.ts b/packages/excalidraw/actions/actionLinearEditor.ts index 5b76868f6..020df8b6f 100644 --- a/packages/excalidraw/actions/actionLinearEditor.ts +++ b/packages/excalidraw/actions/actionLinearEditor.ts @@ -2,6 +2,7 @@ import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette" import { LinearElementEditor } from "../element/linearElementEditor"; import { isLinearElement } from "../element/typeChecks"; import { ExcalidrawLinearElement } from "../element/types"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleLinearEditor = register({ @@ -41,7 +42,7 @@ export const actionToggleLinearEditor = register({ ...appState, editingLinearElement, }, - commitToHistory: false, + storeAction: StoreAction.CAPTURE, }; }, }); diff --git a/packages/excalidraw/actions/actionLink.tsx b/packages/excalidraw/actions/actionLink.tsx index 21e3a4e1a..ae6197486 100644 --- a/packages/excalidraw/actions/actionLink.tsx +++ b/packages/excalidraw/actions/actionLink.tsx @@ -5,6 +5,7 @@ import { isEmbeddableElement } from "../element/typeChecks"; import { t } from "../i18n"; import { KEYS } from "../keys"; import { getSelectedElements } from "../scene"; +import { StoreAction } from "../store"; import { getShortcutKey } from "../utils"; import { register } from "./register"; @@ -24,7 +25,7 @@ export const actionLink = register({ showHyperlinkPopup: "editor", openMenu: null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, trackEvent: { category: "hyperlink", action: "click" }, diff --git a/packages/excalidraw/actions/actionMenu.tsx b/packages/excalidraw/actions/actionMenu.tsx index 45a97eeba..84a5d1be4 100644 --- a/packages/excalidraw/actions/actionMenu.tsx +++ b/packages/excalidraw/actions/actionMenu.tsx @@ -4,6 +4,7 @@ import { t } from "../i18n"; import { showSelectedShapeActions, getNonDeletedElements } from "../element"; import { register } from "./register"; import { KEYS } from "../keys"; +import { StoreAction } from "../store"; export const actionToggleCanvasMenu = register({ name: "toggleCanvasMenu", @@ -14,7 +15,7 @@ export const actionToggleCanvasMenu = register({ ...appState, openMenu: appState.openMenu === "canvas" ? null : "canvas", }, - commitToHistory: false, + storeAction: StoreAction.NONE, }), PanelComponent: ({ appState, updateData }) => ( ( event.key === KEYS.QUESTION_MARK, diff --git a/packages/excalidraw/actions/actionNavigate.tsx b/packages/excalidraw/actions/actionNavigate.tsx index c60185657..9e401f4e2 100644 --- a/packages/excalidraw/actions/actionNavigate.tsx +++ b/packages/excalidraw/actions/actionNavigate.tsx @@ -7,6 +7,7 @@ import { microphoneMutedIcon, } from "../components/icons"; import { t } from "../i18n"; +import { StoreAction } from "../store"; import { Collaborator } from "../types"; import { register } from "./register"; import clsx from "clsx"; @@ -27,7 +28,7 @@ export const actionGoToCollaborator = register({ ...appState, userToFollow: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; } @@ -41,7 +42,7 @@ export const actionGoToCollaborator = register({ // Close mobile menu openMenu: appState.openMenu === "canvas" ? null : appState.openMenu, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, PanelComponent: ({ updateData, data, appState }) => { diff --git a/packages/excalidraw/actions/actionProperties.tsx b/packages/excalidraw/actions/actionProperties.tsx index 562f04b35..8ff2b40e7 100644 --- a/packages/excalidraw/actions/actionProperties.tsx +++ b/packages/excalidraw/actions/actionProperties.tsx @@ -96,6 +96,7 @@ import { import { hasStrokeColor } from "../scene/comparisons"; import { arrayToMap, getShortcutKey } from "../utils"; import { register } from "./register"; +import { StoreAction } from "../store"; const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1; @@ -231,7 +232,7 @@ const changeFontSize = ( ? [...newFontSizes][0] : fallbackValue ?? appState.currentItemFontSize, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }; @@ -261,7 +262,9 @@ export const actionChangeStrokeColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemStrokeColor, + storeAction: !!value.currentItemStrokeColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -305,7 +308,9 @@ export const actionChangeBackgroundColor = register({ ...appState, ...value, }, - commitToHistory: !!value.currentItemBackgroundColor, + storeAction: !!value.currentItemBackgroundColor + ? StoreAction.CAPTURE + : StoreAction.NONE, }; }, PanelComponent: ({ elements, appState, updateData, appProps }) => ( @@ -349,7 +354,7 @@ export const actionChangeFillStyle = register({ }), ), appState: { ...appState, currentItemFillStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -422,7 +427,7 @@ export const actionChangeStrokeWidth = register({ }), ), appState: { ...appState, currentItemStrokeWidth: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -477,7 +482,7 @@ export const actionChangeSloppiness = register({ }), ), appState: { ...appState, currentItemRoughness: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -528,7 +533,7 @@ export const actionChangeStrokeStyle = register({ }), ), appState: { ...appState, currentItemStrokeStyle: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -583,7 +588,7 @@ export const actionChangeOpacity = register({ true, ), appState: { ...appState, currentItemOpacity: value }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => ( @@ -758,7 +763,7 @@ export const actionChangeFontFamily = register({ ...appState, currentItemFontFamily: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -859,7 +864,7 @@ export const actionChangeTextAlign = register({ ...appState, currentItemTextAlign: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -949,7 +954,7 @@ export const actionChangeVerticalAlign = register({ appState: { ...appState, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData, app }) => { @@ -1030,7 +1035,7 @@ export const actionChangeRoundness = register({ ...appState, currentItemRoundness: value, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { @@ -1182,7 +1187,7 @@ export const actionChangeArrowhead = register({ ? "currentItemStartArrowhead" : "currentItemEndArrowhead"]: value.type, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, PanelComponent: ({ elements, appState, updateData }) => { diff --git a/packages/excalidraw/actions/actionSelectAll.ts b/packages/excalidraw/actions/actionSelectAll.ts index 2d682166f..7cc7a0e28 100644 --- a/packages/excalidraw/actions/actionSelectAll.ts +++ b/packages/excalidraw/actions/actionSelectAll.ts @@ -7,6 +7,7 @@ import { isLinearElement } from "../element/typeChecks"; import { LinearElementEditor } from "../element/linearElementEditor"; import { excludeElementsInFramesFromSelection } from "../scene/selection"; import { selectAllIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionSelectAll = register({ name: "selectAll", @@ -50,7 +51,7 @@ export const actionSelectAll = register({ ? new LinearElementEditor(elements[0]) : null, }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.A, diff --git a/packages/excalidraw/actions/actionStyles.ts b/packages/excalidraw/actions/actionStyles.ts index 8c0bc5370..fa8c6b9a3 100644 --- a/packages/excalidraw/actions/actionStyles.ts +++ b/packages/excalidraw/actions/actionStyles.ts @@ -26,6 +26,7 @@ import { import { getSelectedElements } from "../scene"; import { ExcalidrawTextElement } from "../element/types"; import { paintIcon } from "../components/icons"; +import { StoreAction } from "../store"; // `copiedStyles` is exported only for tests. export let copiedStyles: string = "{}"; @@ -54,7 +55,7 @@ export const actionCopyStyles = register({ ...appState, toast: { message: t("toast.copyStyles") }, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, keyTest: (event) => @@ -71,7 +72,7 @@ export const actionPasteStyles = register({ const pastedElement = elementsCopied[0]; const boundTextElement = elementsCopied[1]; if (!isExcalidrawElement(pastedElement)) { - return { elements, commitToHistory: false }; + return { elements, storeAction: StoreAction.NONE }; } const selectedElements = getSelectedElements(elements, appState, { @@ -160,7 +161,7 @@ export const actionPasteStyles = register({ } return element; }), - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/actionToggleGridMode.tsx b/packages/excalidraw/actions/actionToggleGridMode.tsx index 46e1879d9..da5ab6b44 100644 --- a/packages/excalidraw/actions/actionToggleGridMode.tsx +++ b/packages/excalidraw/actions/actionToggleGridMode.tsx @@ -3,6 +3,7 @@ import { register } from "./register"; import { GRID_SIZE } from "../constants"; import { AppState } from "../types"; import { gridIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleGridMode = register({ name: "gridMode", @@ -21,7 +22,7 @@ export const actionToggleGridMode = register({ gridSize: this.checked!(appState) ? null : GRID_SIZE, objectsSnapModeEnabled: false, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState: AppState) => appState.gridSize !== null, diff --git a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx index 2f9a148c0..586293d08 100644 --- a/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx +++ b/packages/excalidraw/actions/actionToggleObjectsSnapMode.tsx @@ -1,5 +1,6 @@ import { magnetIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleObjectsSnapMode = register({ @@ -18,7 +19,7 @@ export const actionToggleObjectsSnapMode = register({ objectsSnapModeEnabled: !this.checked!(appState), gridSize: null, }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.objectsSnapModeEnabled, diff --git a/packages/excalidraw/actions/actionToggleStats.tsx b/packages/excalidraw/actions/actionToggleStats.tsx index 74d0e0410..fc1e70a47 100644 --- a/packages/excalidraw/actions/actionToggleStats.tsx +++ b/packages/excalidraw/actions/actionToggleStats.tsx @@ -1,6 +1,7 @@ import { register } from "./register"; import { CODES, KEYS } from "../keys"; import { abacusIcon } from "../components/icons"; +import { StoreAction } from "../store"; export const actionToggleStats = register({ name: "stats", @@ -15,7 +16,7 @@ export const actionToggleStats = register({ ...appState, showStats: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.showStats, diff --git a/packages/excalidraw/actions/actionToggleViewMode.tsx b/packages/excalidraw/actions/actionToggleViewMode.tsx index f3c5e4da6..87dbb94ea 100644 --- a/packages/excalidraw/actions/actionToggleViewMode.tsx +++ b/packages/excalidraw/actions/actionToggleViewMode.tsx @@ -1,5 +1,6 @@ import { eyeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleViewMode = register({ @@ -18,7 +19,7 @@ export const actionToggleViewMode = register({ ...appState, viewModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.viewModeEnabled, diff --git a/packages/excalidraw/actions/actionToggleZenMode.tsx b/packages/excalidraw/actions/actionToggleZenMode.tsx index fd397582a..86261443f 100644 --- a/packages/excalidraw/actions/actionToggleZenMode.tsx +++ b/packages/excalidraw/actions/actionToggleZenMode.tsx @@ -1,5 +1,6 @@ import { coffeeIcon } from "../components/icons"; import { CODES, KEYS } from "../keys"; +import { StoreAction } from "../store"; import { register } from "./register"; export const actionToggleZenMode = register({ @@ -18,7 +19,7 @@ export const actionToggleZenMode = register({ ...appState, zenModeEnabled: !this.checked!(appState), }, - commitToHistory: false, + storeAction: StoreAction.NONE, }; }, checked: (appState) => appState.zenModeEnabled, diff --git a/packages/excalidraw/actions/actionZindex.tsx b/packages/excalidraw/actions/actionZindex.tsx index 7b68a00d5..716688811 100644 --- a/packages/excalidraw/actions/actionZindex.tsx +++ b/packages/excalidraw/actions/actionZindex.tsx @@ -15,6 +15,7 @@ import { SendToBackIcon, } from "../components/icons"; import { isDarwin } from "../constants"; +import { StoreAction } from "../store"; export const actionSendBackward = register({ name: "sendBackward", @@ -25,7 +26,7 @@ export const actionSendBackward = register({ return { elements: moveOneLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyPriority: 40, @@ -54,7 +55,7 @@ export const actionBringForward = register({ return { elements: moveOneRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyPriority: 40, @@ -83,7 +84,7 @@ export const actionSendToBack = register({ return { elements: moveAllLeft(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => @@ -120,7 +121,7 @@ export const actionBringToFront = register({ return { elements: moveAllRight(elements, appState), appState, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }; }, keyTest: (event) => diff --git a/packages/excalidraw/actions/manager.tsx b/packages/excalidraw/actions/manager.tsx index 90dfe6088..b5e36e855 100644 --- a/packages/excalidraw/actions/manager.tsx +++ b/packages/excalidraw/actions/manager.tsx @@ -7,7 +7,7 @@ import { PanelComponentProps, ActionSource, } from "./types"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState } from "../types"; import { trackEvent } from "../analytics"; import { isPromiseLike } from "../utils"; @@ -46,13 +46,13 @@ export class ActionManager { updater: (actionResult: ActionResult | Promise) => void; getAppState: () => Readonly; - getElementsIncludingDeleted: () => readonly ExcalidrawElement[]; + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[]; app: AppClassProperties; constructor( updater: UpdaterFn, getAppState: () => AppState, - getElementsIncludingDeleted: () => readonly ExcalidrawElement[], + getElementsIncludingDeleted: () => readonly OrderedExcalidrawElement[], app: AppClassProperties, ) { this.updater = (actionResult) => { diff --git a/packages/excalidraw/actions/types.ts b/packages/excalidraw/actions/types.ts index 18503363f..e904bfa02 100644 --- a/packages/excalidraw/actions/types.ts +++ b/packages/excalidraw/actions/types.ts @@ -1,5 +1,5 @@ import React from "react"; -import { ExcalidrawElement } from "../element/types"; +import { ExcalidrawElement, OrderedExcalidrawElement } from "../element/types"; import { AppClassProperties, AppState, @@ -8,6 +8,7 @@ import { UIAppState, } from "../types"; import { MarkOptional } from "../utility-types"; +import { StoreAction } from "../store"; export type ActionSource = | "ui" @@ -25,14 +26,13 @@ export type ActionResult = "offsetTop" | "offsetLeft" | "width" | "height" > | null; files?: BinaryFiles | null; - commitToHistory: boolean; - syncHistory?: boolean; + storeAction: keyof typeof StoreAction; replaceFiles?: boolean; } | false; type ActionFn = ( - elements: readonly ExcalidrawElement[], + elements: readonly OrderedExcalidrawElement[], appState: Readonly, formData: any, app: AppClassProperties, diff --git a/packages/excalidraw/change.ts b/packages/excalidraw/change.ts new file mode 100644 index 000000000..b8c88f54f --- /dev/null +++ b/packages/excalidraw/change.ts @@ -0,0 +1,1529 @@ +import { ENV } from "./constants"; +import { + BoundElement, + BindableElement, + BindableProp, + BindingProp, + bindingProperties, + updateBoundElements, +} from "./element/binding"; +import { LinearElementEditor } from "./element/linearElementEditor"; +import { + ElementUpdate, + mutateElement, + newElementWith, +} from "./element/mutateElement"; +import { + getBoundTextElementId, + redrawTextBoundingBox, +} from "./element/textElement"; +import { + hasBoundTextElement, + isBindableElement, + isBoundToContainer, + isTextElement, +} from "./element/typeChecks"; +import { + ExcalidrawElement, + ExcalidrawLinearElement, + ExcalidrawTextElement, + NonDeleted, + OrderedExcalidrawElement, + SceneElementsMap, +} from "./element/types"; +import { orderByFractionalIndex, syncMovedIndices } from "./fractionalIndex"; +import { getNonDeletedGroupIds } from "./groups"; +import { getObservedAppState } from "./store"; +import { + AppState, + ObservedAppState, + ObservedElementsAppState, + ObservedStandaloneAppState, +} from "./types"; +import { SubtypeOf, ValueOf } from "./utility-types"; +import { + arrayToMap, + arrayToObject, + assertNever, + isShallowEqual, + toBrandedType, +} from "./utils"; + +/** + * Represents the difference between two objects of the same type. + * + * Both `deleted` and `inserted` partials represent the same set of added, removed or updated properties, where: + * - `deleted` is a set of all the deleted values + * - `inserted` is a set of all the inserted (added, updated) values + * + * Keeping it as pure object (without transient state, side-effects, etc.), so we won't have to instantiate it on load. + */ +class Delta { + private constructor( + public readonly deleted: Partial, + public readonly inserted: Partial, + ) {} + + public static create( + deleted: Partial, + inserted: Partial, + modifier?: (delta: Partial) => Partial, + modifierOptions?: "deleted" | "inserted", + ) { + const modifiedDeleted = + modifier && modifierOptions !== "inserted" ? modifier(deleted) : deleted; + const modifiedInserted = + modifier && modifierOptions !== "deleted" ? modifier(inserted) : inserted; + + return new Delta(modifiedDeleted, modifiedInserted); + } + + /** + * Calculates the delta between two objects. + * + * @param prevObject - The previous state of the object. + * @param nextObject - The next state of the object. + * + * @returns new delta instance. + */ + public static calculate( + prevObject: T, + nextObject: T, + modifier?: (partial: Partial) => Partial, + postProcess?: ( + deleted: Partial, + inserted: Partial, + ) => [Partial, Partial], + ): Delta { + if (prevObject === nextObject) { + return Delta.empty(); + } + + const deleted = {} as Partial; + const inserted = {} as Partial; + + // O(n^3) here for elements, but it's not as bad as it looks: + // - we do this only on store recordings, not on every frame (not for ephemerals) + // - we do this only on previously detected changed elements + // - we do shallow compare only on the first level of properties (not going any deeper) + // - # of properties is reasonably small + for (const key of this.distinctKeysIterator( + "full", + prevObject, + nextObject, + )) { + deleted[key as keyof T] = prevObject[key]; + inserted[key as keyof T] = nextObject[key]; + } + + const [processedDeleted, processedInserted] = postProcess + ? postProcess(deleted, inserted) + : [deleted, inserted]; + + return Delta.create(processedDeleted, processedInserted, modifier); + } + + public static empty() { + return new Delta({}, {}); + } + + public static isEmpty(delta: Delta): boolean { + return ( + !Object.keys(delta.deleted).length && !Object.keys(delta.inserted).length + ); + } + + /** + * Merges deleted and inserted object partials. + */ + public static mergeObjects( + prev: T, + added: T, + removed: T, + ) { + const cloned = { ...prev }; + + for (const key of Object.keys(removed)) { + delete cloned[key]; + } + + return { ...cloned, ...added }; + } + + /** + * Merges deleted and inserted array partials. + */ + public static mergeArrays( + prev: readonly T[] | null, + added: readonly T[] | null | undefined, + removed: readonly T[] | null | undefined, + predicate?: (value: T) => string, + ) { + return Object.values( + Delta.mergeObjects( + arrayToObject(prev ?? [], predicate), + arrayToObject(added ?? [], predicate), + arrayToObject(removed ?? [], predicate), + ), + ); + } + + /** + * Diff object partials as part of the `postProcess`. + */ + public static diffObjects>( + deleted: Partial, + inserted: Partial, + property: K, + setValue: (prevValue: V | undefined) => V, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if ( + typeof deleted[property] === "object" || + typeof inserted[property] === "object" + ) { + type RecordLike = Record; + + const deletedObject: RecordLike = deleted[property] ?? {}; + const insertedObject: RecordLike = inserted[property] ?? {}; + + const deletedDifferences = Delta.getLeftDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(deletedObject[curr]); + return acc; + }, {} as RecordLike); + + const insertedDifferences = Delta.getRightDifferences( + deletedObject, + insertedObject, + ).reduce((acc, curr) => { + acc[curr] = setValue(insertedObject[curr]); + return acc; + }, {} as RecordLike); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + Reflect.set(deleted, property, deletedDifferences); + Reflect.set(inserted, property, insertedDifferences); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Diff array partials as part of the `postProcess`. + */ + public static diffArrays( + deleted: Partial, + inserted: Partial, + property: K, + groupBy: (value: V extends ArrayLike ? T : never) => string, + ) { + if (!deleted[property] && !inserted[property]) { + return; + } + + if (Array.isArray(deleted[property]) || Array.isArray(inserted[property])) { + const deletedArray = ( + Array.isArray(deleted[property]) ? deleted[property] : [] + ) as []; + const insertedArray = ( + Array.isArray(inserted[property]) ? inserted[property] : [] + ) as []; + + const deletedDifferences = arrayToObject( + Delta.getLeftDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + const insertedDifferences = arrayToObject( + Delta.getRightDifferences( + arrayToObject(deletedArray, groupBy), + arrayToObject(insertedArray, groupBy), + ), + ); + + if ( + Object.keys(deletedDifferences).length || + Object.keys(insertedDifferences).length + ) { + const deletedValue = deletedArray.filter( + (x) => deletedDifferences[groupBy ? groupBy(x) : String(x)], + ); + const insertedValue = insertedArray.filter( + (x) => insertedDifferences[groupBy ? groupBy(x) : String(x)], + ); + + Reflect.set(deleted, property, deletedValue); + Reflect.set(inserted, property, insertedValue); + } else { + Reflect.deleteProperty(deleted, property); + Reflect.deleteProperty(inserted, property); + } + } + } + + /** + * Compares if object1 contains any different value compared to the object2. + */ + public static isLeftDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "left", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Compares if object2 contains any different value compared to the object1. + */ + public static isRightDifferent( + object1: T, + object2: T, + skipShallowCompare = false, + ): boolean { + const anyDistinctKey = this.distinctKeysIterator( + "right", + object1, + object2, + skipShallowCompare, + ).next().value; + + return !!anyDistinctKey; + } + + /** + * Returns all the object1 keys that have distinct values. + */ + public static getLeftDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("left", object1, object2, skipShallowCompare), + ); + } + + /** + * Returns all the object2 keys that have distinct values. + */ + public static getRightDifferences( + object1: T, + object2: T, + skipShallowCompare = false, + ) { + return Array.from( + this.distinctKeysIterator("right", object1, object2, skipShallowCompare), + ); + } + + /** + * Iterator comparing values of object properties based on the passed joining strategy. + * + * @yields keys of properties with different values + * + * WARN: it's based on shallow compare performed only on the first level and doesn't go deeper than that. + */ + private static *distinctKeysIterator( + join: "left" | "right" | "full", + object1: T, + object2: T, + skipShallowCompare = false, + ) { + if (object1 === object2) { + return; + } + + let keys: string[] = []; + + if (join === "left") { + keys = Object.keys(object1); + } else if (join === "right") { + keys = Object.keys(object2); + } else if (join === "full") { + keys = Array.from( + new Set([...Object.keys(object1), ...Object.keys(object2)]), + ); + } else { + assertNever( + join, + `Unknown distinctKeysIterator's join param "${join}"`, + true, + ); + } + + for (const key of keys) { + const object1Value = object1[key as keyof T]; + const object2Value = object2[key as keyof T]; + + if (object1Value !== object2Value) { + if ( + !skipShallowCompare && + typeof object1Value === "object" && + typeof object2Value === "object" && + object1Value !== null && + object2Value !== null && + isShallowEqual(object1Value, object2Value) + ) { + continue; + } + + yield key; + } + } + } +} + +/** + * Encapsulates the modifications captured as `Delta`/s. + */ +interface Change { + /** + * Inverses the `Delta`s inside while creating a new `Change`. + */ + inverse(): Change; + + /** + * Applies the `Change` to the previous object. + * + * @returns a tuple of the next object `T` with applied change, and `boolean`, indicating whether the applied change resulted in a visible change. + */ + applyTo(previous: T, ...options: unknown[]): [T, boolean]; + + /** + * Checks whether there are actually `Delta`s. + */ + isEmpty(): boolean; +} + +export class AppStateChange implements Change { + private constructor(private readonly delta: Delta) {} + + public static calculate( + prevAppState: T, + nextAppState: T, + ): AppStateChange { + const delta = Delta.calculate( + prevAppState, + nextAppState, + undefined, + AppStateChange.postProcess, + ); + + return new AppStateChange(delta); + } + + public static empty() { + return new AppStateChange(Delta.create({}, {})); + } + + public inverse(): AppStateChange { + const inversedDelta = Delta.create(this.delta.inserted, this.delta.deleted); + return new AppStateChange(inversedDelta); + } + + public applyTo( + appState: AppState, + nextElements: SceneElementsMap, + ): [AppState, boolean] { + try { + const { + selectedElementIds: removedSelectedElementIds = {}, + selectedGroupIds: removedSelectedGroupIds = {}, + } = this.delta.deleted; + + const { + selectedElementIds: addedSelectedElementIds = {}, + selectedGroupIds: addedSelectedGroupIds = {}, + selectedLinearElementId, + editingLinearElementId, + ...directlyApplicablePartial + } = this.delta.inserted; + + const mergedSelectedElementIds = Delta.mergeObjects( + appState.selectedElementIds, + addedSelectedElementIds, + removedSelectedElementIds, + ); + + const mergedSelectedGroupIds = Delta.mergeObjects( + appState.selectedGroupIds, + addedSelectedGroupIds, + removedSelectedGroupIds, + ); + + const selectedLinearElement = + selectedLinearElementId && nextElements.has(selectedLinearElementId) + ? new LinearElementEditor( + nextElements.get( + selectedLinearElementId, + ) as NonDeleted, + ) + : null; + + const editingLinearElement = + editingLinearElementId && nextElements.has(editingLinearElementId) + ? new LinearElementEditor( + nextElements.get( + editingLinearElementId, + ) as NonDeleted, + ) + : null; + + const nextAppState = { + ...appState, + ...directlyApplicablePartial, + selectedElementIds: mergedSelectedElementIds, + selectedGroupIds: mergedSelectedGroupIds, + selectedLinearElement: + typeof selectedLinearElementId !== "undefined" + ? selectedLinearElement // element was either inserted or deleted + : appState.selectedLinearElement, // otherwise assign what we had before + editingLinearElement: + typeof editingLinearElementId !== "undefined" + ? editingLinearElement // element was either inserted or deleted + : appState.editingLinearElement, // otherwise assign what we had before + }; + + const constainsVisibleChanges = this.filterInvisibleChanges( + appState, + nextAppState, + nextElements, + ); + + return [nextAppState, constainsVisibleChanges]; + } catch (e) { + // shouldn't really happen, but just in case + console.error(`Couldn't apply appstate change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + return [appState, false]; + } + } + + public isEmpty(): boolean { + return Delta.isEmpty(this.delta); + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: Partial, + inserted: Partial, + ): [Partial, Partial] { + try { + Delta.diffObjects( + deleted, + inserted, + "selectedElementIds", + // ts language server has a bit trouble resolving this, so we are giving it a little push + (_) => true as ValueOf, + ); + Delta.diffObjects( + deleted, + inserted, + "selectedGroupIds", + (prevValue) => (prevValue ?? false) as ValueOf, + ); + } catch (e) { + // if postprocessing fails it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess appstate change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + /** + * Mutates `nextAppState` be filtering out state related to deleted elements. + * + * @returns `true` if a visible change is found, `false` otherwise. + */ + private filterInvisibleChanges( + prevAppState: AppState, + nextAppState: AppState, + nextElements: SceneElementsMap, + ): boolean { + // TODO: #7348 we could still get an empty undo/redo, as we assume that previous appstate does not contain references to deleted elements + // which is not always true - i.e. now we do cleanup appstate during history, but we do not do it during remote updates + const prevObservedAppState = getObservedAppState(prevAppState); + const nextObservedAppState = getObservedAppState(nextAppState); + + const containsStandaloneDifference = Delta.isRightDifferent( + AppStateChange.stripElementsProps(prevObservedAppState), + AppStateChange.stripElementsProps(nextObservedAppState), + ); + + const containsElementsDifference = Delta.isRightDifferent( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ); + + if (!containsStandaloneDifference && !containsElementsDifference) { + // no change in appstate was detected + return false; + } + + const visibleDifferenceFlag = { + value: containsStandaloneDifference, + }; + + if (containsElementsDifference) { + // filter invisible changes on each iteration + const changedElementsProps = Delta.getRightDifferences( + AppStateChange.stripStandaloneProps(prevObservedAppState), + AppStateChange.stripStandaloneProps(nextObservedAppState), + ) as Array; + + let nonDeletedGroupIds = new Set(); + + if ( + changedElementsProps.includes("editingGroupId") || + changedElementsProps.includes("selectedGroupIds") + ) { + // this one iterates through all the non deleted elements, so make sure it's not done twice + nonDeletedGroupIds = getNonDeletedGroupIds(nextElements); + } + + // check whether delta properties are related to the existing non-deleted elements + for (const key of changedElementsProps) { + switch (key) { + case "selectedElementIds": + nextAppState[key] = AppStateChange.filterSelectedElements( + nextAppState[key], + nextElements, + visibleDifferenceFlag, + ); + + break; + case "selectedGroupIds": + nextAppState[key] = AppStateChange.filterSelectedGroups( + nextAppState[key], + nonDeletedGroupIds, + visibleDifferenceFlag, + ); + + break; + case "editingGroupId": + const editingGroupId = nextAppState[key]; + + if (!editingGroupId) { + // previously there was an editingGroup (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else if (nonDeletedGroupIds.has(editingGroupId)) { + // previously there wasn't an editingGroup, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned an editingGroup now, but it's related to deleted element + nextAppState[key] = null; + } + + break; + case "selectedLinearElementId": + case "editingLinearElementId": + const appStateKey = AppStateChange.convertToAppStateKey(key); + const linearElement = nextAppState[appStateKey]; + + if (!linearElement) { + // previously there was a linear element (assuming visible), now there is none + visibleDifferenceFlag.value = true; + } else { + const element = nextElements.get(linearElement.elementId); + + if (element && !element.isDeleted) { + // previously there wasn't a linear element, now there is one which is visible + visibleDifferenceFlag.value = true; + } else { + // there was assigned a linear element now, but it's deleted + nextAppState[appStateKey] = null; + } + } + + break; + default: { + assertNever( + key, + `Unknown ObservedElementsAppState's key "${key}"`, + true, + ); + } + } + } + } + + return visibleDifferenceFlag.value; + } + + private static convertToAppStateKey( + key: keyof Pick< + ObservedElementsAppState, + "selectedLinearElementId" | "editingLinearElementId" + >, + ): keyof Pick { + switch (key) { + case "selectedLinearElementId": + return "selectedLinearElement"; + case "editingLinearElementId": + return "editingLinearElement"; + } + } + + private static filterSelectedElements( + selectedElementIds: AppState["selectedElementIds"], + elements: SceneElementsMap, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedElementIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible elements), now there are none + visibleDifferenceFlag.value = true; + return selectedElementIds; + } + + const nextSelectedElementIds = { ...selectedElementIds }; + + for (const id of ids) { + const element = elements.get(id); + + if (element && !element.isDeleted) { + // there is a selected element id related to a visible element + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedElementIds[id]; + } + } + + return nextSelectedElementIds; + } + + private static filterSelectedGroups( + selectedGroupIds: AppState["selectedGroupIds"], + nonDeletedGroupIds: Set, + visibleDifferenceFlag: { value: boolean }, + ) { + const ids = Object.keys(selectedGroupIds); + + if (!ids.length) { + // previously there were ids (assuming related to visible groups), now there are none + visibleDifferenceFlag.value = true; + return selectedGroupIds; + } + + const nextSelectedGroupIds = { ...selectedGroupIds }; + + for (const id of Object.keys(nextSelectedGroupIds)) { + if (nonDeletedGroupIds.has(id)) { + // there is a selected group id related to a visible group + visibleDifferenceFlag.value = true; + } else { + delete nextSelectedGroupIds[id]; + } + } + + return nextSelectedGroupIds; + } + + private static stripElementsProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { + editingGroupId, + selectedGroupIds, + selectedElementIds, + editingLinearElementId, + selectedLinearElementId, + ...standaloneProps + } = delta as ObservedAppState; + + return standaloneProps as SubtypeOf< + typeof standaloneProps, + ObservedStandaloneAppState + >; + } + + private static stripStandaloneProps( + delta: Partial, + ): Partial { + // WARN: Do not remove the type-casts as they here to ensure proper type checks + const { name, viewBackgroundColor, ...elementsProps } = + delta as ObservedAppState; + + return elementsProps as SubtypeOf< + typeof elementsProps, + ObservedElementsAppState + >; + } +} + +type ElementPartial = Omit, "seed">; + +/** + * Elements change is a low level primitive to capture a change between two sets of elements. + * It does so by encapsulating forward and backward `Delta`s, allowing to time-travel in both directions. + */ +export class ElementsChange implements Change { + private constructor( + private readonly added: Map>, + private readonly removed: Map>, + private readonly updated: Map>, + ) {} + + public static create( + added: Map>, + removed: Map>, + updated: Map>, + options = { shouldRedistribute: false }, + ) { + let change: ElementsChange; + + if (options.shouldRedistribute) { + const nextAdded = new Map>(); + const nextRemoved = new Map>(); + const nextUpdated = new Map>(); + + const deltas = [...added, ...removed, ...updated]; + + for (const [id, delta] of deltas) { + if (this.satisfiesAddition(delta)) { + nextAdded.set(id, delta); + } else if (this.satisfiesRemoval(delta)) { + nextRemoved.set(id, delta); + } else { + nextUpdated.set(id, delta); + } + } + + change = new ElementsChange(nextAdded, nextRemoved, nextUpdated); + } else { + change = new ElementsChange(added, removed, updated); + } + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + ElementsChange.validate(change, "added", this.satisfiesAddition); + ElementsChange.validate(change, "removed", this.satisfiesRemoval); + ElementsChange.validate(change, "updated", this.satisfiesUpdate); + } + + return change; + } + + private static satisfiesAddition = ({ + deleted, + inserted, + }: Delta) => + // dissallowing added as "deleted", which could cause issues when resolving conflicts + deleted.isDeleted === true && !inserted.isDeleted; + + private static satisfiesRemoval = ({ + deleted, + inserted, + }: Delta) => + !deleted.isDeleted && inserted.isDeleted === true; + + private static satisfiesUpdate = ({ + deleted, + inserted, + }: Delta) => !!deleted.isDeleted === !!inserted.isDeleted; + + private static validate( + change: ElementsChange, + type: "added" | "removed" | "updated", + satifies: (delta: Delta) => boolean, + ) { + for (const [id, delta] of change[type].entries()) { + if (!satifies(delta)) { + console.error( + `Broken invariant for "${type}" delta, element "${id}", delta:`, + delta, + ); + throw new Error(`ElementsChange invariant broken for element "${id}".`); + } + } + } + + /** + * Calculates the `Delta`s between the previous and next set of elements. + * + * @param prevElements - Map representing the previous state of elements. + * @param nextElements - Map representing the next state of elements. + * + * @returns `ElementsChange` instance representing the `Delta` changes between the two sets of elements. + */ + public static calculate( + prevElements: Map, + nextElements: Map, + ): ElementsChange { + if (prevElements === nextElements) { + return ElementsChange.empty(); + } + + const added = new Map>(); + const removed = new Map>(); + const updated = new Map>(); + + // this might be needed only in same edge cases, like during collab, when `isDeleted` elements get removed or when we (un)intentionally remove the elements + for (const prevElement of prevElements.values()) { + const nextElement = nextElements.get(prevElement.id); + + if (!nextElement) { + const deleted = { ...prevElement, isDeleted: false } as ElementPartial; + const inserted = { isDeleted: true } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + removed.set(prevElement.id, delta); + } + } + + for (const nextElement of nextElements.values()) { + const prevElement = prevElements.get(nextElement.id); + + if (!prevElement) { + const deleted = { isDeleted: true } as ElementPartial; + const inserted = { + ...nextElement, + isDeleted: false, + } as ElementPartial; + + const delta = Delta.create( + deleted, + inserted, + ElementsChange.stripIrrelevantProps, + ); + + added.set(nextElement.id, delta); + + continue; + } + + if (prevElement.versionNonce !== nextElement.versionNonce) { + const delta = Delta.calculate( + prevElement, + nextElement, + ElementsChange.stripIrrelevantProps, + ElementsChange.postProcess, + ); + + if ( + // making sure we don't get here some non-boolean values (i.e. undefined, null, etc.) + typeof prevElement.isDeleted === "boolean" && + typeof nextElement.isDeleted === "boolean" && + prevElement.isDeleted !== nextElement.isDeleted + ) { + // notice that other props could have been updated as well + if (prevElement.isDeleted && !nextElement.isDeleted) { + added.set(nextElement.id, delta); + } else { + removed.set(nextElement.id, delta); + } + + continue; + } + + // making sure there are at least some changes + if (!Delta.isEmpty(delta)) { + updated.set(nextElement.id, delta); + } + } + } + + return ElementsChange.create(added, removed, updated); + } + + public static empty() { + return ElementsChange.create(new Map(), new Map(), new Map()); + } + + public inverse(): ElementsChange { + const inverseInternal = (deltas: Map>) => { + const inversedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + inversedDeltas.set(id, Delta.create(delta.inserted, delta.deleted)); + } + + return inversedDeltas; + }; + + const added = inverseInternal(this.added); + const removed = inverseInternal(this.removed); + const updated = inverseInternal(this.updated); + + // notice we inverse removed with added not to break the invariants + return ElementsChange.create(removed, added, updated); + } + + public isEmpty(): boolean { + return ( + this.added.size === 0 && + this.removed.size === 0 && + this.updated.size === 0 + ); + } + + /** + * Update delta/s based on the existing elements. + * + * @param elements current elements + * @param modifierOptions defines which of the delta (`deleted` or `inserted`) will be updated + * @returns new instance with modified delta/s + */ + public applyLatestChanges(elements: SceneElementsMap): ElementsChange { + const modifier = + (element: OrderedExcalidrawElement) => (partial: ElementPartial) => { + const latestPartial: { [key: string]: unknown } = {}; + + for (const key of Object.keys(partial) as Array) { + // do not update following props: + // - `boundElements`, as it is a reference value which is postprocessed to contain only deleted/inserted keys + switch (key) { + case "boundElements": + latestPartial[key] = partial[key]; + break; + default: + latestPartial[key] = element[key]; + } + } + + return latestPartial; + }; + + const applyLatestChangesInternal = ( + deltas: Map>, + ) => { + const modifiedDeltas = new Map>(); + + for (const [id, delta] of deltas.entries()) { + const existingElement = elements.get(id); + + if (existingElement) { + const modifiedDelta = Delta.create( + delta.deleted, + delta.inserted, + modifier(existingElement), + "inserted", + ); + + modifiedDeltas.set(id, modifiedDelta); + } else { + modifiedDeltas.set(id, delta); + } + } + + return modifiedDeltas; + }; + + const added = applyLatestChangesInternal(this.added); + const removed = applyLatestChangesInternal(this.removed); + const updated = applyLatestChangesInternal(this.updated); + + return ElementsChange.create(added, removed, updated, { + shouldRedistribute: true, // redistribute the deltas as `isDeleted` could have been updated + }); + } + + public applyTo( + elements: SceneElementsMap, + snapshot: Map, + ): [SceneElementsMap, boolean] { + let nextElements = toBrandedType(new Map(elements)); + let changedElements: Map; + + const flags = { + containsVisibleDifference: false, + containsZindexDifference: false, + }; + + // mimic a transaction by applying deltas into `nextElements` (always new instance, no mutation) + try { + const applyDeltas = ElementsChange.createApplier( + nextElements, + snapshot, + flags, + ); + + const addedElements = applyDeltas(this.added); + const removedElements = applyDeltas(this.removed); + const updatedElements = applyDeltas(this.updated); + + const affectedElements = this.resolveConflicts(elements, nextElements); + + // TODO: #7348 validate elements semantically and syntactically the changed elements, in case they would result data integrity issues + changedElements = new Map([ + ...addedElements, + ...removedElements, + ...updatedElements, + ...affectedElements, + ]); + } catch (e) { + console.error(`Couldn't apply elements change`, e); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + + // should not really happen, but just in case we cannot apply deltas, let's return the previous elements with visible change set to `true` + // even though there is obviously no visible change, returning `false` could be dangerous, as i.e.: + // in the worst case, it could lead into iterating through the whole stack with no possibility to redo + // instead, the worst case when returning `true` is an empty undo / redo + return [elements, true]; + } + + try { + // TODO: #7348 refactor away mutations below, so that we couldn't end up in an incosistent state + ElementsChange.redrawTextBoundingBoxes(nextElements, changedElements); + ElementsChange.redrawBoundArrows(nextElements, changedElements); + + // the following reorder performs also mutations, but only on new instances of changed elements + // (unless something goes really bad and it fallbacks to fixing all invalid indices) + nextElements = ElementsChange.reorderElements( + nextElements, + changedElements, + flags, + ); + } catch (e) { + console.error( + `Couldn't mutate elements after applying elements change`, + e, + ); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [nextElements, flags.containsVisibleDifference]; + } + } + + private static createApplier = ( + nextElements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => { + const getElement = ElementsChange.createGetter( + nextElements, + snapshot, + flags, + ); + + return (deltas: Map>) => + Array.from(deltas.entries()).reduce((acc, [id, delta]) => { + const element = getElement(id, delta.inserted); + + if (element) { + const newElement = ElementsChange.applyDelta(element, delta, flags); + nextElements.set(newElement.id, newElement); + acc.set(newElement.id, newElement); + } + + return acc; + }, new Map()); + }; + + private static createGetter = + ( + elements: SceneElementsMap, + snapshot: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) => + (id: string, partial: ElementPartial) => { + let element = elements.get(id); + + if (!element) { + // always fallback to the local snapshot, in cases when we cannot find the element in the elements array + element = snapshot.get(id); + + if (element) { + // as the element was brought from the snapshot, it automatically results in a possible zindex difference + flags.containsZindexDifference = true; + + // as the element was force deleted, we need to check if adding it back results in a visible change + if ( + partial.isDeleted === false || + (partial.isDeleted !== true && element.isDeleted === false) + ) { + flags.containsVisibleDifference = true; + } + } + } + + return element; + }; + + private static applyDelta( + element: OrderedExcalidrawElement, + delta: Delta, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + } = { + // by default we don't care about about the flags + containsVisibleDifference: true, + containsZindexDifference: true, + }, + ) { + const { boundElements, ...directlyApplicablePartial } = delta.inserted; + + if ( + delta.deleted.boundElements?.length || + delta.inserted.boundElements?.length + ) { + const mergedBoundElements = Delta.mergeArrays( + element.boundElements, + delta.inserted.boundElements, + delta.deleted.boundElements, + (x) => x.id, + ); + + Object.assign(directlyApplicablePartial, { + boundElements: mergedBoundElements, + }); + } + + if (!flags.containsVisibleDifference) { + // strip away fractional as even if it would be different, it doesn't have to result in visible change + const { index, ...rest } = directlyApplicablePartial; + const containsVisibleDifference = + ElementsChange.checkForVisibleDifference(element, rest); + + flags.containsVisibleDifference = containsVisibleDifference; + } + + if (!flags.containsZindexDifference) { + flags.containsZindexDifference = + delta.deleted.index !== delta.inserted.index; + } + + return newElementWith(element, directlyApplicablePartial); + } + + /** + * Check for visible changes regardless of whether they were removed, added or updated. + */ + private static checkForVisibleDifference( + element: OrderedExcalidrawElement, + partial: ElementPartial, + ) { + if (element.isDeleted && partial.isDeleted !== false) { + // when it's deleted and partial is not false, it cannot end up with a visible change + return false; + } + + if (element.isDeleted && partial.isDeleted === false) { + // when we add an element, it results in a visible change + return true; + } + + if (element.isDeleted === false && partial.isDeleted) { + // when we remove an element, it results in a visible change + return true; + } + + // check for any difference on a visible element + return Delta.isRightDifferent(element, partial); + } + + /** + * Resolves conflicts for all previously added, removed and updated elements. + * Updates the previous deltas with all the changes after conflict resolution. + * + * @returns all elements affected by the conflict resolution + */ + private resolveConflicts( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + ) { + const nextAffectedElements = new Map(); + const updater = ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => { + const nextElement = nextElements.get(element.id); // only ever modify next element! + if (!nextElement) { + return; + } + + let affectedElement: OrderedExcalidrawElement; + + if (prevElements.get(element.id) === nextElement) { + // create the new element instance in case we didn't modify the element yet + // so that we won't end up in an incosistent state in case we would fail in the middle of mutations + affectedElement = newElementWith( + nextElement, + updates as ElementUpdate, + ); + } else { + affectedElement = mutateElement( + nextElement, + updates as ElementUpdate, + ); + } + + nextAffectedElements.set(affectedElement.id, affectedElement); + nextElements.set(affectedElement.id, affectedElement); + }; + + // removed delta is affecting the bindings always, as all the affected elements of the removed elements need to be unbound + for (const [id] of this.removed) { + ElementsChange.unbindAffected(prevElements, nextElements, id, updater); + } + + // added delta is affecting the bindings always, all the affected elements of the added elements need to be rebound + for (const [id] of this.added) { + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // updated delta is affecting the binding only in case it contains changed binding or bindable property + for (const [id] of Array.from(this.updated).filter(([_, delta]) => + Object.keys({ ...delta.deleted, ...delta.inserted }).find((prop) => + bindingProperties.has(prop as BindingProp | BindableProp), + ), + )) { + const updatedElement = nextElements.get(id); + if (!updatedElement || updatedElement.isDeleted) { + // skip fixing bindings for updates on deleted elements + continue; + } + + ElementsChange.rebindAffected(prevElements, nextElements, id, updater); + } + + // filter only previous elements, which were now affected + const prevAffectedElements = new Map( + Array.from(prevElements).filter(([id]) => nextAffectedElements.has(id)), + ); + + // calculate complete deltas for affected elements, and assign them back to all the deltas + // technically we could do better here if perf. would become an issue + const { added, removed, updated } = ElementsChange.calculate( + prevAffectedElements, + nextAffectedElements, + ); + + for (const [id, delta] of added) { + this.added.set(id, delta); + } + + for (const [id, delta] of removed) { + this.removed.set(id, delta); + } + + for (const [id, delta] of updated) { + this.updated.set(id, delta); + } + + return nextAffectedElements; + } + + /** + * Non deleted affected elements of removed elements (before and after applying delta), + * should be unbound ~ bindings should not point from non deleted into the deleted element/s. + */ + private static unbindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before removal + const nextElement = () => nextElements.get(id); // element after removal + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.unbindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected(nextElements, prevElement(), updater); + BindableElement.unbindAffected(nextElements, nextElement(), updater); + } + + /** + * Non deleted affected elements of added or updated element/s (before and after applying delta), + * should be rebound (if possible) with the current element ~ bindings should be bidirectional. + */ + private static rebindAffected( + prevElements: SceneElementsMap, + nextElements: SceneElementsMap, + id: string, + updater: ( + element: ExcalidrawElement, + updates: ElementUpdate, + ) => void, + ) { + // the instance could have been updated, so make sure we are passing the latest element to each function below + const prevElement = () => prevElements.get(id); // element before addition / update + const nextElement = () => nextElements.get(id); // element after addition / update + + BoundElement.unbindAffected(nextElements, prevElement(), updater); + BoundElement.rebindAffected(nextElements, nextElement(), updater); + + BindableElement.unbindAffected( + nextElements, + prevElement(), + (element, updates) => { + // we cannot rebind arrows with bindable element so we don't unbind them at all during rebind (we still need to unbind them on removal) + // TODO: #7348 add startBinding / endBinding to the `BoundElement` context so that we could rebind arrows and remove this condition + if (isTextElement(element)) { + updater(element, updates); + } + }, + ); + BindableElement.rebindAffected(nextElements, nextElement(), updater); + } + + private static redrawTextBoundingBoxes( + elements: SceneElementsMap, + changed: Map, + ) { + const boxesToRedraw = new Map< + string, + { container: OrderedExcalidrawElement; boundText: ExcalidrawTextElement } + >(); + + for (const element of changed.values()) { + if (isBoundToContainer(element)) { + const { containerId } = element as ExcalidrawTextElement; + const container = containerId ? elements.get(containerId) : undefined; + + if (container) { + boxesToRedraw.set(container.id, { + container, + boundText: element as ExcalidrawTextElement, + }); + } + } + + if (hasBoundTextElement(element)) { + const boundTextElementId = getBoundTextElementId(element); + const boundText = boundTextElementId + ? elements.get(boundTextElementId) + : undefined; + + if (boundText) { + boxesToRedraw.set(element.id, { + container: element, + boundText: boundText as ExcalidrawTextElement, + }); + } + } + } + + for (const { container, boundText } of boxesToRedraw.values()) { + if (container.isDeleted || boundText.isDeleted) { + // skip redraw if one of them is deleted, as it would not result in a meaningful redraw + continue; + } + + redrawTextBoundingBox(boundText, container, elements, false); + } + } + + private static redrawBoundArrows( + elements: SceneElementsMap, + changed: Map, + ) { + for (const element of changed.values()) { + if (!element.isDeleted && isBindableElement(element)) { + updateBoundElements(element, elements); + } + } + } + + private static reorderElements( + elements: SceneElementsMap, + changed: Map, + flags: { + containsVisibleDifference: boolean; + containsZindexDifference: boolean; + }, + ) { + if (!flags.containsZindexDifference) { + return elements; + } + + const previous = Array.from(elements.values()); + const reordered = orderByFractionalIndex([...previous]); + + if ( + !flags.containsVisibleDifference && + Delta.isRightDifferent(previous, reordered, true) + ) { + // we found a difference in order! + flags.containsVisibleDifference = true; + } + + // let's synchronize all invalid indices of moved elements + return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements; + } + + /** + * It is necessary to post process the partials in case of reference values, + * for which we need to calculate the real diff between `deleted` and `inserted`. + */ + private static postProcess( + deleted: ElementPartial, + inserted: ElementPartial, + ): [ElementPartial, ElementPartial] { + try { + Delta.diffArrays(deleted, inserted, "boundElements", (x) => x.id); + } catch (e) { + // if postprocessing fails, it does not make sense to bubble up, but let's make sure we know about it + console.error(`Couldn't postprocess elements change deltas.`); + + if (import.meta.env.DEV || import.meta.env.MODE === ENV.TEST) { + throw e; + } + } finally { + return [deleted, inserted]; + } + } + + private static stripIrrelevantProps( + partial: Partial, + ): ElementPartial { + const { id, updated, version, versionNonce, seed, ...strippedPartial } = + partial; + + return strippedPartial; + } +} diff --git a/packages/excalidraw/components/Actions.scss b/packages/excalidraw/components/Actions.scss index df0d73755..5826628de 100644 --- a/packages/excalidraw/components/Actions.scss +++ b/packages/excalidraw/components/Actions.scss @@ -12,6 +12,7 @@ font-size: 0.875rem !important; width: var(--lg-button-size); height: var(--lg-button-size); + svg { width: var(--lg-icon-size) !important; height: var(--lg-icon-size) !important; diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 8ceb362a5..f9c41074b 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -183,6 +183,7 @@ import { ExcalidrawIframeElement, ExcalidrawEmbeddableElement, Ordered, + OrderedExcalidrawElement, } from "../element/types"; import { getCenter, getDistance } from "../gesture"; import { @@ -194,7 +195,7 @@ import { isSelectedViaGroup, selectGroupsForSelectedElements, } from "../groups"; -import History from "../history"; +import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; import { CODES, @@ -278,11 +279,12 @@ import { muteFSAbortError, isTestEnv, easeOut, - arrayToMap, updateStable, addEventListener, normalizeEOL, getDateTime, + isShallowEqual, + arrayToMap, } from "../utils"; import { createSrcDoc, @@ -410,6 +412,7 @@ import { ElementCanvasButton } from "./MagicButton"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { EditorLocalStorage } from "../data/EditorLocalStorage"; import FollowMode from "./FollowMode/FollowMode"; +import { IStore, Store, StoreAction } from "../store"; import { AnimationFrameHandler } from "../animation-frame-handler"; import { AnimatedTrail } from "../animated-trail"; import { LaserTrails } from "../laser-trails"; @@ -540,6 +543,7 @@ class App extends React.Component { public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; + private store: IStore; private history: History; private excalidrawContainerValue: { container: HTMLDivElement | null; @@ -665,6 +669,10 @@ class App extends React.Component { this.canvas = document.createElement("canvas"); this.rc = rough.canvas(this.canvas); this.renderer = new Renderer(this.scene); + + this.store = new Store(); + this.history = new History(); + if (excalidrawAPI) { const api: ExcalidrawImperativeAPI = { updateScene: this.updateScene, @@ -714,10 +722,14 @@ class App extends React.Component { onSceneUpdated: this.onSceneUpdated, }); this.history = new History(); - this.actionManager.registerAll(actions); - this.actionManager.registerAction(createUndoAction(this.history)); - this.actionManager.registerAction(createRedoAction(this.history)); + this.actionManager.registerAll(actions); + this.actionManager.registerAction( + createUndoAction(this.history, this.store), + ); + this.actionManager.registerAction( + createRedoAction(this.history, this.store), + ); } private onWindowMessage(event: MessageEvent) { @@ -2092,12 +2104,12 @@ class App extends React.Component { if (shouldUpdateStrokeColor) { this.syncActionResult({ appState: { ...this.state, currentItemStrokeColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else { this.syncActionResult({ appState: { ...this.state, currentItemBackgroundColor: color }, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } } else { @@ -2111,6 +2123,7 @@ class App extends React.Component { } return el; }), + commitToStore: true, }); } }, @@ -2135,10 +2148,14 @@ class App extends React.Component { editingElement = element; } }); - this.scene.replaceAllElements(actionResult.elements); - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } + + this.scene.replaceAllElements(actionResult.elements); } if (actionResult.files) { @@ -2149,8 +2166,10 @@ class App extends React.Component { } if (actionResult.appState || editingElement || this.state.contextMenu) { - if (actionResult.commitToHistory) { - this.history.resumeRecording(); + if (actionResult.storeAction === StoreAction.UPDATE) { + this.store.shouldUpdateSnapshot(); + } else if (actionResult.storeAction === StoreAction.CAPTURE) { + this.store.shouldCaptureIncrement(); } let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false; @@ -2180,34 +2199,24 @@ class App extends React.Component { editingElement = null; } - this.setState( - (state) => { - // using Object.assign instead of spread to fool TS 4.2.2+ into - // regarding the resulting type as not containing undefined - // (which the following expression will never contain) - return Object.assign(actionResult.appState || {}, { - // NOTE this will prevent opening context menu using an action - // or programmatically from the host, so it will need to be - // rewritten later - contextMenu: null, - editingElement, - viewModeEnabled, - zenModeEnabled, - gridSize, - theme, - name, - errorMessage, - }); - }, - () => { - if (actionResult.syncHistory) { - this.history.setCurrentState( - this.state, - this.scene.getElementsIncludingDeleted(), - ); - } - }, - ); + this.setState((state) => { + // using Object.assign instead of spread to fool TS 4.2.2+ into + // regarding the resulting type as not containing undefined + // (which the following expression will never contain) + return Object.assign(actionResult.appState || {}, { + // NOTE this will prevent opening context menu using an action + // or programmatically from the host, so it will need to be + // rewritten later + contextMenu: null, + editingElement, + viewModeEnabled, + zenModeEnabled, + gridSize, + theme, + name, + errorMessage, + }); + }); } }, ); @@ -2231,6 +2240,10 @@ class App extends React.Component { this.history.clear(); }; + private resetStore = () => { + this.store.clear(); + }; + /** * Resets scene & history. * ! Do not use to clear scene user action ! @@ -2243,6 +2256,7 @@ class App extends React.Component { isLoading: opts?.resetLoadingState ? false : state.isLoading, theme: this.state.theme, })); + this.resetStore(); this.resetHistory(); }, ); @@ -2327,10 +2341,11 @@ class App extends React.Component { // seems faster even in browsers that do fire the loadingdone event. this.fonts.loadFontsForElements(scene.elements); + this.resetStore(); this.resetHistory(); this.syncActionResult({ ...scene, - commitToHistory: true, + storeAction: StoreAction.UPDATE, }); }; @@ -2420,9 +2435,17 @@ class App extends React.Component { configurable: true, value: this.history, }, + store: { + configurable: true, + value: this.store, + }, }); } + this.store.onStoreIncrementEmitter.on((increment) => { + this.history.record(increment.elementsChange, increment.appStateChange); + }); + this.scene.addCallback(this.onSceneUpdated); this.addEventListeners(); @@ -2479,6 +2502,7 @@ class App extends React.Component { this.laserTrails.stop(); this.eraserTrail.stop(); this.onChangeEmitter.clear(); + this.store.onStoreIncrementEmitter.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2623,7 +2647,8 @@ class App extends React.Component { componentDidUpdate(prevProps: AppProps, prevState: AppState) { this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); - const elementsMap = this.scene.getNonDeletedElementsMap(); + const elementsMap = this.scene.getElementsMapIncludingDeleted(); + const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap(); if (!this.state.showWelcomeScreen && !elements.length) { this.setState({ showWelcomeScreen: true }); @@ -2739,7 +2764,7 @@ class App extends React.Component { this.state.editingLinearElement && !this.state.selectedElementIds[this.state.editingLinearElement.elementId] ) { - // defer so that the commitToHistory flag isn't reset via current update + // defer so that the storeAction flag isn't reset via current update setTimeout(() => { // execute only if the condition still holds when the deferred callback // executes (it can be scheduled multiple times depending on how @@ -2778,13 +2803,14 @@ class App extends React.Component { LinearElementEditor.getPointAtIndexGlobalCoordinates( multiElement, -1, - elementsMap, + nonDeletedElementsMap, ), ), this, ); } - this.history.record(this.state, elements); + + this.store.capture(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other // potential issues, this fixes a case where the tab isn't focused during @@ -3154,7 +3180,7 @@ class App extends React.Component { this.files = { ...this.files, ...opts.files }; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); const nextElementsToSelect = excludeElementsInFramesFromSelection(newElements); @@ -3389,7 +3415,7 @@ class App extends React.Component { PLAIN_PASTE_TOAST_SHOWN = true; } - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } setAppState: React.Component["setState"] = ( @@ -3657,10 +3683,51 @@ class App extends React.Component { elements?: SceneData["elements"]; appState?: Pick | null; collaborators?: SceneData["collaborators"]; - commitToHistory?: SceneData["commitToHistory"]; + commitToStore?: SceneData["commitToStore"]; }) => { - if (sceneData.commitToHistory) { - this.history.resumeRecording(); + const nextElements = syncInvalidIndices(sceneData.elements ?? []); + + if (sceneData.commitToStore) { + this.store.shouldCaptureIncrement(); + } + + if (sceneData.elements || sceneData.appState) { + let nextCommittedAppState = this.state; + let nextCommittedElements: Map; + + if (sceneData.appState) { + nextCommittedAppState = { + ...this.state, + ...sceneData.appState, // Here we expect just partial appState + }; + } + + const prevElements = this.scene.getElementsIncludingDeleted(); + + if (sceneData.elements) { + /** + * We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update), + * as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff). + * + * This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true, + * as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history). + * + * WARN: be careful here as moving it elsewhere could break the history for remote client without noticing + * - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories. + */ + this.store.shouldUpdateSnapshot(); + + // TODO#7348: deprecate once exchanging just store increments between clients + nextCommittedElements = this.store.ignoreUncomittedElements( + arrayToMap(prevElements), + arrayToMap(nextElements), + ); + } else { + nextCommittedElements = arrayToMap(prevElements); + } + + // WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter + this.store.capture(nextCommittedElements, nextCommittedAppState); } if (sceneData.appState) { @@ -3668,7 +3735,7 @@ class App extends React.Component { } if (sceneData.elements) { - this.scene.replaceAllElements(sceneData.elements); + this.scene.replaceAllElements(nextElements); } if (sceneData.collaborators) { @@ -3896,7 +3963,7 @@ class App extends React.Component { this.state.editingLinearElement.elementId !== selectedElements[0].id ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor( selectedElement, @@ -4308,7 +4375,7 @@ class App extends React.Component { ]); } if (!isDeleted || isExistingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } this.setState({ @@ -4793,7 +4860,7 @@ class App extends React.Component { (!this.state.editingLinearElement || this.state.editingLinearElement.elementId !== selectedElements[0].id) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.setState({ editingLinearElement: new LinearElementEditor(selectedElements[0]), }); @@ -4818,6 +4885,7 @@ class App extends React.Component { getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds); if (selectedGroupId) { + this.store.shouldCaptureIncrement(); this.setState((prevState) => ({ ...prevState, ...selectGroupsForSelectedElements( @@ -6300,7 +6368,7 @@ class App extends React.Component { const ret = LinearElementEditor.handlePointerDown( event, this.state, - this.history, + this.store, pointerDownState.origin, linearElementEditor, this, @@ -7848,7 +7916,7 @@ class App extends React.Component { if (isLinearElement(draggingElement)) { if (draggingElement!.points.length > 1) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } const pointerCoords = viewportCoordsToSceneCoords( childEvent, @@ -7917,14 +7985,16 @@ class App extends React.Component { isInvisiblySmallElement(draggingElement) ) { // remove invisible element which was added in onPointerDown - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== draggingElement.id), - ); - this.setState({ - draggingElement: null, + appState: { + draggingElement: null, + }, }); + return; } @@ -8086,15 +8156,16 @@ class App extends React.Component { } if (resizingElement) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (resizingElement && isInvisiblySmallElement(resizingElement)) { - this.scene.replaceAllElements( - this.scene + // update the store snapshot, so that invisible elements are not captured by the store + this.updateScene({ + elements: this.scene .getElementsIncludingDeleted() .filter((el) => el.id !== resizingElement.id), - ); + }); } // handle frame membership for resizing frames and/or selected elements @@ -8395,9 +8466,13 @@ class App extends React.Component { if ( activeTool.type !== "selection" || - isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) + isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) || + !isShallowEqual( + this.state.previousSelectedElementIds, + this.state.selectedElementIds, + ) ) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); } if (pointerDownState.drag.hasOccurred || isResizing || isRotating) { @@ -8475,7 +8550,7 @@ class App extends React.Component { this.elementsPendingErasure = new Set(); if (didChange) { - this.history.resumeRecording(); + this.store.shouldCaptureIncrement(); this.scene.replaceAllElements(elements); } }; @@ -9038,7 +9113,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); return; } catch (error: any) { @@ -9118,12 +9193,13 @@ class App extends React.Component { ) => { file = await normalizeFile(file); try { + const elements = this.scene.getElementsIncludingDeleted(); let ret; try { ret = await loadSceneOrLibraryFromBlob( file, this.state, - this.scene.getElementsIncludingDeleted(), + elements, fileHandle, ); } catch (error: any) { @@ -9152,6 +9228,13 @@ class App extends React.Component { } if (ret.type === MIME_TYPES.excalidraw) { + // Restore the fractional indices by mutating elements and update the + // store snapshot, otherwise we would end up with duplicate indices + syncInvalidIndices(elements.concat(ret.data.elements)); + this.store.snapshot = this.store.snapshot.clone( + arrayToMap(elements), + this.state, + ); this.setState({ isLoading: true }); this.syncActionResult({ ...ret.data, @@ -9160,7 +9243,7 @@ class App extends React.Component { isLoading: false, }, replaceFiles: true, - commitToHistory: true, + storeAction: StoreAction.CAPTURE, }); } else if (ret.type === MIME_TYPES.excalidrawlib) { await this.library @@ -9770,6 +9853,7 @@ declare global { setState: React.Component["setState"]; app: InstanceType; history: History; + store: Store; }; } } diff --git a/packages/excalidraw/components/ToolButton.tsx b/packages/excalidraw/components/ToolButton.tsx index 2dace89d7..e6d14ba08 100644 --- a/packages/excalidraw/components/ToolButton.tsx +++ b/packages/excalidraw/components/ToolButton.tsx @@ -25,6 +25,7 @@ type ToolButtonBaseProps = { hidden?: boolean; visible?: boolean; selected?: boolean; + disabled?: boolean; className?: string; style?: CSSProperties; isLoading?: boolean; @@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => { type={type} onClick={onClick} ref={innerRef} - disabled={isLoading || props.isLoading} + disabled={isLoading || props.isLoading || !!props.disabled} > {(props.icon || props.label) && ( -