diff --git a/.codesandbox/Dockerfile b/.codesandbox/Dockerfile index fd5b38d1e8..ce8c857650 100644 --- a/.codesandbox/Dockerfile +++ b/.codesandbox/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bullseye +FROM node:24-bullseye # Vite wants to open the browser using `open`, so we # need to install those utils. diff --git a/.env.development b/.env.development index bf641c34c8..5e2d3dfe36 100644 --- a/.env.development +++ b/.env.development @@ -12,7 +12,7 @@ VITE_APP_WS_SERVER_URL=http://localhost:3002 VITE_APP_PLUS_LP=https://plus.excalidraw.com VITE_APP_PLUS_APP=http://localhost:3000 -VITE_APP_AI_BACKEND=http://localhost:3015 +VITE_APP_AI_BACKEND=http://localhost:3016 VITE_APP_FIREBASE_CONFIG='{"apiKey":"AIzaSyCMkxA60XIW8KbqMYL7edC4qT5l4qHX2h8","authDomain":"excalidraw-oss-dev.firebaseapp.com","projectId":"excalidraw-oss-dev","storageBucket":"excalidraw-oss-dev.appspot.com","messagingSenderId":"664559512677","appId":"1:664559512677:web:a385181f2928d328a7aa8c"}' @@ -27,7 +27,7 @@ VITE_APP_ENABLE_TRACKING=true FAST_REFRESH=false # The port the run the dev server -VITE_APP_PORT=3000 +VITE_APP_PORT=3001 #Debug flags diff --git a/.eslintrc.json b/.eslintrc.json index 89f8227361..708210535c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -39,5 +39,26 @@ "allowReferrer": true } ] - } + }, + "overrides": [ + { + "files": ["packages/excalidraw/**/*.{ts,tsx}"], + "excludedFiles": ["packages/excalidraw/**/*.test.{ts,tsx}", "packages/excalidraw/**/*.test.*.{ts,tsx}"], + "rules": { + "@typescript-eslint/no-restricted-imports": [ + "error", + { + "patterns": [ + { + "group": ["@excalidraw/excalidraw"], + "message": "Do not import from the barrel 'index.tsx' files. Use direct relative imports to the specific module instead.", + "allowTypeImports": true + } + ], + "paths": [".", "..", "../..", "../../..", "../../../..", "../../../../..", "../index", "../../index", "../../../index", "../../../../index"] + } + ] + } + } + ] } diff --git a/.github/workflows/autorelease-excalidraw.yml b/.github/workflows/autorelease-excalidraw.yml index 6e2c0d00e0..c365647ee8 100644 --- a/.github/workflows/autorelease-excalidraw.yml +++ b/.github/workflows/autorelease-excalidraw.yml @@ -12,10 +12,10 @@ jobs: - uses: actions/checkout@v2 with: fetch-depth: 2 - - name: Setup Node.js 18.x + - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x - name: Set up publish access run: | npm config set //registry.npmjs.org/:_authToken ${NPM_TOKEN} diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml index e1ef216651..a572df9bed 100644 --- a/.github/workflows/cancel.yml +++ b/.github/workflows/cancel.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: - - uses: styfle/cancel-workflow-action@0.6.0 + - uses: styfle/cancel-workflow-action@ce177499ccf9fd2aded3b0426c97e5434c2e8a73 # 0.6.0 with: workflow_id: 400555, 400556, 905313, 1451724, 1710116, 3185001, 3438604 access_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 82f826361c..cc73980d10 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -9,10 +9,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Setup Node.js 18.x + - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x - name: Install and lint run: | diff --git a/.github/workflows/locales-coverage.yml b/.github/workflows/locales-coverage.yml index 957e9bc37c..67a942438b 100644 --- a/.github/workflows/locales-coverage.yml +++ b/.github/workflows/locales-coverage.yml @@ -14,10 +14,10 @@ jobs: with: token: ${{ secrets.PUSH_TRANSLATIONS_COVERAGE_PAT }} - - name: Setup Node.js 18.x + - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x - name: Create report file run: | @@ -40,7 +40,7 @@ jobs: echo ::set-output name=body::$body - name: Update description with coverage - uses: kt3k/update-pr-description@v1.0.1 + uses: kt3k/update-pr-description@1b35a6dcd84d81aa0bc1889610efdcde7f37b0c0 # v1.0.1 with: pr_body: ${{ steps.getCommentBody.outputs.body }} pr_title: "chore: Update translations from Crowdin" diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 68eee27755..d0aedcb26b 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -13,16 +13,16 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 - name: Login to DockerHub - uses: docker/login-action@v2 + uses: docker/login-action@465a07811f14bebb1938fbed4728c6a1ff8901fc # v2 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5 with: context: . push: true diff --git a/.github/workflows/semantic-pr-title.yml b/.github/workflows/semantic-pr-title.yml index 34a6413fe2..8bfc1327b4 100644 --- a/.github/workflows/semantic-pr-title.yml +++ b/.github/workflows/semantic-pr-title.yml @@ -11,6 +11,6 @@ jobs: semantic: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/sentry-production.yml b/.github/workflows/sentry-production.yml index cea4cf63d6..4434873fd3 100644 --- a/.github/workflows/sentry-production.yml +++ b/.github/workflows/sentry-production.yml @@ -10,10 +10,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - - name: Setup Node.js 18.x + - name: Setup Node.js uses: actions/setup-node@v2 with: - node-version: 18.x + node-version: 20.x - name: Install and build run: | yarn --frozen-lockfile @@ -28,7 +28,7 @@ jobs: export SENTRY_RELEASE=$(sentry-cli releases propose-version) sentry-cli releases new $SENTRY_RELEASE --project $SENTRY_PROJECT sentry-cli releases set-commits --auto $SENTRY_RELEASE - sentry-cli releases files $SENTRY_RELEASE upload-sourcemaps --no-rewrite ./build/static/js/ --url-prefix "~/static/js" + sentry-cli sourcemaps upload --release $SENTRY_RELEASE --no-rewrite ./build/static/js/ --url-prefix "~/static/js" sentry-cli releases finalize $SENTRY_RELEASE sentry-cli releases deploys $SENTRY_RELEASE new -e production env: diff --git a/.github/workflows/size-limit.yml b/.github/workflows/size-limit.yml index 5bd3c0d92b..2a24507630 100644 --- a/.github/workflows/size-limit.yml +++ b/.github/workflows/size-limit.yml @@ -11,16 +11,16 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v3 - - name: Setup Node.js 18.x + - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 18.x + node-version: 20.x - name: Install in packages/excalidraw run: yarn working-directory: packages/excalidraw env: CI: true - - uses: andresz1/size-limit-action@v1 + - uses: andresz1/size-limit-action@e7493a72a44b113341c0cf6186ab49c17c4b65c1 # v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} build_script: build:esm diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 7ff40ad5d2..0cb6327564 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -14,13 +14,13 @@ jobs: - name: "Install Node" uses: actions/setup-node@v2 with: - node-version: "18.x" + node-version: "20.x" - name: "Install Deps" run: yarn install - name: "Test Coverage" run: yarn test:coverage - name: "Report Coverage" if: always() # Also generate the report if tests are failing - uses: davelosert/vitest-coverage-report-action@v2 + uses: davelosert/vitest-coverage-report-action@2500dafcee7dd64f85ab689c0b83798a8359770e # v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d454ecfc4..8bebd6c1ee 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,10 +9,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup Node.js 18.x + - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: 20.x - name: Install and test run: | yarn install diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 3c032078a4..0000000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -18 diff --git a/Dockerfile b/Dockerfile index c08385d654..e15b425704 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=${BUILDPLATFORM} node:18 AS build +FROM --platform=${BUILDPLATFORM} node:24 AS build WORKDIR /opt/node_app @@ -13,7 +13,7 @@ ARG NODE_ENV=production RUN npm_config_target_arch=${TARGETARCH} yarn build:app:docker -FROM --platform=${TARGETPLATFORM} nginx:1.27-alpine +FROM nginx:1.27-alpine COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html diff --git a/README.md b/README.md index f1cf030539..48b9d1de9f 100644 --- a/README.md +++ b/README.md @@ -23,23 +23,17 @@

- Excalidraw is released under the MIT license. - + Excalidraw is released under the MIT license. - npm downloads/month - + npm downloads/month - PRs welcome! - + PRs welcome! - Chat on Discord - + Chat on Discord - Ask DeepWiki - + Ask DeepWiki - Follow Excalidraw on Twitter - + Follow Excalidraw on Twitter

diff --git a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx index e7852cee94..39e8e18425 100644 --- a/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx +++ b/dev-docs/docs/@excalidraw/excalidraw/api/children-components/footer.mdx @@ -9,7 +9,7 @@ You will need to import the `Footer` component from the package and wrap your co ```jsx live function App() { return ( -
+
+ + {isOpen && ( + {}} + > +
+ {showFillIcons && renderAction("changeFillStyle")} + {(hasStrokeWidth(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeWidth(element.type), + )) && + renderAction("changeStrokeWidth")} + {(hasStrokeStyle(appState.activeTool.type) || + targetElements.some((element) => + hasStrokeStyle(element.type), + )) && ( + <> + {renderAction("changeStrokeStyle")} + {renderAction("changeSloppiness")} + + )} + {(canChangeRoundness(appState.activeTool.type) || + targetElements.some((element) => + canChangeRoundness(element.type), + )) && + renderAction("changeRoundness")} + {renderAction("changeOpacity")} +
+
+ )} + +
+ ); +}; + +const CombinedArrowProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + app, +}: { + targetElements: ExcalidrawElement[]; + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; +}) => { + const showShowArrowProperties = + toolIsArrow(appState.activeTool.type) || + targetElements.some((element) => toolIsArrow(element.type)); + const isOpen = appState.openPopup === "compactArrowProperties"; + + if (!showShowArrowProperties) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactArrowProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > + {renderAction("changeArrowProperties")} + + )} + +
+ ); +}; + +const CombinedTextProperties = ({ + appState, + renderAction, + setAppState, + targetElements, + container, + elementsMap, +}: { + appState: UIAppState; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + targetElements: ExcalidrawElement[]; + container: HTMLDivElement | null; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; +}) => { + const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus(); + const isOpen = appState.openPopup === "compactTextProperties"; + + return ( +
+ { + if (open) { + if (appState.editingTextElement) { + saveCaretPosition(); + } + setAppState({ openPopup: "compactTextProperties" }); + } else { + setAppState({ openPopup: null }); + if (appState.editingTextElement) { + restoreCaretPosition(); + } + } + }} + > + + + + {appState.openPopup === "compactTextProperties" && ( + { + // Refocus text editor when popover closes with caret restoration + if (appState.editingTextElement) { + restoreCaretPosition(); + } + }} + > +
+ {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && + renderAction("changeFontSize")} + {(appState.activeTool.type === "text" || + suppportsHorizontalAlign(targetElements, elementsMap)) && + renderAction("changeTextAlign")} + {shouldAllowVerticalAlign(targetElements, elementsMap) && + renderAction("changeVerticalAlign")} +
+
+ )} +
+
+ ); +}; + +const CombinedExtraActions = ({ + appState, + renderAction, + targetElements, + setAppState, + container, + app, + showDuplicate, + showDelete, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; + setAppState: React.Component["setState"]; + container: HTMLDivElement | null; + app: AppClassProperties; + showDuplicate?: boolean; + showDelete?: boolean; +}) => { + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); + const showCropEditorAction = + !appState.croppingElementId && + targetElements.length === 1 && + isImageElement(targetElements[0]); + const showLinkIcon = targetElements.length === 1; + const showAlignActions = alignActionsPredicate(appState, app); + let isSingleElementBoundContainer = false; + if ( + targetElements.length === 2 && + (hasBoundTextElement(targetElements[0]) || + hasBoundTextElement(targetElements[1])) + ) { + isSingleElementBoundContainer = true; + } + + const isRTL = document.documentElement.getAttribute("dir") === "rtl"; + const isOpen = appState.openPopup === "compactOtherProperties"; + + if (isEditingTextOrNewElement || targetElements.length === 0) { + return null; + } + + return ( +
+ { + if (open) { + setAppState({ openPopup: "compactOtherProperties" }); + } else { + setAppState({ openPopup: null }); + } + }} + > + + + + {isOpen && ( + {}} + > +
+
+ {t("labels.layers")} +
+ {renderAction("sendToBack")} + {renderAction("sendBackward")} + {renderAction("bringForward")} + {renderAction("bringToFront")} +
+
+ + {showAlignActions && !isSingleElementBoundContainer && ( +
+ {t("labels.align")} +
+ {isRTL ? ( + <> + {renderAction("alignRight")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignLeft")} + + ) : ( + <> + {renderAction("alignLeft")} + {renderAction("alignHorizontallyCentered")} + {renderAction("alignRight")} + + )} + {targetElements.length > 2 && + renderAction("distributeHorizontally")} + {/* breaks the row ˇˇ */} +
+
+ {renderAction("alignTop")} + {renderAction("alignVerticallyCentered")} + {renderAction("alignBottom")} + {targetElements.length > 2 && + renderAction("distributeVertically")} +
+
+
+ )} +
+ {t("labels.actions")} +
+ {renderAction("group")} + {renderAction("ungroup")} + {showLinkIcon && renderAction("hyperlink")} + {showCropEditorAction && renderAction("cropEditor")} + {showDuplicate && renderAction("duplicateSelection")} + {showDelete && renderAction("deleteSelectedElements")} +
+
+
+
+ )} +
+
+ ); +}; + +const LinearEditorAction = ({ + appState, + renderAction, + targetElements, +}: { + appState: UIAppState; + targetElements: ExcalidrawElement[]; + renderAction: ActionManager["renderAction"]; +}) => { + const showLineEditorAction = + !appState.selectedLinearElement?.isEditing && + targetElements.length === 1 && + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); + + if (!showLineEditorAction) { + return null; + } + + return ( +
+ {renderAction("toggleLinearEditor")} +
+ ); +}; + +export const CompactShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const { container } = useExcalidrawContainer(); + + const isEditingTextOrNewElement = Boolean( + appState.editingTextElement || appState.newElement, + ); + + const showLineEditorAction = + !appState.selectedLinearElement?.isEditing && + targetElements.length === 1 && + isLinearElement(targetElements[0]) && + !isElbowArrow(targetElements[0]); + + return ( +
+ {/* Stroke Color */} + {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + + {/* Background Color */} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + + + + {/* Linear Editor */} + {showLineEditorAction && ( +
+ {renderAction("toggleLinearEditor")} +
+ )} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+ + + )} + + {/* Dedicated Copy Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("duplicateSelection")} +
+ )} + + {/* Dedicated Delete Button */} + {!isEditingTextOrNewElement && targetElements.length > 0 && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} + + +
+ ); +}; + +export const MobileShapeActions = ({ + appState, + elementsMap, + renderAction, + app, + setAppState, +}: { + appState: UIAppState; + elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap; + renderAction: ActionManager["renderAction"]; + app: AppClassProperties; + setAppState: React.Component["setState"]; +}) => { + const targetElements = getTargetElements(elementsMap, appState); + const { container } = useExcalidrawContainer(); + const mobileActionsRef = useRef(null); + + const ACTIONS_WIDTH = + mobileActionsRef.current?.getBoundingClientRect()?.width ?? 0; + + // 7 actions + 2 for undo/redo + const MIN_ACTIONS = 9; + + const GAP = 6; + const WIDTH = 32; + + const MIN_WIDTH = MIN_ACTIONS * WIDTH + (MIN_ACTIONS - 1) * GAP; + + const ADDITIONAL_WIDTH = WIDTH + GAP; + + const showDeleteOutside = ACTIONS_WIDTH >= MIN_WIDTH + ADDITIONAL_WIDTH; + const showDuplicateOutside = + ACTIONS_WIDTH >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH; + + return ( + +
+ {canChangeStrokeColor(appState, targetElements) && ( +
+ {renderAction("changeStrokeColor")} +
+ )} + {canChangeBackgroundColor(appState, targetElements) && ( +
+ {renderAction("changeBackgroundColor")} +
+ )} + + {/* Combined Arrow Properties */} + + {/* Linear Editor */} + + {/* Text Properties */} + {(appState.activeTool.type === "text" || + targetElements.some(isTextElement)) && ( + <> +
+ {renderAction("changeFontFamily")} +
+ + + )} + + {/* Combined Other Actions */} + +
+
+
{renderAction("undo")}
+
{renderAction("redo")}
+ {showDuplicateOutside && ( +
+ {renderAction("duplicateSelection")} +
+ )} + {showDeleteOutside && ( +
+ {renderAction("deleteSelectedElements")} +
+ )} +
+
+ ); +}; + export const ShapesSwitcher = ({ activeTool, - appState, + setAppState, app, UIOptions, }: { activeTool: UIAppState["activeTool"]; - appState: UIAppState; + setAppState: React.Component["setState"]; app: AppClassProperties; UIOptions: AppProps["UIOptions"]; }) => { const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false); + const stylesPanelMode = useStylesPanelMode(); + const isFullStylesPanel = stylesPanelMode === "full"; + const isCompactStylesPanel = stylesPanelMode === "compact"; + + const SELECTION_TOOLS = [ + { + type: "selection", + icon: SelectionIcon, + title: capitalizeString(t("toolBar.selection")), + }, + { + type: "lasso", + icon: LassoIcon, + title: capitalizeString(t("toolBar.lasso")), + }, + ] as const; const frameToolSelected = activeTool.type === "frame"; const laserToolSelected = activeTool.type === "laser"; - const lassoToolSelected = activeTool.type === "lasso"; + const lassoToolSelected = + isFullStylesPanel && + activeTool.type === "lasso" && + app.state.preferredSelectionTool.type !== "lasso"; const embeddableToolSelected = activeTool.type === "embeddable"; @@ -303,63 +1080,106 @@ export const ShapesSwitcher = ({ return ( <> - {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { - if ( - UIOptions.tools?.[ - value as Extract - ] === false - ) { - return null; - } + {getToolbarTools(app).map( + ({ value, icon, key, numericKey, fillable, toolbar }) => { + if ( + toolbar === false || + UIOptions.tools?.[ + value as Extract< + typeof value, + keyof AppProps["UIOptions"]["tools"] + > + ] === false + ) { + return null; + } - const label = t(`toolBar.${value}`); - const letter = - key && capitalizeString(typeof key === "string" ? key : key[0]); - const shortcut = letter - ? `${letter} ${t("helpDialog.or")} ${numericKey}` - : `${numericKey}`; + const label = t(`toolBar.${value}`); + const letter = + key && capitalizeString(typeof key === "string" ? key : key[0]); + const shortcut = letter + ? `${letter} ${t("helpDialog.or")} ${numericKey}` + : `${numericKey}`; + const keybindingLabel = + value === "hand" ? undefined : numericKey || letter; - return ( - { - if (!appState.penDetected && pointerType === "pen") { - app.togglePenMode(true); - } - - if (value === "selection") { - if (appState.activeTool.type === "selection") { - app.setActiveTool({ type: "lasso" }); - } else { - app.setActiveTool({ type: "selection" }); + // when in compact styles panel mode (tablet) + // use a ToolPopover for selection/lasso toggle as well + if ( + (value === "selection" || value === "lasso") && + isCompactStylesPanel + ) { + return ( + { + if (type === "selection" || type === "lasso") { + app.setActiveTool({ type }); + setAppState({ + preferredSelectionTool: { type, initialized: true }, + }); + } + }} + displayedOption={ + SELECTION_TOOLS.find( + (tool) => + tool.type === app.state.preferredSelectionTool.type, + ) || SELECTION_TOOLS[0] } - } - }} - onChange={({ pointerType }) => { - if (appState.activeTool.type !== value) { - trackEvent("toolbar", value, "ui"); - } - if (value === "image") { - app.setActiveTool({ - type: value, - }); - } else { - app.setActiveTool({ type: value }); - } - }} - /> - ); - })} + fillable={activeTool.type === "selection"} + /> + ); + } + + return ( + { + if (!app.state.penDetected && pointerType === "pen") { + app.togglePenMode(true); + } + + if (value === "selection") { + if (app.state.activeTool.type === "selection") { + app.setActiveTool({ type: "lasso" }); + } else { + app.setActiveTool({ type: "selection" }); + } + } + }} + onChange={({ pointerType }) => { + if (app.state.activeTool.type !== value) { + trackEvent("toolbar", value, "ui"); + } + if (value === "image") { + app.setActiveTool({ + type: value, + }); + } else { + app.setActiveTool({ type: value }); + } + }} + /> + ); + }, + )}
@@ -374,7 +1194,10 @@ export const ShapesSwitcher = ({ // on top of it (laserToolSelected && !app.props.isCollaborating), })} - onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)} + onToggle={() => { + setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen); + setAppState({ openMenu: null, openPopup: null }); + }} title={t("toolBar.extraTools")} > {frameToolSelected @@ -418,14 +1241,16 @@ export const ShapesSwitcher = ({ > {t("toolBar.laser")} - app.setActiveTool({ type: "lasso" })} - icon={LassoIcon} - data-testid="toolbar-lasso" - selected={lassoToolSelected} - > - {t("toolBar.lasso")} - + {isFullStylesPanel && ( + app.setActiveTool({ type: "lasso" })} + icon={LassoIcon} + data-testid="toolbar-lasso" + selected={lassoToolSelected} + > + {t("toolBar.lasso")} + + )}
Generate
@@ -438,16 +1263,14 @@ export const ShapesSwitcher = ({ {t("toolBar.mermaidToExcalidraw")} {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( - <> - app.onMagicframeToolSelect()} - icon={MagicIcon} - data-testid="toolbar-magicframe" - > - {t("toolBar.magicframe")} - AI - - + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + badge={AI} + > + {t("toolBar.magicframe")} + )}
@@ -488,7 +1311,7 @@ export const UndoRedoActions = ({
); -export const ExitZenModeAction = ({ +export const ExitZenModeButton = ({ actionManager, showExitZenModeBtn, }: { @@ -505,3 +1328,17 @@ export const ExitZenModeAction = ({ {t("buttons.exitZenMode")} ); + +export const ExitViewModeButton = ({ + actionManager, +}: { + actionManager: ActionManager; +}) => ( + +); diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index b616d5d07a..5f0102be22 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -11,7 +11,6 @@ import { pointDistance, vector, pointRotateRads, - vectorScale, vectorFromPoint, vectorSubtract, vectorDot, @@ -29,6 +28,7 @@ import { KEYS, APP_NAME, CURSOR_TYPE, + DEFAULT_TRANSFORM_HANDLE_SPACING, DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT, DEFAULT_VERTICAL_ALIGN, DRAGGING_THRESHOLD, @@ -38,13 +38,9 @@ import { FRAME_STYLE, IMAGE_MIME_TYPES, IMAGE_RENDER_TIMEOUT, - isBrave, LINE_CONFIRM_THRESHOLD, MAX_ALLOWED_FILE_BYTES, MIME_TYPES, - MQ_MAX_HEIGHT_LANDSCAPE, - MQ_MAX_WIDTH_LANDSCAPE, - MQ_MAX_WIDTH_PORTRAIT, MQ_RIGHT_SIDEBAR_MIN_WIDTH, POINTER_BUTTON, ROUNDNESS, @@ -52,20 +48,17 @@ import { TAP_TWICE_TIMEOUT, TEXT_TO_CENTER_SNAP_THRESHOLD, THEME, - THEME_FILTER, TOUCH_CTX_MENU_TIMEOUT, VERTICAL_ALIGN, YOUTUBE_STATES, ZOOM_STEP, POINTER_EVENTS, TOOL_TYPE, - isIOS, supportsResizeObserver, DEFAULT_COLLISION_THRESHOLD, DEFAULT_TEXT_ALIGN, ARROW_TYPE, DEFAULT_REDUCED_GLOBAL_ALPHA, - isSafari, isLocalLink, normalizeLink, toValidURL, @@ -84,7 +77,6 @@ import { wrapEvent, updateObject, updateActiveTool, - getShortcutKey, isTransparent, easeToValuesRAF, muteFSAbortError, @@ -97,25 +89,40 @@ import { getDateTime, isShallowEqual, arrayToMap, + applyDarkModeFilter, + AppEventBus, type EXPORT_IMAGE_TYPES, randomInteger, CLASSES, Emitter, MINIMUM_ARROW_SIZE, + DOUBLE_TAP_POSITION_THRESHOLD, + BIND_MODE_TIMEOUT, + invariant, + getFeatureFlag, + createUserAgentDescriptor, + getFormFactor, + deriveStylesPanelMode, + isIOS, + isBrave, + isSafari, + type EditorInterface, + type StylesPanelMode, + loadDesktopUIModePreference, + setDesktopUIMode, + isSelectionLikeTool, + oneOf, } from "@excalidraw/common"; import { getObservedAppState, getCommonBounds, - maybeSuggestBindingsForLinearElementAtCoords, getElementAbsoluteCoords, - bindOrUnbindLinearElements, + bindOrUnbindBindingElements, fixBindingsAfterDeletion, getHoveredElementForBinding, isBindingEnabled, - shouldEnableBindingForPointerEvent, updateBoundElements, - getSuggestedBindingsForArrows, LinearElementEditor, newElementWith, newFrameElement, @@ -151,7 +158,6 @@ import { isFlowchartNodeElement, isBindableElement, isTextElement, - getLockedLinearCursorAlignSize, getNormalizedDimensions, isElementCompletelyInViewport, isElementInViewport, @@ -170,7 +176,7 @@ import { getContainerElement, isValidTextContainer, redrawTextBoundingBox, - shouldShowBoundingBox, + hasBoundingBox, getFrameChildren, isCursorInFrame, addElementsToFrame, @@ -238,14 +244,32 @@ import { SnapCache, isActiveToolNonLinearSnappable, getSnapLinesAtPointer, - snapLinearElementPoint, - snapToDiscreteAngle, isSnappingEnabled, getReferenceSnapPoints, getVisibleGaps, snapDraggedElements, snapNewElement, snapResizingElements, + StoreDelta, + type ApplyToOptions, + positionElementsOnGrid, + calculateFixedPointForNonElbowArrowBinding, + bindOrUnbindBindingElement, + mutateElement, + getElementBounds, + doBoundsIntersect, + isPointInElement, + maxBindingDistance_simple, + convertToExcalidrawElements, + type ExcalidrawElementSkeleton, + getSnapOutlineMidPoint, + handleFocusPointDrag, + handleFocusPointHover, + handleFocusPointPointerDown, + handleFocusPointPointerUp, + maybeHandleArrowPointlikeDrag, + getUncroppedWidthAndHeight, + getActiveTextElement, } from "@excalidraw/element"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; @@ -272,6 +296,8 @@ import type { MagicGenerationData, ExcalidrawArrowElement, ExcalidrawElbowArrowElement, + SceneElementsMap, + ExcalidrawBindableElement, } from "@excalidraw/element/types"; import type { Mutable, ValueOf } from "@excalidraw/common/utility-types"; @@ -306,6 +332,8 @@ import { actionToggleElementLock, actionToggleLinearEditor, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleCropEditor, } from "../actions"; import { actionWrapTextInContainer } from "../actions/actionBoundText"; @@ -331,10 +359,16 @@ import { isEraserActive, isHandToolActive, } from "../appState"; -import { copyTextToSystemClipboard, parseClipboard } from "../clipboard"; +import { + copyTextToSystemClipboard, + parseClipboard, + parseDataTransferEvent, + type ParsedDataTransferFile, +} from "../clipboard"; + import { exportCanvas, loadFromBlob } from "../data"; import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; -import { restore, restoreElements } from "../data/restore"; +import { restoreAppState, restoreElements } from "../data/restore"; import { getCenter, getDistance } from "../gesture"; import { History } from "../history"; import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n"; @@ -353,7 +387,6 @@ import { generateIdFromFile, getDataURL, getDataURL_sync, - getFileFromEvent, ImageURLToFile, isImageFileHandle, isSupportedImageFile, @@ -374,7 +407,6 @@ import { import { Fonts } from "../fonts"; import { editorJotaiStore, type WritableAtom } from "../editor-jotai"; import { ImageSceneDataError } from "../errors"; -import { convertToExcalidrawElements } from "../data/transform"; import { Renderer } from "../scene/Renderer"; import { setEraserCursor, @@ -385,6 +417,7 @@ import { import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { LaserTrails } from "../laser-trails"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; +import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle"; import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { isOverScrollBars } from "../scene/scrollbars"; @@ -394,6 +427,10 @@ import { LassoTrail } from "../lasso"; import { EraserTrail } from "../eraser"; +import { getShortcutKey } from "../shortcut"; + +import { tryParseSpreadsheet } from "../charts"; + import ConvertElementTypePopup, { getConversionTypeFromElements, convertElementTypePopupAtom, @@ -412,27 +449,25 @@ import { searchItemInFocusAtom } from "./SearchMenu"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { StaticCanvas, InteractiveCanvas } from "./canvases"; import NewElementCanvas from "./canvases/NewElementCanvas"; -import { - isPointHittingLink, - isPointHittingLinkIcon, -} from "./hyperlink/helpers"; +import { isPointHittingLink } from "./hyperlink/helpers"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; -import { Toast } from "./Toast"; +import { AppStateObserver, type OnStateChange } from "./AppStateObserver"; import { findShapeByKey } from "./shapes"; import UnlockPopup from "./UnlockPopup"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { RenderInteractiveSceneCallback, ScrollBars, } from "../scene/types"; -import type { PastedMixedContent } from "../clipboard"; +import type { ClipboardData, PastedMixedContent } from "../clipboard"; import type { ExportedElements } from "../data"; import type { ContextMenuItems } from "./ContextMenu"; -import type { FileSystemHandle } from "../data/filesystem"; -import type { ExcalidrawElementSkeleton } from "../data/transform"; + import type { AppClassProperties, AppProps, @@ -445,7 +480,6 @@ import type { LibraryItems, PointerDownState, SceneData, - Device, FrameNameBoundsCache, SidebarName, SidebarTabName, @@ -456,6 +490,7 @@ import type { UnsubscribeCallback, EmbedsValidationStatus, ElementsPendingErasure, + ExcalidrawImperativeAPIEventMap, GenerateDiagramToCode, NullableGridSize, Offsets, @@ -466,19 +501,26 @@ import type { Action, ActionResult } from "../actions/types"; const AppContext = React.createContext(null!); const AppPropsContext = React.createContext(null!); -const deviceContextInitialValue = { - viewport: { - isMobile: false, - isLandscape: false, - }, - editor: { - isMobile: false, - canFitSidebar: false, - }, +const editorInterfaceContextInitialValue: EditorInterface = { + formFactor: "desktop", + desktopUIMode: "full", + userAgent: createUserAgentDescriptor( + typeof navigator !== "undefined" ? navigator.userAgent : "", + ), isTouchScreen: false, + canFitSidebar: false, + isLandscape: true, }; -const DeviceContext = React.createContext(deviceContextInitialValue); -DeviceContext.displayName = "DeviceContext"; +const EditorInterfaceContext = React.createContext( + editorInterfaceContextInitialValue, +); +EditorInterfaceContext.displayName = "EditorInterfaceContext"; + +const editorLifecycleEventBehavior = { + "editor:mount": { cardinality: "once", replay: "last" }, + "editor:initialize": { cardinality: "once", replay: "last" }, + "editor:unmount": { cardinality: "once", replay: "last" }, +} as const; export const ExcalidrawContainerContext = React.createContext<{ container: HTMLDivElement | null; @@ -512,9 +554,21 @@ const ExcalidrawActionManagerContext = React.createContext( ); ExcalidrawActionManagerContext.displayName = "ExcalidrawActionManagerContext"; +export const ExcalidrawAPIContext = + React.createContext(null); +ExcalidrawAPIContext.displayName = "ExcalidrawAPIContext"; + +export const ExcalidrawAPISetContext = React.createContext< + ((api: ExcalidrawImperativeAPI | null) => void) | null +>(null); +ExcalidrawAPISetContext.displayName = "ExcalidrawAPISetContext"; + export const useApp = () => useContext(AppContext); export const useAppProps = () => useContext(AppPropsContext); -export const useDevice = () => useContext(DeviceContext); +export const useEditorInterface = () => + useContext(EditorInterfaceContext); +export const useStylesPanelMode = () => + deriveStylesPanelMode(useEditorInterface()); export const useExcalidrawContainer = () => useContext(ExcalidrawContainerContext); export const useExcalidrawElements = () => @@ -525,9 +579,14 @@ export const useExcalidrawSetAppState = () => useContext(ExcalidrawSetAppStateContext); export const useExcalidrawActionManager = () => useContext(ExcalidrawActionManagerContext); +/** + * Requires wrapping your component in + */ +export const useExcalidrawAPI = () => useContext(ExcalidrawAPIContext); let didTapTwice: boolean = false; let tappedTwiceTimer = 0; +let firstTapPosition: { x: number; y: number } | null = null; let isHoldingSpace: boolean = false; let isPanning: boolean = false; let isDraggingScrollBar: boolean = false; @@ -558,10 +617,14 @@ const gesture: Gesture = { class App extends React.Component { canvas: AppClassProperties["canvas"]; interactiveCanvas: AppClassProperties["interactiveCanvas"] = null; + public sessionExportThemeOverride: AppState["theme"] | undefined; rc: RoughCanvas; unmounted: boolean = false; actionManager: ActionManager; - device: Device = deviceContextInitialValue; + editorInterface: EditorInterface = editorInterfaceContextInitialValue; + private stylesPanelMode: StylesPanelMode = deriveStylesPanelMode( + editorInterfaceContextInitialValue, + ); private excalidrawContainerRef = React.createRef(); @@ -570,7 +633,6 @@ class App extends React.Component { public renderer: Renderer; public visibleElements: readonly NonDeletedExcalidrawElement[]; private resizeObserver: ResizeObserver | undefined; - private nearestScrollableContainer: HTMLElement | Document | undefined; public library: AppClassProperties["library"]; public libraryItemsFromStorage: LibraryItems | undefined; public id: string; @@ -595,21 +657,46 @@ class App extends React.Component { * insert to DOM before user initially scrolls to them) */ private initializedEmbeds = new Set(); - private handleToastClose = () => { - this.setToast(null); - }; - private elementsPendingErasure: ElementsPendingErasure = new Set(); + private _initialized = false; + + private readonly editorLifecycleEvents = new AppEventBus< + ExcalidrawImperativeAPIEventMap, + typeof editorLifecycleEventBehavior + >(editorLifecycleEventBehavior); + + public onEvent = this.editorLifecycleEvents.on.bind( + this.editorLifecycleEvents, + ) as AppEventBus< + ExcalidrawImperativeAPIEventMap, + typeof editorLifecycleEventBehavior + >["on"]; + + private appStateObserver = new AppStateObserver(() => this.state); + + public onStateChange: OnStateChange = this.appStateObserver.onStateChange; + public flowChartCreator: FlowChartCreator = new FlowChartCreator(); private flowChartNavigator: FlowChartNavigator = new FlowChartNavigator(); + bindModeHandler: ReturnType | null = null; + hitLinkElement?: NonDeletedExcalidrawElement; lastPointerDownEvent: React.PointerEvent | null = null; lastPointerUpEvent: React.PointerEvent | PointerEvent | null = null; + // TODO this is a hack and we should ideally unify touch and pointer events + // and implement our own double click handling end-to-end (currently we're + // using a mix of native browser for click events and manual for touch - + // and browser doubleClick sucks to begin with) + lastPointerUpIsDoubleClick: boolean = false; lastPointerMoveEvent: PointerEvent | null = null; + /** current frame pointer cords */ lastPointerMoveCoords: { x: number; y: number } | null = null; + private lastCompletedCanvasClicks: { x: number; y: number }[] = []; + /** previous frame pointer coords */ + previousPointerMoveCoords: { x: number; y: number } | null = null; lastViewportPosition = { x: 0, y: 0 }; animationFrameHandler = new AnimationFrameHandler(); @@ -651,11 +738,56 @@ class App extends React.Component { >(); onRemoveEventListenersEmitter = new Emitter<[]>(); + api: ExcalidrawImperativeAPI; + + private createExcalidrawAPI(): ExcalidrawImperativeAPI { + const api: ExcalidrawImperativeAPI = { + isDestroyed: false, + updateScene: this.updateScene, + applyDeltas: this.applyDeltas, + mutateElement: this.mutateElement, + updateLibrary: this.library.updateLibrary, + addFiles: this.addFiles, + resetScene: this.resetScene, + getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, + getSceneElementsMapIncludingDeleted: + this.getSceneElementsMapIncludingDeleted, + history: { + clear: this.resetHistory, + }, + scrollToContent: this.scrollToContent, + getSceneElements: this.getSceneElements, + getAppState: () => this.state, + getFiles: () => this.files, + getName: this.getName, + registerAction: (action: Action) => { + this.actionManager.registerAction(action); + }, + refresh: this.refresh, + setToast: this.setToast, + id: this.id, + setActiveTool: this.setActiveTool, + setCursor: this.setCursor, + resetCursor: this.resetCursor, + getEditorInterface: () => this.editorInterface, + updateFrameRendering: this.updateFrameRendering, + toggleSidebar: this.toggleSidebar, + onChange: (cb) => this.onChangeEmitter.on(cb), + onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb), + onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), + onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), + onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), + onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), + onStateChange: this.onStateChange, + onEvent: this.onEvent, + }; + return api; + } + constructor(props: AppProps) { super(props); const defaultAppState = getDefaultAppState(); const { - excalidrawAPI, viewModeEnabled = false, zenModeEnabled = false, gridModeEnabled = false, @@ -663,9 +795,11 @@ class App extends React.Component { theme = defaultAppState.theme, name = `${t("labels.untitled")}-${getDateTime()}`, } = props; + this.state = { ...defaultAppState, theme, + exportWithDarkMode: theme === THEME.DARK, isLoading: true, ...this.getCanvasOffsets(), viewModeEnabled, @@ -677,6 +811,9 @@ class App extends React.Component { height: window.innerHeight, }; + this.refreshEditorInterface(); + this.stylesPanelMode = deriveStylesPanelMode(this.editorInterface); + this.id = nanoid(); this.library = new Library(this); this.actionManager = new ActionManager( @@ -695,49 +832,6 @@ class App extends React.Component { this.store = new Store(this); this.history = new History(this.store); - if (excalidrawAPI) { - const api: ExcalidrawImperativeAPI = { - updateScene: this.updateScene, - mutateElement: this.mutateElement, - updateLibrary: this.library.updateLibrary, - addFiles: this.addFiles, - resetScene: this.resetScene, - getSceneElementsIncludingDeleted: this.getSceneElementsIncludingDeleted, - getSceneElementsMapIncludingDeleted: - this.getSceneElementsMapIncludingDeleted, - history: { - clear: this.resetHistory, - }, - scrollToContent: this.scrollToContent, - getSceneElements: this.getSceneElements, - getAppState: () => this.state, - getFiles: () => this.files, - getName: this.getName, - registerAction: (action: Action) => { - this.actionManager.registerAction(action); - }, - refresh: this.refresh, - setToast: this.setToast, - id: this.id, - setActiveTool: this.setActiveTool, - setCursor: this.setCursor, - resetCursor: this.resetCursor, - updateFrameRendering: this.updateFrameRendering, - toggleSidebar: this.toggleSidebar, - onChange: (cb) => this.onChangeEmitter.on(cb), - onIncrement: (cb) => this.store.onStoreIncrementEmitter.on(cb), - onPointerDown: (cb) => this.onPointerDownEmitter.on(cb), - onPointerUp: (cb) => this.onPointerUpEmitter.on(cb), - onScrollChange: (cb) => this.onScrollChangeEmitter.on(cb), - onUserFollow: (cb) => this.onUserFollowEmitter.on(cb), - } as const; - if (typeof excalidrawAPI === "function") { - excalidrawAPI(api); - } else { - console.error("excalidrawAPI should be a function!"); - } - } - this.excalidrawContainerValue = { container: this.excalidrawContainerRef.current, id: this.id, @@ -749,6 +843,12 @@ class App extends React.Component { this.actionManager.registerAll(actions); this.actionManager.registerAction(createUndoAction(this.history)); this.actionManager.registerAction(createRedoAction(this.history)); + + // in case internal editor APIs call this early, otherwise we need + // to construct this in componentDidMount because componentWillUnmount + // will invalidate it (so in StrictMode, doing this in constructor alone + // would be a problem) + this.api = this.createExcalidrawAPI(); } updateEditorAtom = ( @@ -823,6 +923,320 @@ class App extends React.Component { } } + private handleSkipBindMode() { + if ( + this.state.selectedLinearElement?.initialState && + !this.state.selectedLinearElement.initialState.arrowStartIsInside + ) { + invariant( + this.lastPointerMoveCoords, + "Missing last pointer move coords when changing bind skip mode for arrow start", + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const hoveredElement = getHoveredElementForBinding( + pointFrom( + this.lastPointerMoveCoords.x, + this.lastPointerMoveCoords.y, + ), + this.scene.getNonDeletedElements(), + elementsMap, + ); + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + elementsMap, + ); + + if ( + element?.startBinding && + hoveredElement?.id === element.startBinding.elementId + ) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + initialState: { + ...this.state.selectedLinearElement.initialState, + arrowStartIsInside: true, + }, + }, + }); + } + } + + if (this.state.bindMode === "orbit") { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // PERF: It's okay since it's a single trigger from a key handler + // or single call from pointer move handler because the bindMode check + // will not pass the second time + flushSync(() => { + this.setState({ + bindMode: "skip", + }); + }); + + if ( + this.lastPointerMoveCoords && + this.state.selectedLinearElement?.selectedPointsIndices && + this.state.selectedLinearElement?.selectedPointsIndices.length + ) { + const { x, y } = this.lastPointerMoveCoords; + const event = + this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; + invariant(event, "Last event must exist"); + const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; + const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; + const newState = this.state.multiElement + ? LinearElementEditor.handlePointerMove( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ) + : LinearElementEditor.handlePointDragging( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } + } + } + } + + private resetDelayedBindMode() { + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + if (this.state.bindMode !== "orbit") { + // We need this iteration to complete binding and change + // back to orbit mode after that + setTimeout(() => + this.setState({ + bindMode: "orbit", + }), + ); + } + } + + private previousHoveredBindableElement: NonDeletedExcalidrawElement | null = + null; + + private handleDelayedBindModeChange( + arrow: ExcalidrawArrowElement, + hoveredElement: NonDeletedExcalidrawElement | null, + ) { + if (arrow.isDeleted || isElbowArrow(arrow)) { + return; + } + + const effector = () => { + this.bindModeHandler = null; + + invariant( + this.lastPointerMoveCoords, + "Expected lastPointerMoveCoords to be set", + ); + + if (!this.state.multiElement) { + if ( + !this.state.selectedLinearElement || + !this.state.selectedLinearElement.selectedPointsIndices || + !this.state.selectedLinearElement.selectedPointsIndices.length + ) { + return; + } + + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + + // Check if the whole arrow is dragged by selecting all endpoints + if ((!startDragged && !endDragged) || (startDragged && endDragged)) { + return; + } + } + + const { x, y } = this.lastPointerMoveCoords; + const hoveredElement = getHoveredElementForBinding( + pointFrom(x, y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + + if (hoveredElement && this.state.bindMode !== "skip") { + invariant( + this.state.selectedLinearElement?.elementId === arrow.id, + "The selectedLinearElement is expected to not change while a bind mode timeout is ticking", + ); + + // Once the start is set to inside binding, it remains so + const arrowStartIsInside = + this.state.selectedLinearElement.initialState.arrowStartIsInside || + arrow.startBinding?.elementId === hoveredElement.id; + + // Change the global binding mode + flushSync(() => { + invariant( + this.state.selectedLinearElement, + "this.state.selectedLinearElement must exist", + ); + + this.setState({ + bindMode: "inside", + selectedLinearElement: { + ...this.state.selectedLinearElement, + initialState: { + ...this.state.selectedLinearElement.initialState, + arrowStartIsInside, + }, + }, + }); + }); + + const event = + this.lastPointerMoveEvent ?? this.lastPointerDownEvent?.nativeEvent; + invariant(event, "Last event must exist"); + const deltaX = x - this.state.selectedLinearElement.pointerOffset.x; + const deltaY = y - this.state.selectedLinearElement.pointerOffset.y; + const newState = this.state.multiElement + ? LinearElementEditor.handlePointerMove( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ) + : LinearElementEditor.handlePointDragging( + event, + this, + deltaX, + deltaY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } + } + }; + + let isOverlapping = false; + if (this.state.selectedLinearElement?.selectedPointsIndices) { + const elementsMap = this.scene.getNonDeletedElementsMap(); + const startDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes(0); + const endDragged = + this.state.selectedLinearElement.selectedPointsIndices.includes( + arrow.points.length - 1, + ); + const startElement = startDragged + ? hoveredElement + : arrow.startBinding && elementsMap.get(arrow.startBinding.elementId); + const endElement = endDragged + ? hoveredElement + : arrow.endBinding && elementsMap.get(arrow.endBinding.elementId); + const startBounds = + startElement && getElementBounds(startElement, elementsMap); + const endBounds = endElement && getElementBounds(endElement, elementsMap); + isOverlapping = !!( + startBounds && + endBounds && + startElement.id !== endElement.id && + doBoundsIntersect(startBounds, endBounds) + ); + } + + const startDragged = + this.state.selectedLinearElement?.selectedPointsIndices?.includes(0); + const endDragged = + this.state.selectedLinearElement?.selectedPointsIndices?.includes( + arrow.points.length - 1, + ); + const currentBinding = startDragged + ? "startBinding" + : endDragged + ? "endBinding" + : null; + const otherBinding = startDragged + ? "endBinding" + : endDragged + ? "startBinding" + : null; + const isAlreadyInsideBindingToSameElement = + (otherBinding && + arrow[otherBinding]?.mode === "inside" && + arrow[otherBinding]?.elementId === hoveredElement?.id) || + (currentBinding && + arrow[currentBinding]?.mode === "inside" && + hoveredElement?.id === arrow[currentBinding]?.elementId); + + if ( + currentBinding && + otherBinding && + arrow[currentBinding]?.mode === "inside" && + hoveredElement?.id !== arrow[currentBinding]?.elementId && + arrow[otherBinding]?.elementId !== arrow[currentBinding]?.elementId + ) { + // Update binding out of place to orbit mode + this.scene.mutateElement( + arrow, + { + [currentBinding]: { + ...arrow[currentBinding], + mode: "orbit", + }, + }, + { + informMutation: false, + isDragging: true, + }, + ); + } + + if ( + !hoveredElement || + (this.previousHoveredBindableElement && + hoveredElement.id !== this.previousHoveredBindableElement.id) + ) { + // Clear the timeout if we're not hovering a bindable + if (this.bindModeHandler) { + clearTimeout(this.bindModeHandler); + this.bindModeHandler = null; + } + + // Clear the inside binding mode too + if (this.state.bindMode === "inside") { + flushSync(() => { + this.setState({ + bindMode: "orbit", + }); + }); + } + + this.previousHoveredBindableElement = null; + } else if ( + !this.bindModeHandler && + (!this.state.newElement || !arrow.startBinding || isOverlapping) && + !isAlreadyInsideBindingToSameElement + ) { + // We are hovering a bindable element + this.bindModeHandler = setTimeout(effector, BIND_MODE_TIMEOUT); + } + + this.previousHoveredBindableElement = hoveredElement; + } + private cacheEmbeddableRef( element: ExcalidrawIframeLikeElement, ref: HTMLIFrameElement | null, @@ -842,18 +1256,138 @@ class App extends React.Component { ) as NullableGridSize; }; + private getTextCreationGridPoint = (x: number, y: number) => { + const effectiveGridSize = this.getEffectiveGridSize(); + + if (effectiveGridSize === null) { + return null; + } + + const getTextCreationGridCoordinate = (coordinate: number) => { + const topLeftGridPoint = + Math.floor(coordinate / effectiveGridSize) * effectiveGridSize; + + return topLeftGridPoint; + }; + + return { + x: getTextCreationGridCoordinate(x), + y: getTextCreationGridCoordinate(y), + }; + }; + private getHTMLIFrameElement( element: ExcalidrawIframeLikeElement, ): HTMLIFrameElement | undefined { return this.iFrameRefs.get(element.id); } - private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { + private handleIframeLikeElementHover = ({ + hitElement, + scenePointer, + moveEvent, + }: { + hitElement: NonDeleted | null; + scenePointer: { x: number; y: number }; + moveEvent: React.PointerEvent; + }): boolean => { if ( - this.state.activeEmbeddable?.element === element && + hitElement && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + moveEvent, + scenePointer.x, + scenePointer.y, + )) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + this.setState({ + activeEmbeddable: { element: hitElement, state: "hover" }, + }); + return true; + } else if (this.state.activeEmbeddable?.state === "hover") { + this.setState({ activeEmbeddable: null }); + } + return false; + }; + + /** @returns true if iframe-like element click handled */ + private handleIframeLikeCenterClick(): boolean { + if ( + !this.lastPointerDownEvent || + !this.lastPointerUpEvent || + // middle-click or something other than primary + this.lastPointerDownEvent.button !== POINTER_BUTTON.MAIN || + // panning + isHoldingSpace || + // wrong tool + !oneOf(this.state.activeTool.type, ["laser", "selection", "lasso"]) + ) { + return false; + } + + const viewportClickStart_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerDownEvent.clientX, + clientY: this.lastPointerDownEvent.clientY, + }, + this.state, + ), + ); + const viewportClickEnd_scenePoint = pointFrom( + viewportCoordsToSceneCoords( + { + clientX: this.lastPointerUpEvent.clientX, + clientY: this.lastPointerUpEvent.clientY, + }, + this.state, + ), + ); + + const draggedDistance = pointDistance( + viewportClickStart_scenePoint, + viewportClickEnd_scenePoint, + ); + + if (draggedDistance > DRAGGING_THRESHOLD) { + return false; + } + + const hitElement = this.getElementAtPosition( + viewportClickStart_scenePoint[0], + viewportClickStart_scenePoint[1], + ); + + const shouldActivate = + hitElement && + this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp <= + 300 && + gesture.pointers.size < 2 && + isIframeLikeElement(hitElement) && + (this.state.viewModeEnabled || + this.state.activeTool.type === "laser" || + this.isIframeLikeElementCenter( + hitElement, + this.lastPointerUpEvent, + viewportClickEnd_scenePoint[0], + viewportClickEnd_scenePoint[1], + )); + + if (!shouldActivate) { + return false; + } + + const iframeLikeElement = hitElement; + + if ( + this.state.activeEmbeddable?.element === iframeLikeElement && this.state.activeEmbeddable?.state === "active" ) { - return; + return true; } // The delay serves two purposes @@ -864,31 +1398,34 @@ class App extends React.Component { // in fullscreen mode setTimeout(() => { this.setState({ - activeEmbeddable: { element, state: "active" }, - selectedElementIds: { [element.id]: true }, + activeEmbeddable: { element: iframeLikeElement, state: "active" }, + selectedElementIds: { [iframeLikeElement.id]: true }, newElement: null, selectionElement: null, }); }, 100); - if (isIframeElement(element)) { - return; + if (isIframeElement(iframeLikeElement)) { + return true; } - const iframe = this.getHTMLIFrameElement(element); + const iframe = this.getHTMLIFrameElement(iframeLikeElement); if (!iframe?.contentWindow) { - return; + return true; } if (iframe.src.includes("youtube")) { - const state = YOUTUBE_VIDEO_STATES.get(element.id); + const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id); if (!state) { - YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); + YOUTUBE_VIDEO_STATES.set( + iframeLikeElement.id, + YOUTUBE_STATES.UNSTARTED, + ); iframe.contentWindow.postMessage( JSON.stringify({ event: "listening", - id: element.id, + id: iframeLikeElement.id, }), "*", ); @@ -925,8 +1462,25 @@ class App extends React.Component { "*", ); } + + return true; } + private isDoubleClick = ( + lastPointerEvent: + | PointerEvent + | React.PointerEvent + | undefined + | null, + currentPointerEvent: PointerEvent | React.PointerEvent, + ) => { + return ( + lastPointerEvent != null && + currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <= + TAP_TWICE_TIMEOUT + ); + }; + private isIframeLikeElementCenter( el: ExcalidrawIframeLikeElement | null, event: React.PointerEvent | PointerEvent, @@ -1417,8 +1971,9 @@ class App extends React.Component { } }} style={{ - background: this.state.viewBackgroundColor, - filter: isDarkTheme ? THEME_FILTER : "none", + background: isDarkTheme + ? applyDarkModeFilter(this.state.viewBackgroundColor) + : this.state.viewBackgroundColor, zIndex: 2, border: "none", display: "block", @@ -1428,7 +1983,9 @@ class App extends React.Component { fontFamily: "Assistant", fontSize: `${FRAME_STYLE.nameFontSize}px`, transform: `translate(-${FRAME_NAME_EDIT_PADDING}px, ${FRAME_NAME_EDIT_PADDING}px)`, - color: "var(--color-gray-80)", + color: isDarkTheme + ? FRAME_STYLE.nameColorDarkTheme + : FRAME_STYLE.nameColorLightTheme, overflow: "hidden", maxWidth: `${ document.body.clientWidth - x1 - FRAME_NAME_EDIT_PADDING @@ -1448,6 +2005,7 @@ class App extends React.Component { return (
{ public render() { const selectedElements = this.scene.getSelectedElements(this.state); - const { renderTopRightUI, renderCustomStats } = this.props; + const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props; const sceneNonce = this.scene.getSceneNonce(); const { elementsMap, visibleElements } = @@ -1546,11 +2104,12 @@ class App extends React.Component { return (
{ onPointerEnter={this.toggleOverscrollBehavior} onPointerLeave={this.toggleOverscrollBehavior} > - - - - - - - - + + + + + + + - - {this.props.children} - + + {this.props.children} + -
-
-
- - {selectedElements.length === 1 && - this.state.openDialog?.name !== - "elementLinkSelector" && - this.state.showHyperlinkPopup && ( - +
+
+ + {selectedElements.length === 1 && + this.state.openDialog?.name !== + "elementLinkSelector" && + this.state.showHyperlinkPopup && ( + + )} + {this.props.aiEnabled !== false && + selectedElements.length === 1 && + isMagicFrameElement(firstSelectedElement) && ( + + + this.onMagicFrameGenerate( + firstSelectedElement, + "button", + ) + } + /> + + )} + {selectedElements.length === 1 && + isIframeElement(firstSelectedElement) && + firstSelectedElement.customData?.generationData + ?.status === "done" && ( + + + this.onIframeSrcCopy(firstSelectedElement) + } + /> + { + const iframe = + this.getHTMLIFrameElement( + firstSelectedElement, + ); + if (iframe) { + try { + iframe.requestFullscreen(); + this.setState({ + activeEmbeddable: { + element: firstSelectedElement, + state: "active", + }, + selectedElementIds: { + [firstSelectedElement.id]: true, + }, + newElement: null, + selectionElement: null, + }); + } catch (err: any) { + console.warn(err); + this.setState({ + errorMessage: + "Couldn't enter fullscreen", + }); + } + } + }} + /> + + )} + + {this.state.contextMenu && ( + { + this.setState({ contextMenu: null }, () => { + this.focusContainer(); + callback?.(); + }); + }} /> )} - {this.props.aiEnabled !== false && - selectedElements.length === 1 && - isMagicFrameElement(firstSelectedElement) && ( - - - this.onMagicFrameGenerate( - firstSelectedElement, - "button", - ) - } - /> - - )} - {selectedElements.length === 1 && - isIframeElement(firstSelectedElement) && - firstSelectedElement.customData?.generationData - ?.status === "done" && ( - - - this.onIframeSrcCopy(firstSelectedElement) - } - /> - { - const iframe = - this.getHTMLIFrameElement( - firstSelectedElement, - ); - if (iframe) { - try { - iframe.requestFullscreen(); - this.setState({ - activeEmbeddable: { - element: firstSelectedElement, - state: "active", - }, - selectedElementIds: { - [firstSelectedElement.id]: true, - }, - newElement: null, - selectionElement: null, - }); - } catch (err: any) { - console.warn(err); - this.setState({ - errorMessage: - "Couldn't enter fullscreen", - }); - } - } - }} - /> - - )} - - {this.state.toast !== null && ( - - )} - - {this.state.contextMenu && ( - { - this.setState({ contextMenu: null }, () => { - this.focusContainer(); - callback?.(); - }); - }} - /> - )} - - {this.state.newElement && ( - - )} - - {this.state.userToFollow && ( - - )} - {this.renderFrameNames()} - {this.state.activeLockedId && ( - + )} + - )} - {showShapeSwitchPanel && ( - - )} - - {this.renderEmbeddables()} - - - - - - - + {this.state.userToFollow && ( + + )} + {this.renderFrameNames()} + {this.state.activeLockedId && ( + + )} + {showShapeSwitchPanel && ( + + )} + + {this.renderEmbeddables()} + + + + + + + +
); } @@ -1962,6 +2524,7 @@ class App extends React.Component { const magicFrameChildren = getElementsOverlappingFrame( this.scene.getNonDeletedElements(), magicFrame, + this.scene.getNonDeletedElementsMap(), ).filter((el) => !isMagicFrameElement(el)); if (!magicFrameChildren.length) { @@ -2257,7 +2820,9 @@ class App extends React.Component { private onBlur = withBatchedUpdates(() => { isHoldingSpace = false; - this.setState({ isBindingEnabled: true }); + this.setState({ + isBindingEnabled: this.state.bindingPreference === "enabled", + }); }); private onUnload = () => { @@ -2343,27 +2908,47 @@ class App extends React.Component { }, }; } - const scene = restore(initialData, null, null, { repairBindings: true }); - scene.appState = { - ...scene.appState, - theme: this.props.theme || scene.appState.theme, + const restoredElements = restoreElements(initialData?.elements, null, { + repairBindings: true, + deleteInvisibleElements: true, + }); + let restoredAppState = restoreAppState(initialData?.appState, null); + const activeTool = restoredAppState.activeTool; + + if (!restoredAppState.preferredSelectionTool.initialized) { + restoredAppState.preferredSelectionTool = { + type: + this.editorInterface.formFactor === "phone" ? "lasso" : "selection", + initialized: true, + }; + } + + restoredAppState = { + ...restoredAppState, + theme: this.props.theme || restoredAppState.theme, // we're falling back to current (pre-init) state when deciding // whether to open the library, to handle a case where we // update the state outside of initialData (e.g. when loading the app // with a library install link, which should auto-open the library) - openSidebar: scene.appState?.openSidebar || this.state.openSidebar, + openSidebar: restoredAppState?.openSidebar || this.state.openSidebar, activeTool: - scene.appState.activeTool.type === "image" - ? { ...scene.appState.activeTool, type: "selection" } - : scene.appState.activeTool, + activeTool.type === "image" || + activeTool.type === "lasso" || + activeTool.type === "selection" + ? { + ...activeTool, + type: restoredAppState.preferredSelectionTool.type, + } + : restoredAppState.activeTool, isLoading: false, toast: this.state.toast, }; + if (initialData?.scrollToContent) { - scene.appState = { - ...scene.appState, - ...calculateScrollCenter(scene.elements, { - ...scene.appState, + restoredAppState = { + ...restoredAppState, + ...calculateScrollCenter(restoredElements, { + ...restoredAppState, width: this.state.width, height: this.state.height, offsetTop: this.state.offsetTop, @@ -2375,7 +2960,9 @@ class App extends React.Component { this.resetStore(); this.resetHistory(); this.syncActionResult({ - ...scene, + elements: restoredElements, + appState: restoredAppState, + files: initialData?.files, captureUpdate: CaptureUpdateAction.NEVER, }); @@ -2393,37 +2980,14 @@ class App extends React.Component { } }; - private isMobileBreakpoint = (width: number, height: number) => { + private getFormFactor = (editorWidth: number, editorHeight: number) => { return ( - width < MQ_MAX_WIDTH_PORTRAIT || - (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) + this.props.UIOptions.getFormFactor?.(editorWidth, editorHeight) ?? + getFormFactor(editorWidth, editorHeight) ); }; - private refreshViewportBreakpoints = () => { - const container = this.excalidrawContainerRef.current; - if (!container) { - return; - } - - const { clientWidth: viewportWidth, clientHeight: viewportHeight } = - document.body; - - const prevViewportState = this.device.viewport; - - const nextViewportState = updateObject(prevViewportState, { - isLandscape: viewportWidth > viewportHeight, - isMobile: this.isMobileBreakpoint(viewportWidth, viewportHeight), - }); - - if (prevViewportState !== nextViewportState) { - this.device = { ...this.device, viewport: nextViewportState }; - return true; - } - return false; - }; - - private refreshEditorBreakpoints = () => { + public refreshEditorInterface = () => { const container = this.excalidrawContainerRef.current; if (!container) { return; @@ -2432,23 +2996,53 @@ class App extends React.Component { const { width: editorWidth, height: editorHeight } = container.getBoundingClientRect(); + const storedDesktopUIMode = loadDesktopUIModePreference(); + const userAgentDescriptor = createUserAgentDescriptor( + typeof navigator !== "undefined" ? navigator.userAgent : "", + ); + // allow host app to control formFactor and desktopUIMode via props const sidebarBreakpoint = this.props.UIOptions.dockedSidebarBreakpoint != null ? this.props.UIOptions.dockedSidebarBreakpoint : MQ_RIGHT_SIDEBAR_MIN_WIDTH; - - const prevEditorState = this.device.editor; - - const nextEditorState = updateObject(prevEditorState, { - isMobile: this.isMobileBreakpoint(editorWidth, editorHeight), + const nextEditorInterface = updateObject(this.editorInterface, { + desktopUIMode: storedDesktopUIMode ?? this.editorInterface.desktopUIMode, + formFactor: this.getFormFactor(editorWidth, editorHeight), + userAgent: userAgentDescriptor, canFitSidebar: editorWidth > sidebarBreakpoint, + isLandscape: editorWidth > editorHeight, }); - if (prevEditorState !== nextEditorState) { - this.device = { ...this.device, editor: nextEditorState }; - return true; + this.editorInterface = nextEditorInterface; + this.reconcileStylesPanelMode(nextEditorInterface); + }; + + private reconcileStylesPanelMode = (nextEditorInterface: EditorInterface) => { + const nextStylesPanelMode = deriveStylesPanelMode(nextEditorInterface); + if (nextStylesPanelMode === this.stylesPanelMode) { + return; } - return false; + + const prevStylesPanelMode = this.stylesPanelMode; + this.stylesPanelMode = nextStylesPanelMode; + + if (prevStylesPanelMode !== "full" && nextStylesPanelMode === "full") { + this.setState((prevState) => ({ + preferredSelectionTool: { + type: "selection", + initialized: true, + }, + })); + } + }; + + /** TO BE USED LATER */ + private setDesktopUIMode = (mode: EditorInterface["desktopUIMode"]) => { + const nextMode = setDesktopUIMode(mode); + this.editorInterface = updateObject(this.editorInterface, { + desktopUIMode: nextMode, + }); + this.reconcileStylesPanelMode(this.editorInterface); }; private clearImageShapeCache(filesMap?: BinaryFiles) { @@ -2463,6 +3057,8 @@ class App extends React.Component { public async componentDidMount() { this.unmounted = false; + this.api = this.createExcalidrawAPI(); + this.excalidrawContainerValue.container = this.excalidrawContainerRef.current; @@ -2504,12 +3100,10 @@ class App extends React.Component { this.history.record(increment.delta); }); - const { onIncrement } = this.props; - // per. optimmisation, only subscribe if there is the `onIncrement` prop registered, to avoid unnecessary computation - if (onIncrement) { + if (this.props.onIncrement) { this.store.onStoreIncrementEmitter.on((increment) => { - onIncrement(increment); + this.props.onIncrement?.(increment); }); } @@ -2520,19 +3114,9 @@ class App extends React.Component { this.focusContainer(); } - if ( - // bounding rects don't work in tests so updating - // the state on init would result in making the test enviro run - // in mobile breakpoint (0 width/height), making everything fail - !isTestEnv() - ) { - this.refreshViewportBreakpoints(); - this.refreshEditorBreakpoints(); - } - if (supportsResizeObserver && this.excalidrawContainerRef.current) { this.resizeObserver = new ResizeObserver(() => { - this.refreshEditorBreakpoints(); + this.refreshEditorInterface(); this.updateDOMRect(); }); this.resizeObserver?.observe(this.excalidrawContainerRef.current); @@ -2553,10 +3137,43 @@ class App extends React.Component { errorMessage: , }); } + + const mountPayload = { + excalidrawAPI: this.api, + container: this.excalidrawContainerRef.current, + }; + + this.editorLifecycleEvents.emit("editor:mount", mountPayload); + this.props.onMount?.(mountPayload); + this.props.onExcalidrawAPI?.(this.api); } public componentWillUnmount() { + // we're recreating the api object reference so that the + // picks up on it + this.api = { ...this.api, isDestroyed: true }; + + for (const key of Object.keys(this.api) as (keyof typeof this.api)[]) { + if ( + (key.startsWith("get") || + key === "onStateChange" || + key === "onEvent") && + typeof this.api[key] === "function" + ) { + (this.api as any)[key] = () => { + throw new Error( + "ExcalidrawAPI is no longer usable after the editor has been unmounted and will return invalid/empty data. You should check for `ExcalidrawAPI.isDestroyed` before calling get* methods on subscribing to state/event changes.", + ); + }; + } + } + + this.editorLifecycleEvents.emit("editor:unmount"); + this.props.onUnmount?.(); + this.props.onExcalidrawAPI?.(null); + (window as any).launchQueue?.setConsumer(() => {}); + this.renderer.destroy(); this.scene.destroy(); this.scene = new Scene(); @@ -2573,6 +3190,8 @@ class App extends React.Component { this.onChangeEmitter.clear(); this.store.onStoreIncrementEmitter.clear(); this.store.onDurableIncrementEmitter.clear(); + this.appStateObserver.clear(); + this.editorLifecycleEvents.clear(); ShapeCache.destroy(); SnapCache.destroy(); clearTimeout(touchTimeout); @@ -2586,11 +3205,8 @@ class App extends React.Component { this.scene .getElementsIncludingDeleted() .forEach((element) => ShapeCache.delete(element)); - this.refreshViewportBreakpoints(); + this.refreshEditorInterface(); this.updateDOMRect(); - if (!supportsResizeObserver) { - this.refreshEditorBreakpoints(); - } this.setState({}); }); @@ -2741,19 +3357,28 @@ class App extends React.Component { } componentDidUpdate(prevProps: AppProps, prevState: AppState) { + // must be updated *before* state change listeners are triggered below + if (!this._initialized && !this.state.isLoading) { + this._initialized = true; + this.editorLifecycleEvents.emit("editor:initialize", this.api); + this.props.onInitialize?.(this.api); + } + + this.appStateObserver.flush(prevState); + this.updateEmbeddables(); const elements = this.scene.getElementsIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted(); - if (!this.state.showWelcomeScreen && !elements.length) { - this.setState({ showWelcomeScreen: true }); + const shouldExportWithDarkMode = + (this.sessionExportThemeOverride ?? this.state.theme) === THEME.DARK; + + if (this.state.exportWithDarkMode !== shouldExportWithDarkMode) { + this.setState({ exportWithDarkMode: shouldExportWithDarkMode }); } - if ( - prevProps.UIOptions.dockedSidebarBreakpoint !== - this.props.UIOptions.dockedSidebarBreakpoint - ) { - this.refreshEditorBreakpoints(); + if (!this.state.showWelcomeScreen && !elements.length) { + this.setState({ showWelcomeScreen: true }); } const hasFollowedPersonLeft = @@ -2811,6 +3436,7 @@ class App extends React.Component { ) { setEraserCursor(this.interactiveCanvas, this.state.theme); } + // Hide hyperlink popup if shown when element type is not selection if ( prevState.activeTool.type === "selection" && @@ -2881,15 +3507,6 @@ class App extends React.Component { this.setState({ editingTextElement: null }); } - if ( - this.state.selectedLinearElement && - !this.state.selectedElementIds[this.state.selectedLinearElement.elementId] - ) { - // To make sure `selectedLinearElement` is in sync with `selectedElementIds`, however this shouldn't be needed once - // we have a single API to update `selectedElementIds` - this.setState({ selectedLinearElement: null }); - } - this.store.commit(elementsMap, this.state); // Do not notify consumers if we're still loading the scene. Among other @@ -2960,6 +3577,7 @@ class App extends React.Component { private static resetTapTwice() { didTapTwice = false; + firstTapPosition = null; } private onTouchStart = (event: TouchEvent) => { @@ -2970,6 +3588,13 @@ class App extends React.Component { if (!didTapTwice) { didTapTwice = true; + + if (event.touches.length === 1) { + firstTapPosition = { + x: event.touches[0].clientX, + y: event.touches[0].clientY, + }; + } clearTimeout(tappedTwiceTimer); tappedTwiceTimer = window.setTimeout( App.resetTapTwice, @@ -2977,15 +3602,33 @@ class App extends React.Component { ); return; } - // insert text only if we tapped twice with a single finger + + // insert text only if we tapped twice with a single finger at approximately the same position // event.touches.length === 1 will also prevent inserting text when user's zooming - if (didTapTwice && event.touches.length === 1) { + if (didTapTwice && event.touches.length === 1 && firstTapPosition) { const touch = event.touches[0]; - // @ts-ignore - this.handleCanvasDoubleClick({ - clientX: touch.clientX, - clientY: touch.clientY, - }); + const distance = pointDistance( + pointFrom(touch.clientX, touch.clientY), + pointFrom(firstTapPosition.x, firstTapPosition.y), + ); + + // only create text if the second tap is within the threshold of the first tap + // this prevents accidental text creation during dragging/selection + if (distance <= DOUBLE_TAP_POSITION_THRESHOLD) { + // end lasso trail and deselect elements just in case + this.lassoTrail.endPath(); + this.deselectElements(); + + this.handleCanvasDoubleClick({ + clientX: touch.clientX, + clientY: touch.clientY, + type: "touch", + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }); + } didTapTwice = false; clearTimeout(tappedTwiceTimer); } @@ -3013,7 +3656,173 @@ class App extends React.Component { } }; - // TODO: this is so spaghetti, we should refactor it and cover it with tests + // TODO: Cover with tests + private async insertClipboardContent( + data: ClipboardData, + dataTransferFiles: ParsedDataTransferFile[], + isPlainPaste: boolean, + ) { + const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( + { + clientX: this.lastViewportPosition.x, + clientY: this.lastViewportPosition.y, + }, + this.state, + ); + + // ------------------- Error ------------------- + if (data.errorMessage) { + this.setState({ errorMessage: data.errorMessage }); + return; + } + + // ------------------- Mixed content with no files ------------------- + if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) { + await this.addElementsFromMixedContentPaste(data.mixedContent, { + isPlainPaste, + sceneX, + sceneY, + }); + return; + } + + // ------------------- Spreadsheet ------------------- + + if (!isPlainPaste && data.text) { + const result = tryParseSpreadsheet(data.text); + if (result.ok) { + this.setState({ + openDialog: { + name: "charts", + data: result.data, + rawText: data.text, + }, + }); + return; + } + } + + // ------------------- Images or SVG code ------------------- + const imageFiles = dataTransferFiles.map((data) => data.file); + + if (imageFiles.length === 0 && data.text && !isPlainPaste) { + const trimmedText = data.text.trim(); + if (trimmedText.startsWith("")) { + // ignore SVG validation/normalization which will be done during image + // initialization + imageFiles.push(SVGStringToFile(trimmedText)); + } + } + + if (imageFiles.length > 0) { + if (this.isToolSupported("image")) { + await this.insertImages(imageFiles, sceneX, sceneY); + } else { + this.setState({ errorMessage: t("errors.imageToolNotSupported") }); + } + return; + } + + // ------------------- Elements ------------------- + if (data.elements) { + const elements = ( + data.programmaticAPI + ? convertToExcalidrawElements( + data.elements as ExcalidrawElementSkeleton[], + ) + : data.elements + ) as readonly ExcalidrawElement[]; + // TODO: remove formatting from elements if isPlainPaste + this.addElementsFromPasteOrLibrary({ + elements, + files: data.files || null, + position: + this.editorInterface.formFactor === "desktop" ? "cursor" : "center", + retainSeed: isPlainPaste, + }); + return; + } + + // ------------------- Only textual stuff remaining ------------------- + if (!data.text) { + return; + } + + // ------------------- Successful Mermaid ------------------- + if (!isPlainPaste && isMaybeMermaidDefinition(data.text)) { + const api = await import("@excalidraw/mermaid-to-excalidraw"); + try { + const { elements: skeletonElements, files = {} } = + await api.parseMermaidToExcalidraw(data.text); + + const elements = convertToExcalidrawElements(skeletonElements, { + regenerateIds: true, + }); + + this.addElementsFromPasteOrLibrary({ + elements, + files, + position: + this.editorInterface.formFactor === "desktop" ? "cursor" : "center", + }); + + return; + } catch (err: any) { + console.warn( + `parsing pasted text as mermaid definition failed: ${err.message}`, + ); + } + } + + // ------------------- Pure embeddable URLs ------------------- + const nonEmptyLines = normalizeEOL(data.text) + .split(/\n+/) + .map((s) => s.trim()) + .filter(Boolean); + const embbeddableUrls = nonEmptyLines + .map((str) => maybeParseEmbedSrc(str)) + .filter( + (string) => + embeddableURLValidator(string, this.props.validateEmbeddable) && + (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || + getEmbedLink(string)?.type === "video"), + ); + + if ( + !isPlainPaste && + embbeddableUrls.length > 0 && + embbeddableUrls.length === nonEmptyLines.length + ) { + const embeddables: NonDeleted[] = []; + for (const url of embbeddableUrls) { + const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = + embeddables[embeddables.length - 1]; + const embeddable = this.insertEmbeddableElement({ + sceneX: prevEmbeddable + ? prevEmbeddable.x + prevEmbeddable.width + 20 + : sceneX, + sceneY, + link: normalizeLink(url), + }); + if (embeddable) { + embeddables.push(embeddable); + } + } + if (embeddables.length) { + this.store.scheduleCapture(); + this.setState({ + selectedElementIds: Object.fromEntries( + embeddables.map((embeddable) => [embeddable.id, true]), + ), + }); + } + return; + } + + // ------------------- Text ------------------- + this.addTextFromPaste(data.text, isPlainPaste); + } + public pasteFromClipboard = withBatchedUpdates( async (event: ClipboardEvent) => { const isPlainPaste = !!IS_PLAIN_PASTE; @@ -3038,47 +3847,14 @@ class App extends React.Component { return; } - const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( - { - clientX: this.lastViewportPosition.x, - clientY: this.lastViewportPosition.y, - }, - this.state, - ); - // must be called in the same frame (thus before any awaits) as the paste // event else some browsers (FF...) will clear the clipboardData // (something something security) - let file = event?.clipboardData?.files[0]; - const data = await parseClipboard(event, isPlainPaste); - if (!file && !isPlainPaste) { - if (data.mixedContent) { - return this.addElementsFromMixedContentPaste(data.mixedContent, { - isPlainPaste, - sceneX, - sceneY, - }); - } else if (data.text) { - const string = data.text.trim(); - if (string.startsWith("")) { - // ignore SVG validation/normalization which will be done during image - // initialization - file = SVGStringToFile(string); - } - } - } + const dataTransferList = await parseDataTransferEvent(event); - // prefer spreadsheet data over image file (MS Office/Libre Office) - if (isSupportedImageFile(file) && !data.spreadsheet) { - if (!this.isToolSupported("image")) { - this.setState({ errorMessage: t("errors.imageToolNotSupported") }); - return; - } + const filesList = dataTransferList.getFiles(); - this.createImageElement({ sceneX, sceneY, imageFile: file }); - - return; - } + const data = await parseClipboard(dataTransferList, isPlainPaste); if (this.props.onPaste) { try { @@ -3090,106 +3866,12 @@ class App extends React.Component { } } - if (data.errorMessage) { - this.setState({ errorMessage: data.errorMessage }); - } else if (data.spreadsheet && !isPlainPaste) { - this.setState({ - pasteDialog: { - data: data.spreadsheet, - shown: true, - }, - }); - } else if (data.elements) { - const elements = ( - data.programmaticAPI - ? convertToExcalidrawElements( - data.elements as ExcalidrawElementSkeleton[], - ) - : data.elements - ) as readonly ExcalidrawElement[]; - // TODO remove formatting from elements if isPlainPaste - this.addElementsFromPasteOrLibrary({ - elements, - files: data.files || null, - position: "cursor", - retainSeed: isPlainPaste, - }); - } else if (data.text) { - if (data.text && isMaybeMermaidDefinition(data.text)) { - const api = await import("@excalidraw/mermaid-to-excalidraw"); + await this.insertClipboardContent(data, filesList, isPlainPaste); - try { - const { elements: skeletonElements, files } = - await api.parseMermaidToExcalidraw(data.text); - - const elements = convertToExcalidrawElements(skeletonElements, { - regenerateIds: true, - }); - - this.addElementsFromPasteOrLibrary({ - elements, - files, - position: "cursor", - }); - - return; - } catch (err: any) { - console.warn( - `parsing pasted text as mermaid definition failed: ${err.message}`, - ); - } - } - - const nonEmptyLines = normalizeEOL(data.text) - .split(/\n+/) - .map((s) => s.trim()) - .filter(Boolean); - - const embbeddableUrls = nonEmptyLines - .map((str) => maybeParseEmbedSrc(str)) - .filter((string) => { - return ( - embeddableURLValidator(string, this.props.validateEmbeddable) && - (/^(http|https):\/\/[^\s/$.?#].[^\s]*$/.test(string) || - getEmbedLink(string)?.type === "video") - ); - }); - - if ( - !IS_PLAIN_PASTE && - embbeddableUrls.length > 0 && - // if there were non-embeddable text (lines) mixed in with embeddable - // urls, ignore and paste as text - embbeddableUrls.length === nonEmptyLines.length - ) { - const embeddables: NonDeleted[] = []; - for (const url of embbeddableUrls) { - const prevEmbeddable: ExcalidrawEmbeddableElement | undefined = - embeddables[embeddables.length - 1]; - const embeddable = this.insertEmbeddableElement({ - sceneX: prevEmbeddable - ? prevEmbeddable.x + prevEmbeddable.width + 20 - : sceneX, - sceneY, - link: normalizeLink(url), - }); - if (embeddable) { - embeddables.push(embeddable); - } - } - if (embeddables.length) { - this.store.scheduleCapture(); - this.setState({ - selectedElementIds: Object.fromEntries( - embeddables.map((embeddable) => [embeddable.id, true]), - ), - }); - } - return; - } - this.addTextFromPaste(data.text, isPlainPaste); - } - this.setActiveTool({ type: "selection" }); + this.setActiveTool( + { type: this.state.preferredSelectionTool.type }, + true, + ); event?.preventDefault(); }, ); @@ -3201,7 +3883,9 @@ class App extends React.Component { retainSeed?: boolean; fitToContent?: boolean; }) => { - const elements = restoreElements(opts.elements, null, undefined); + const elements = restoreElements(opts.elements, null, { + deleteInvisibleElements: true, + }); const [minX, minY, maxX, maxY] = getCommonBounds(elements); const elementsCenterX = distance(minX, maxX) / 2; @@ -3305,7 +3989,7 @@ class App extends React.Component { // from library, not when pasting from clipboard. Alas. openSidebar: this.state.openSidebar && - this.device.editor.canFitSidebar && + this.editorInterface.canFitSidebar && editorJotaiStore.get(isSidebarDockedAtom) ? this.state.openSidebar : null, @@ -3333,7 +4017,7 @@ class App extends React.Component { } }, ); - this.setActiveTool({ type: "selection" }); + this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true); if (opts.fitToContent) { this.scrollToContent(duplicatedElements, { @@ -3376,45 +4060,11 @@ class App extends React.Component { } }), ); - let y = sceneY; - let firstImageYOffsetDone = false; - const nextSelectedIds: Record = {}; - for (const response of responses) { - if (response.file) { - const initializedImageElement = await this.createImageElement({ - sceneX, - sceneY: y, - imageFile: response.file, - }); - - if (initializedImageElement) { - // vertically center first image in the batch - if (!firstImageYOffsetDone) { - firstImageYOffsetDone = true; - y -= initializedImageElement.height / 2; - } - // hack to reset the `y` coord because we vertically center during - // insertImageElement - this.scene.mutateElement( - initializedImageElement, - { y }, - { informMutation: false, isDragging: false }, - ); - - y = initializedImageElement.y + initializedImageElement.height + 25; - - nextSelectedIds[initializedImageElement.id] = true; - } - } - } - - this.setState({ - selectedElementIds: makeNextSelectedElementIds( - nextSelectedIds, - this.state, - ), - }); + const imageFiles = responses + .filter((response): response is { file: File } => !!response.file) + .map((response) => response.file); + await this.insertImages(imageFiles, sceneX, sceneY); const error = responses.find((response) => !!response.errorMessage); if (error && error.errorMessage) { this.setState({ errorMessage: error.errorMessage }); @@ -3537,7 +4187,7 @@ class App extends React.Component { !isPlainPaste && textElements.length > 1 && PLAIN_PASTE_TOAST_SHOWN === false && - !this.device.editor.isMobile + this.editorInterface.formFactor !== "phone" ) { this.setToast({ message: t("toast.pasteAsSingleElement", { @@ -3569,7 +4219,9 @@ class App extends React.Component { trackEvent( "toolbar", "toggleLock", - `${source} (${this.device.editor.isMobile ? "mobile" : "desktop"})`, + `${source} (${ + this.editorInterface.formFactor === "phone" ? "mobile" : "desktop" + })`, ); } this.setState((prevState) => { @@ -3579,7 +4231,7 @@ class App extends React.Component { ...updateActiveTool( this.state, prevState.activeTool.locked - ? { type: "selection" } + ? { type: this.state.preferredSelectionTool.type } : prevState.activeTool, ), locked: !prevState.activeTool.locked, @@ -3803,13 +4455,7 @@ class App extends React.Component { this.setState(state); }; - setToast = ( - toast: { - message: string; - closable?: boolean; - duration?: number; - } | null, - ) => { + setToast = (toast: AppState["toast"]) => { this.setState({ toast }); }; @@ -3921,7 +4567,7 @@ class App extends React.Component { } if (appState) { - this.setState(appState); + this.setState(appState as Pick | null); } if (elements) { @@ -3934,6 +4580,27 @@ class App extends React.Component { }, ); + public applyDeltas = ( + deltas: StoreDelta[], + options?: ApplyToOptions, + ): [SceneElementsMap, AppState, boolean] => { + // squash all deltas together, starting with a fresh new delta instance + const aggregatedDelta = StoreDelta.squash(...deltas); + + // create new instance of elements map & appState, so we don't accidentaly mutate existing ones + const nextAppState = { ...this.state }; + const nextElements = new Map( + this.scene.getElementsMapIncludingDeleted(), + ) as SceneElementsMap; + + return StoreDelta.applyTo( + aggregatedDelta, + nextElements, + nextAppState, + options, + ); + }; + public mutateElement = >( element: TElement, updates: ElementUpdate, @@ -4322,16 +4989,103 @@ class App extends React.Component { return; } + // Handle Alt key for bind mode + if (event.key === KEYS.ALT) { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleSkipBindMode(); + } else { + maybeHandleArrowPointlikeDrag({ app: this, event }); + } + } + if (this.actionManager.handleKeyDown(event)) { return; } + // view mode hardcoded from upstream -> disable tool switching for now + const shouldPreventToolSwitching = this.props.viewModeEnabled === true; + + if ( + !shouldPreventToolSwitching && + this.state.viewModeEnabled && + event.key === KEYS.ESCAPE + ) { + this.setActiveTool({ type: "selection" }); + return; + } + + if ( + !shouldPreventToolSwitching && + !event.ctrlKey && + !event.altKey && + !event.metaKey && + !this.state.newElement && + !this.state.selectionElement && + !this.state.selectedElementsAreBeingDragged + ) { + const shape = findShapeByKey(event.key, this); + + if (this.state.viewModeEnabled && !oneOf(shape, ["laser", "hand"])) { + return; + } + + if (shape) { + if (this.state.activeTool.type !== shape) { + trackEvent( + "toolbar", + shape, + `keyboard (${ + this.editorInterface.formFactor === "phone" + ? "mobile" + : "desktop" + })`, + ); + } + if (shape === "arrow" && this.state.activeTool.type === "arrow") { + this.setState((prevState) => ({ + currentItemArrowType: + prevState.currentItemArrowType === ARROW_TYPE.sharp + ? ARROW_TYPE.round + : prevState.currentItemArrowType === ARROW_TYPE.round + ? ARROW_TYPE.elbow + : ARROW_TYPE.sharp, + })); + } + + if (shape === "lasso" && this.state.activeTool.type === "laser") { + this.setActiveTool({ + type: this.state.preferredSelectionTool.type, + }); + } else { + this.setActiveTool({ type: shape }); + } + + event.stopPropagation(); + + return; + } else if (event.key === KEYS.Q) { + this.toggleLock("keyboard"); + event.stopPropagation(); + return; + } + } + if (this.state.viewModeEnabled) { return; } - if (event[KEYS.CTRL_OR_CMD] && this.state.isBindingEnabled) { - this.setState({ isBindingEnabled: false }); + if (event[KEYS.CTRL_OR_CMD] && !event.repeat) { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + + flushSync(() => { + this.setState({ + isBindingEnabled: this.state.bindingPreference !== "enabled", + }); + }); + + maybeHandleArrowPointlikeDrag({ app: this, event }); } if (isArrowKey(event.key)) { @@ -4341,14 +5095,12 @@ class App extends React.Component { includeElementsInFrames: true, }); - const elbowArrow = selectedElements.find(isElbowArrow) as - | ExcalidrawArrowElement - | undefined; - const arrowIdsToRemove = new Set(); selectedElements - .filter(isElbowArrow) + .filter((el): el is NonDeleted => + isBindingElement(el), + ) .filter((arrow) => { const startElementNotInSelection = arrow.startBinding && @@ -4405,16 +5157,6 @@ class App extends React.Component { }); }); - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements.filter( - (element) => element.id !== elbowArrow?.id || step !== 0, - ), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ), - }); - this.scene.triggerUpdate(); event.preventDefault(); @@ -4463,42 +5205,8 @@ class App extends React.Component { }); } } - } else if ( - !event.ctrlKey && - !event.altKey && - !event.metaKey && - !this.state.newElement && - !this.state.selectionElement && - !this.state.selectedElementsAreBeingDragged - ) { - const shape = findShapeByKey(event.key); - if (shape) { - if (this.state.activeTool.type !== shape) { - trackEvent( - "toolbar", - shape, - `keyboard (${ - this.device.editor.isMobile ? "mobile" : "desktop" - })`, - ); - } - if (shape === "arrow" && this.state.activeTool.type === "arrow") { - this.setState((prevState) => ({ - currentItemArrowType: - prevState.currentItemArrowType === ARROW_TYPE.sharp - ? ARROW_TYPE.round - : prevState.currentItemArrowType === ARROW_TYPE.round - ? ARROW_TYPE.elbow - : ARROW_TYPE.sharp, - })); - } - this.setActiveTool({ type: shape }); - event.stopPropagation(); - } else if (event.key === KEYS.Q) { - this.toggleLock("keyboard"); - event.stopPropagation(); - } } + if (event.key === KEYS.SPACE && gesture.pointers.size === 0) { isHoldingSpace = true; setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); @@ -4562,15 +5270,6 @@ class App extends React.Component { } } - if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) { - if (this.state.activeTool.type === "laser") { - this.setActiveTool({ type: "selection" }); - } else { - this.setActiveTool({ type: "laser" }); - } - return; - } - if ( event[KEYS.CTRL_OR_CMD] && (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) @@ -4581,7 +5280,8 @@ class App extends React.Component { // eye dropper // ----------------------------------------------------------------------- const lowerCased = event.key.toLocaleLowerCase(); - const isPickingStroke = lowerCased === KEYS.S && event.shiftKey; + const isPickingStroke = + lowerCased === KEYS.S && event.shiftKey && !event[KEYS.CTRL_OR_CMD]; const isPickingBackground = event.key === KEYS.I || (lowerCased === KEYS.G && event.shiftKey); @@ -4597,14 +5297,12 @@ class App extends React.Component { private onKeyUp = withBatchedUpdates((event: KeyboardEvent) => { if (event.key === KEYS.SPACE) { if ( - this.state.viewModeEnabled || + (this.state.viewModeEnabled && + this.state.activeTool.type !== "laser") || this.state.openDialog?.name === "elementLinkSelector" ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); - } else if ( - this.state.activeTool.type === "selection" || - this.state.activeTool.type === "lasso" - ) { + } else if (isSelectionLikeTool(this.state.activeTool.type)) { resetCursor(this.interactiveCanvas); } else { setCursorForShape(this.interactiveCanvas, this.state); @@ -4617,18 +5315,107 @@ class App extends React.Component { } isHoldingSpace = false; } - if (!event[KEYS.CTRL_OR_CMD] && !this.state.isBindingEnabled) { - this.setState({ isBindingEnabled: true }); + + if (event.key === KEYS.ALT) { + maybeHandleArrowPointlikeDrag({ app: this, event }); + } + + if ( + (event.key === KEYS.ALT && this.state.bindMode === "skip") || + (!event[KEYS.CTRL_OR_CMD] && !isBindingEnabled(this.state)) + ) { + // Handle Alt key release for bind mode + this.setState({ + bindMode: "orbit", + }); + + // Restart the timer if we're creating/editing a linear element and hovering over an element + if (this.lastPointerMoveEvent && getFeatureFlag("COMPLEX_BINDINGS")) { + const scenePointer = viewportCoordsToSceneCoords( + { + clientX: this.lastPointerMoveEvent.clientX, + clientY: this.lastPointerMoveEvent.clientY, + }, + this.state, + ); + + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointer.x, scenePointer.y), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); + + if (this.state.selectedLinearElement) { + const element = LinearElementEditor.getElement( + this.state.selectedLinearElement.elementId, + this.scene.getNonDeletedElementsMap(), + ); + + if (isBindingElement(element)) { + this.handleDelayedBindModeChange(element, hoveredElement); + } + } + } + } + if (!event[KEYS.CTRL_OR_CMD]) { + const preferenceEnabled = this.state.bindingPreference === "enabled"; + if (this.state.isBindingEnabled !== preferenceEnabled) { + flushSync(() => { + this.setState({ isBindingEnabled: preferenceEnabled }); + }); + } + + maybeHandleArrowPointlikeDrag({ app: this, event }); } if (isArrowKey(event.key)) { - bindOrUnbindLinearElements( - this.scene.getSelectedElements(this.state).filter(isLinearElement), - isBindingEnabled(this.state), - this.state.selectedLinearElement?.selectedPointsIndices ?? [], + bindOrUnbindBindingElements( + this.scene.getSelectedElements(this.state).filter(isArrowElement), this.scene, - this.state.zoom, + this.state, ); - this.setState({ suggestedBindings: [] }); + + const elementsMap = this.scene.getNonDeletedElementsMap(); + + this.scene + .getSelectedElements(this.state) + .filter(isSimpleArrow) + .forEach((element) => { + // Update the fixed point bindings for non-elbow arrows + // when the pointer is released, so that they are correctly positioned + // after the drag. + if (element.startBinding) { + this.scene.mutateElement(element, { + startBinding: { + ...element.startBinding, + ...calculateFixedPointForNonElbowArrowBinding( + element, + elementsMap.get( + element.startBinding.elementId, + ) as ExcalidrawBindableElement, + "start", + elementsMap, + ), + }, + }); + } + if (element.endBinding) { + this.scene.mutateElement(element, { + endBinding: { + ...element.endBinding, + ...calculateFixedPointForNonElbowArrowBinding( + element, + elementsMap.get( + element.endBinding.elementId, + ) as ExcalidrawBindableElement, + "end", + elementsMap, + ), + }, + }); + } + }); + + this.setState({ suggestedBinding: null }); } if (!event.altKey) { @@ -4727,10 +5514,10 @@ class App extends React.Component { this.focusContainer(); } if (!isLinearElementType(nextActiveTool.type)) { - this.setState({ suggestedBindings: [] }); + this.setState({ suggestedBinding: null }); } if (nextActiveTool.type === "image") { - this.onImageAction(); + this.onImageToolbarButtonClick(); } this.setState((prevState) => { @@ -4738,6 +5525,9 @@ class App extends React.Component { snapLines: prevState.snapLines.length ? [] : prevState.snapLines, originSnapOffset: null, activeEmbeddable: null, + selectedLinearElement: isSelectionLikeTool(nextActiveTool.type) + ? prevState.selectedLinearElement + : null, } as const; if (nextActiveTool.type === "freedraw") { @@ -4747,6 +5537,7 @@ class App extends React.Component { if (nextActiveTool.type === "lasso") { return { ...prevState, + ...commonResets, activeTool: nextActiveTool, ...(keepSelection ? {} @@ -4756,23 +5547,22 @@ class App extends React.Component { editingGroupId: null, multiElement: null, }), - ...commonResets, }; } else if (nextActiveTool.type !== "selection") { return { ...prevState, + ...commonResets, activeTool: nextActiveTool, selectedElementIds: makeNextSelectedElementIds({}, prevState), selectedGroupIds: makeNextSelectedElementIds({}, prevState), editingGroupId: null, multiElement: null, - ...commonResets, }; } return { ...prevState, - activeTool: nextActiveTool, ...commonResets, + activeTool: nextActiveTool, }; }); }; @@ -4876,8 +5666,14 @@ class App extends React.Component { element: ExcalidrawTextElement, { isExistingElement = false, + initialCaretSceneCoords = null, }: { isExistingElement?: boolean; + /** + * supply null if no caret positioning is desired, and instead + * text should be auto-selected + */ + initialCaretSceneCoords?: { x: number; y: number } | null; }, ) { const elementsMap = this.scene.getElementsMapIncludingDeleted(); @@ -4928,24 +5724,15 @@ class App extends React.Component { }), onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => { const isDeleted = !nextOriginalText.trim(); + updateElement(nextOriginalText, isDeleted); - if (isDeleted && !isExistingElement) { - // let's just remove the element from the scene, as it's an empty just created text element - this.scene.replaceAllElements( - this.scene - .getElementsIncludingDeleted() - .filter((x) => x.id !== element.id), - ); - } else { - updateElement(nextOriginalText, isDeleted); - } - // select the created text element only if submitting via keyboard - // (when submitting via click it should act as signal to deselect) - if (!isDeleted && viaKeyboard) { - const elementIdToSelect = element.containerId - ? element.containerId - : element.id; + // keyboard-submit keeps focus on the edited object. For bound text, keep + // the container selected even if the text becomes empty and is deleted. + const elementIdToSelect = viaKeyboard + ? element.containerId || (!isDeleted ? element.id : null) + : null; + if (elementIdToSelect) { // needed to ensure state is updated before "finalize" action // that's invoked on keyboard-submit as well // TODO either move this into finalize as well, or handle all state @@ -4962,15 +5749,16 @@ class App extends React.Component { })); }); } + if (isDeleted) { fixBindingsAfterDeletion(this.scene.getNonDeletedElements(), [ element, ]); } - // we need to record either way, whether the text element was added or removed - // since we need to sync this delta to other clients, otherwise it would end up with inconsistencies - this.store.scheduleCapture(); + if (!isDeleted || isExistingElement) { + this.store.scheduleCapture(); + } flushSync(() => { this.setState({ @@ -4988,11 +5776,12 @@ class App extends React.Component { element, excalidrawContainer: this.excalidrawContainerRef.current, app: this, + initialCaretSceneCoords, // when text is selected, it's hard (at least on iOS) to re-position the // caret (i.e. deselect). There's not much use for always selecting // the text on edit anyway (and users can select-all from contextmenu // if needed) - autoSelect: !this.device.isTouchScreen, + autoSelect: !this.editorInterface.isTouchScreen, }); // deselect all other elements when inserting text this.deselectElements(); @@ -5011,6 +5800,68 @@ class App extends React.Component { }); } + private getSelectedTextElement( + container?: ExcalidrawTextContainer | null, + ): NonDeleted | null { + const selectedElements = this.scene.getSelectedElements(this.state); + + if (selectedElements.length !== 1) { + return null; + } + + const selectedElement = selectedElements[0]!; + + if (isTextElement(selectedElement)) { + return selectedElement; + } + + if (!container) { + return null; + } + + return getBoundTextElement( + selectedElement, + this.scene.getNonDeletedElementsMap(), + ); + } + + private getSelectedTextEditingContainerAtPosition( + hitElement: NonDeletedExcalidrawElement | null, + sceneCoords: { x: number; y: number }, + ): ExcalidrawTextContainer | null | undefined { + const selectedElements = this.scene.getSelectedElements(this.state); + + if ( + selectedElements.length !== 1 || + !hitElement || + hitElement.id !== selectedElements[0]!.id + ) { + return null; + } + + const selectedElement = selectedElements[0]!; + + if (isTextElement(selectedElement)) { + return null; + } + + if (!isValidTextContainer(selectedElement)) { + return undefined; + } + + const textElement = this.getSelectedTextElement(selectedElement); + const hitTextElement = this.getTextElementAtPosition( + sceneCoords.x, + sceneCoords.y, + ); + + if (!textElement || hitTextElement?.id !== textElement.id) { + return undefined; + } + + return selectedElement; + } + private getTextElementAtPosition( x: number, y: number, @@ -5024,6 +5875,58 @@ class App extends React.Component { return null; } + private isHittingTextAutoResizeHandle = ( + selectedElements: NonDeleted[], + point: Readonly<{ x: number; y: number }>, + ): boolean => { + const activeTextElement = getActiveTextElement( + selectedElements, + this.state, + ); + + if ( + activeTextElement && + !activeTextElement.isDeleted && + !activeTextElement.autoResize && + isPointHittingTextAutoResizeHandle( + point, + activeTextElement, + this.state.zoom.value, + this.editorInterface.formFactor, + ) + ) { + return true; + } + + return false; + }; + + private handleTextAutoResizeHandlePointerDown = ( + selectedElements: NonDeleted[], + point: Readonly<{ x: number; y: number }>, + ) => { + const activeTextElement = getActiveTextElement( + selectedElements, + this.state, + ); + if ( + !activeTextElement || + !this.isHittingTextAutoResizeHandle(selectedElements, point) + ) { + return false; + } + + this.actionManager.executeAction( + actionTextAutoResize, + "ui", + // we need to pass down the element since it may already be deselected + // due to the pointerdown + activeTextElement, + ); + this.resetCursor(); + return true; + }; + // NOTE: Hot path for hit testing, so avoid unnecessary computations private getElementAtPosition( x: number, @@ -5155,7 +6058,7 @@ class App extends React.Component { if ( considerBoundingBox && this.state.selectedElementIds[element.id] && - shouldShowBoundingBox([element], this.state) + hasBoundingBox([element], this.state, this.editorInterface) ) { // if hitting the bounding box, return early // but if not, we should check for other cases as well (e.g. frame name) @@ -5236,6 +6139,7 @@ class App extends React.Component { insertAtParentCenter = true, container, autoEdit = true, + initialCaretSceneCoords, }: { /** X position to insert text at */ sceneX: number; @@ -5245,6 +6149,7 @@ class App extends React.Component { insertAtParentCenter?: boolean; container?: ExcalidrawTextContainer | null; autoEdit?: boolean; + initialCaretSceneCoords?: { x: number; y: number }; }) => { let shouldBindToContainer = false; @@ -5265,24 +6170,9 @@ class App extends React.Component { shouldBindToContainer = true; } } - let existingTextElement: NonDeleted | null = null; - - const selectedElements = this.scene.getSelectedElements(this.state); - - if (selectedElements.length === 1) { - if (isTextElement(selectedElements[0])) { - existingTextElement = selectedElements[0]; - } else if (container) { - existingTextElement = getBoundTextElement( - selectedElements[0], - this.scene.getNonDeletedElementsMap(), - ); - } else { - existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - } - } else { - existingTextElement = this.getTextElementAtPosition(sceneX, sceneY); - } + const existingTextElement = + this.getSelectedTextElement(container) || + this.getTextElementAtPosition(sceneX, sceneY); const fontFamily = existingTextElement?.fontFamily || this.state.currentItemFontFamily; @@ -5329,11 +6219,32 @@ class App extends React.Component { y: sceneY, }); + const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY); + + const newTextElementPosition = parentCenterPosition + ? { + x: parentCenterPosition.elementCenterX, + y: parentCenterPosition.elementCenterY, + } + : !existingTextElement + ? { + x: textCreationGridPoint?.x ?? sceneX, + y: + textCreationGridPoint === null + ? // Free text starts from a point cursor, so center the first line box on it. + sceneY - getLineHeightInPx(fontSize, lineHeight) / 2 + : textCreationGridPoint.y, + } + : { + x: sceneX, + y: sceneY, + }; + const element = existingTextElement || newTextElement({ - x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX, - y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY, + x: newTextElementPosition.x, + y: newTextElementPosition.y, strokeColor: this.state.currentItemStrokeColor, backgroundColor: this.state.currentItemBackgroundColor, fillStyle: this.state.currentItemFillStyle, @@ -5383,6 +6294,9 @@ class App extends React.Component { if (autoEdit || existingTextElement || container) { this.handleTextWysiwyg(element, { isExistingElement: !!existingTextElement, + initialCaretSceneCoords: existingTextElement + ? initialCaretSceneCoords + : null, }); } else { this.setState({ @@ -5408,16 +6322,55 @@ class App extends React.Component { } }; + private shouldHandleBrowserCanvasDoubleClick = (type: string) => { + // TODO remove this once we consolidate double-click logic and handle + // ourselves for all event types together + if (type === "touch") { + return true; + } + if (this.lastCompletedCanvasClicks.length === 0) { + return true; + } + + if (this.lastCompletedCanvasClicks.length < 2) { + return false; + } + + const [firstClick, secondClick] = this.lastCompletedCanvasClicks; + + return ( + pointDistance( + pointFrom(firstClick.x, firstClick.y), + pointFrom(secondClick.x, secondClick.y), + ) <= DOUBLE_TAP_POSITION_THRESHOLD + ); + }; + private handleCanvasDoubleClick = ( - event: React.MouseEvent, + event: Pick< + React.MouseEvent, + | "type" + | "clientX" + | "clientY" + | "altKey" + | "ctrlKey" + | "metaKey" + | "shiftKey" + >, ) => { + if ( + this.state.editingTextElement || + !this.shouldHandleBrowserCanvasDoubleClick(event.type) + ) { + return; + } // case: double-clicking with arrow/line tool selected would both create // text and enter multiElement mode if (this.state.multiElement) { return; } // we should only be able to double click when mode is selection - if (this.state.activeTool.type !== "selection") { + if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) { return; } @@ -5489,8 +6442,8 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - pointerDownState: { - ...this.state.selectedLinearElement.pointerDownState, + initialState: { + ...this.state.selectedLinearElement.initialState, segmentMidpoint: { index: nextIndex, value: hitCoords, @@ -5598,6 +6551,21 @@ class App extends React.Component { } }; + private handleCanvasClick = (event: React.MouseEvent) => { + if (event.button !== POINTER_BUTTON.MAIN) { + this.lastCompletedCanvasClicks = []; + return; + } + + this.lastCompletedCanvasClicks = [ + ...this.lastCompletedCanvasClicks.slice(-1), + { + x: event.clientX, + y: event.clientY, + }, + ]; + }; + private getElementLinkAtPosition = ( scenePointer: Readonly<{ x: number; y: number }>, hitElementMightBeLocked: NonDeletedExcalidrawElement | null, @@ -5625,7 +6593,7 @@ class App extends React.Component { this.scene.getNonDeletedElementsMap(), this.state, pointFrom(scenePointer.x, scenePointer.y), - this.device.editor.isMobile, + this.editorInterface.formFactor === "phone", ) ) { return element; @@ -5633,9 +6601,8 @@ class App extends React.Component { } }; - private redirectToLink = ( + private handleElementLinkClick = ( event: React.PointerEvent, - isTouchScreen: boolean, ) => { const draggedDistance = pointDistance( pointFrom( @@ -5660,7 +6627,7 @@ class App extends React.Component { elementsMap, this.state, pointFrom(lastPointerDownCoords.x, lastPointerDownCoords.y), - this.device.editor.isMobile, + this.editorInterface.formFactor === "phone", ); const lastPointerUpCoords = viewportCoordsToSceneCoords( this.lastPointerUpEvent!, @@ -5671,7 +6638,7 @@ class App extends React.Component { elementsMap, this.state, pointFrom(lastPointerUpCoords.x, lastPointerUpCoords.y), - this.device.editor.isMobile, + this.editorInterface.formFactor === "phone", ); if (lastPointerDownHittingLinkIcon && lastPointerUpHittingLinkIcon) { hideHyperlinkToolip(); @@ -5709,8 +6676,9 @@ class App extends React.Component { const elementsMap = this.scene.getNonDeletedElementsMap(); const frames = this.scene .getNonDeletedFramesLikes() - .filter((frame): frame is ExcalidrawFrameLikeElement => - isCursorInFrame(sceneCoords, frame, elementsMap), + .filter( + (frame): frame is ExcalidrawFrameLikeElement => + !frame.locked && isCursorInFrame(sceneCoords, frame, elementsMap), ); return frames.length ? frames[frames.length - 1] : null; @@ -5721,6 +6689,12 @@ class App extends React.Component { ) => { this.savePointer(event.clientX, event.clientY, this.state.cursorButton); this.lastPointerMoveEvent = event.nativeEvent; + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; if (gesture.pointers.has(event.pointerId)) { gesture.pointers.set(event.pointerId, { @@ -5770,6 +6744,8 @@ class App extends React.Component { scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom), shouldCacheIgnoreZoom: true, }); + + return null; }); this.resetShouldCacheIgnoreZoomDebounced(); } else { @@ -5807,10 +6783,6 @@ class App extends React.Component { } } - const scenePointer = viewportCoordsToSceneCoords(event, this.state); - const { x: scenePointerX, y: scenePointerY } = scenePointer; - - // snap origin of the new element that's to be created if ( !this.state.newElement && (isActiveToolNonLinearSnappable(this.state.activeTool.type) || @@ -5865,19 +6837,23 @@ class App extends React.Component { this.state.selectedLinearElement?.isEditing && !this.state.selectedLinearElement.isDragging ) { - const result = LinearElementEditor.handlePointerMove( - event, - scenePointerX, - scenePointerY, - this, - ); + const result = this.state.newElement + ? null + : LinearElementEditor.handlePointerMoveInEditMode( + event, + scenePointerX, + scenePointerY, + this, + ); if (result) { const { editingLinearElement, snapLines } = result; + const nextSnapLines = updateStable(this.state.snapLines, snapLines); if ( editingLinearElement && - editingLinearElement !== this.state.selectedLinearElement + (editingLinearElement !== this.state.selectedLinearElement || + nextSnapLines !== this.state.snapLines) ) { // Since we are reading from previous state which is not possible with // automatic batching in React 18 hence using flush sync to synchronously @@ -5885,24 +6861,15 @@ class App extends React.Component { flushSync(() => { this.setState({ selectedLinearElement: editingLinearElement, - snapLines, + snapLines: nextSnapLines, }); }); } - const latestLinearElement = this.scene.getElement( - editingLinearElement.elementId, - ); if ( - editingLinearElement.lastUncommittedPoint != null && - latestLinearElement && - isBindingElementType(latestLinearElement.type) + editingLinearElement.lastUncommittedPoint == null && + this.state.suggestedBinding ) { - this.maybeSuggestBindingAtCursor( - scenePointer, - editingLinearElement.elbowed, - ); - } else if (this.state.suggestedBindings.length) { - this.setState({ suggestedBindings: [] }); + this.setState({ suggestedBinding: null }); } } } @@ -5911,34 +6878,89 @@ class App extends React.Component { // Hovering with a selected tool or creating new linear element via click // and point const { newElement } = this.state; - if (isBindingElement(newElement, false)) { - this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( - newElement, - [scenePointer], - this.scene, - this.state.zoom, - this.state.startBoundElement, - ), - }); - } else { - this.maybeSuggestBindingAtCursor(scenePointer, false); + if (!newElement && isBindingEnabled(this.state)) { + const globalPoint = pointFrom( + scenePointerX, + scenePointerY, + ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const hoveredElement = getHoveredElementForBinding( + globalPoint, + this.scene.getNonDeletedElements(), + elementsMap, + maxBindingDistance_simple(this.state.zoom), + ); + if (hoveredElement) { + this.setState({ + suggestedBinding: { + element: hoveredElement, + midPoint: getSnapOutlineMidPoint( + globalPoint, + hoveredElement, + elementsMap, + this.state.zoom, + ), + }, + }); + } else if (this.state.suggestedBinding) { + this.setState({ + suggestedBinding: null, + }); + } } } - if (this.state.multiElement) { - const { multiElement } = this.state; - const { x: rx, y: ry } = multiElement; - - const { points, lastCommittedPoint } = multiElement; + if (this.state.multiElement && this.state.selectedLinearElement) { + const { multiElement, selectedLinearElement } = this.state; + const { x: rx, y: ry, points } = multiElement; const lastPoint = points[points.length - 1]; + const { lastCommittedPoint } = selectedLinearElement; + setCursorForShape(this.interactiveCanvas, this.state); if (lastPoint === lastCommittedPoint) { - // if we haven't yet created a temp point and we're beyond commit-zone - // threshold, add a point - if ( + const hoveredElement = + isArrowElement(this.state.newElement) && + isBindingEnabled(this.state) && + getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + maxBindingDistance_simple(this.state.zoom), + ); + if (hoveredElement) { + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: scenePointerX, + y: scenePointerY, + }, + }); + this.setState({ suggestedBinding: null, startBoundElement: null }); + if (!this.state.activeTool.locked) { + resetCursor(this.interactiveCanvas); + this.setState((prevState) => ({ + newElement: null, + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), + selectedElementIds: makeNextSelectedElementIds( + { + ...prevState.selectedElementIds, + [multiElement.id]: true, + }, + prevState, + ), + selectedLinearElement: new LinearElementEditor( + multiElement, + this.scene.getNonDeletedElementsMap(), + ), + })); + } + } else if ( + // if we haven't yet created a temp point and we're beyond commit-zone + // threshold, add a point pointDistance( pointFrom(scenePointerX - rx, scenePointerY - ry), lastPoint, @@ -5954,6 +6976,21 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); + invariant( + this.state.selectedLinearElement?.initialState, + "initialState must be set", + ); + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + lastCommittedPoint: points[points.length - 1], + selectedPointsIndices: [multiElement.points.length - 1], + initialState: { + ...this.state.selectedLinearElement.initialState, + lastClickedPoint: multiElement.points.length - 1, + }, + }, + }); } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); // in this branch, we're inside the commit zone, and no uncommitted @@ -5975,125 +7012,99 @@ class App extends React.Component { }, { informMutation: false, isDragging: false }, ); - } else { - const [gridX, gridY] = getGridPoint( - scenePointerX, - scenePointerY, - event[KEYS.CTRL_OR_CMD] || isElbowArrow(multiElement) - ? null - : this.getEffectiveGridSize(), - ); - - const [lastCommittedX, lastCommittedY] = - multiElement?.lastCommittedPoint ?? [0, 0]; - - let dxFromLastCommitted = gridX - rx - lastCommittedX; - let dyFromLastCommitted = gridY - ry - lastCommittedY; - - const rotateWithDiscreteAngle = shouldRotateWithDiscreteAngle(event); - - if (rotateWithDiscreteAngle) { - ({ width: dxFromLastCommitted, height: dyFromLastCommitted } = - getLockedLinearCursorAlignSize( - // actual coordinate of the last committed point - lastCommittedX + rx, - lastCommittedY + ry, - // cursor-grid coordinate - gridX, - gridY, - )); - } - - const effectiveGridX = lastCommittedX + dxFromLastCommitted + rx; - const effectiveGridY = lastCommittedY + dyFromLastCommitted + ry; - - if (!isElbowArrow(multiElement)) { - const { snapOffset, snapLines } = snapLinearElementPoint( - this.scene.getNonDeletedElements(), - multiElement, - points.length - 1, - pointFrom(effectiveGridX, effectiveGridY), - this, - event, - this.scene.getNonDeletedElementsMap(), - { - includeSelfPoints: true, - selectedPointsIndices: [points.length - 1], + const newLastIdx = multiElement.points.length - 1; + this.setState({ + selectedLinearElement: { + ...selectedLinearElement, + selectedPointsIndices: selectedLinearElement.selectedPointsIndices + ? [ + ...new Set( + selectedLinearElement.selectedPointsIndices.map((idx) => + Math.min(idx, newLastIdx), + ), + ), + ] + : selectedLinearElement.selectedPointsIndices, + lastCommittedPoint: multiElement.points[newLastIdx], + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: newLastIdx, }, - ); - - if (snapLines.length > 0) { - if (rotateWithDiscreteAngle) { - // Create line from effective position to last committed point - const angleLine = line( - pointFrom(effectiveGridX, effectiveGridY), - pointFrom(lastCommittedX + rx, lastCommittedY + ry), - ); - - const result = snapToDiscreteAngle( - snapLines, - angleLine, - pointFrom(gridX, gridY), - pointFrom(lastCommittedX + rx, lastCommittedY + ry), - ); - - dxFromLastCommitted = result.dxFromReference; - dyFromLastCommitted = result.dyFromReference; - - this.setState({ - snapLines: result.snapLines, - }); - } else { - const snappedGridX = effectiveGridX + snapOffset.x; - const snappedGridY = effectiveGridY + snapOffset.y; - dxFromLastCommitted = snappedGridX - rx - lastCommittedX; - dyFromLastCommitted = snappedGridY - ry - lastCommittedY; - - this.setState({ - snapLines, - }); - } - } else { - this.setState({ - snapLines: [], - }); - } - } - + }, + }); + } else { if (isPathALoop(points, this.state.zoom.value)) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } - // update last uncommitted point - this.scene.mutateElement( - multiElement, - { - points: [ - ...points.slice(0, -1), - pointFrom( - lastCommittedX + dxFromLastCommitted, - lastCommittedY + dyFromLastCommitted, - ), - ], - }, - { - isDragging: true, - informMutation: false, - }, + + // Update arrow points + const elementsMap = this.scene.getNonDeletedElementsMap(); + + if (isSimpleArrow(multiElement)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + elementsMap, + ); + + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(multiElement, hoveredElement); + } + } + + invariant( + this.state.selectedLinearElement, + "Expected selectedLinearElement to be set to operate on a linear element", ); - // in this path, we're mutating multiElement to reflect - // how it will be after adding pointer position as the next point - // trigger update here so that new element canvas renders again to reflect this - this.triggerRender(false); + const newState = LinearElementEditor.handlePointerMove( + event.nativeEvent, + this, + scenePointerX, + scenePointerY, + this.state.selectedLinearElement, + ); + if (newState) { + this.setState(newState); + } } return; } - const hasDeselectedButton = Boolean(event.buttons); + if (this.state.activeTool.type === "arrow") { + const hit = getHoveredElementForBinding( + pointFrom(scenePointerX, scenePointerY), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + maxBindingDistance_simple(this.state.zoom), + ); + const scenePointer = pointFrom(scenePointerX, scenePointerY); + const elementsMap = this.scene.getNonDeletedElementsMap(); + if (hit && !isPointInElement(scenePointer, hit, elementsMap)) { + this.setState({ + suggestedBinding: { + element: hit, + midPoint: getSnapOutlineMidPoint( + scenePointer, + hit, + elementsMap, + this.state.zoom, + ), + }, + }); + } + } + + const isPressingAnyButton = Boolean(event.buttons); + const isLaserTool = this.state.activeTool.type === "laser"; if ( - hasDeselectedButton || - (this.state.activeTool.type !== "selection" && + isPressingAnyButton || + // checking against laser so that if you mouseover with a laser tool + // over a link/embeddable, we change the cursor + (!isLaserTool && + this.state.activeTool.type !== "selection" && + this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "text" && this.state.activeTool.type !== "eraser") ) { @@ -6103,6 +7114,12 @@ class App extends React.Component { const elements = this.scene.getNonDeletedElements(); const selectedElements = this.scene.getSelectedElements(this.state); + + if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + return; + } + if ( selectedElements.length === 1 && !isOverScrollBar && @@ -6122,7 +7139,14 @@ class App extends React.Component { (!this.state.selectedLinearElement || this.state.selectedLinearElement.hoverPointIndex === -1) && this.state.openDialog?.name !== "elementLinkSelector" && - !(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) + !(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) && + // HACK: Disable transform handles for linear elements on mobile until a + // better way of showing them is found + !( + isLinearElement(selectedElements[0]) && + (this.editorInterface.userAgent.isMobileDevice || + selectedElements[0].points.length === 2) + ) ) { const elementWithTransformHandleType = getElementWithTransformHandleType( @@ -6133,7 +7157,7 @@ class App extends React.Component { this.state.zoom, event.pointerType, this.scene.getNonDeletedElementsMap(), - this.device, + this.editorInterface, ); if ( elementWithTransformHandleType && @@ -6157,7 +7181,7 @@ class App extends React.Component { scenePointerY, this.state.zoom, event.pointerType, - this.device, + this.editorInterface, ); if (transformHandleType) { setCursor( @@ -6170,6 +7194,10 @@ class App extends React.Component { } } + if (isEraserActive(this.state)) { + return; + } + const hitElementMightBeLocked = this.getElementAtPosition( scenePointerX, scenePointerY, @@ -6186,18 +7214,25 @@ class App extends React.Component { hitElement = hitElementMightBeLocked; } - this.hitLinkElement = this.getElementLinkAtPosition( - scenePointer, - hitElementMightBeLocked, - ); - if (isEraserActive(this.state)) { - return; + if ( + !this.handleIframeLikeElementHover({ + hitElement, + scenePointer, + moveEvent: event, + }) + ) { + this.hitLinkElement = this.getElementLinkAtPosition( + scenePointer, + hitElementMightBeLocked, + ); } + if ( this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + showHyperlinkTooltip( this.hitLinkElement, this.state, @@ -6205,6 +7240,9 @@ class App extends React.Component { ); } else { hideHyperlinkToolip(); + if (isLaserTool) { + return; + } if ( hitElement && (hitElement.link || isEmbeddableElement(hitElement)) && @@ -6218,6 +7256,14 @@ class App extends React.Component { this.interactiveCanvas, isTextElement(hitElement) ? CURSOR_TYPE.TEXT : CURSOR_TYPE.CROSSHAIR, ); + } else if ( + !event[KEYS.CTRL_OR_CMD] && + this.isHittingCommonBoundingBoxOfSelectedElements( + scenePointer, + selectedElements, + ) + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } else if (this.state.viewModeEnabled) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else if (this.state.openDialog?.name === "elementLinkSelector") { @@ -6226,7 +7272,9 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } else if ( // if using cmd/ctrl, we're not dragging - !event[KEYS.CTRL_OR_CMD] + !event[KEYS.CTRL_OR_CMD] && + // editing text -> don't show move cursor when hovering over its bbox + hitElement?.id !== this.state.editingTextElement?.id ) { if ( (hitElement || @@ -6237,28 +7285,16 @@ class App extends React.Component { !hitElement?.locked ) { if ( - hitElement && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - event, - scenePointerX, - scenePointerY, - ) - ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); - this.setState({ - activeEmbeddable: { element: hitElement, state: "hover" }, - }); - } else if ( !hitElement || - // Ebow arrows can only be moved when unconnected + // Elbow arrows can only be moved when unconnected !isElbowArrow(hitElement) || !(hitElement.startBinding || hitElement.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); - if (this.state.activeEmbeddable?.state === "hover") { - this.setState({ activeEmbeddable: null }); + if ( + this.state.activeTool.type !== "lasso" || + selectedElements.length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); } } } @@ -6369,20 +7405,30 @@ class App extends React.Component { setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - // Ebow arrows can only be moved when unconnected + // Elbow arrows can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + Object.keys(this.state.selectedElementIds).length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } } } else if (this.hitElement(scenePointerX, scenePointerY, element)) { if ( - // Ebow arrows can only be moved when unconnected + // Elbow arrow can only be moved when unconnected !isElbowArrow(element) || !(element.startBinding || element.endBinding) ) { - setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + if ( + this.state.activeTool.type !== "lasso" || + Object.keys(this.state.selectedElementIds).length > 0 + ) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); + } } } @@ -6410,6 +7456,37 @@ class App extends React.Component { }, }); } + + // Check for focus point hover + let hoveredFocusPointBinding: "start" | "end" | null = null; + const arrow = element as any; + if (arrow.startBinding || arrow.endBinding) { + hoveredFocusPointBinding = handleFocusPointHover( + element as ExcalidrawArrowElement, + scenePointerX, + scenePointerY, + this.scene, + this.state, + ); + } + + if ( + this.state.selectedLinearElement.hoveredFocusPointBinding !== + hoveredFocusPointBinding + ) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isDragging: false, + hoveredFocusPointBinding, + }, + }); + } + + // Set cursor to pointer when hovering over a focus point + if (hoveredFocusPointBinding) { + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); + } } else { setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); } @@ -6418,6 +7495,23 @@ class App extends React.Component { private handleCanvasPointerDown = ( event: React.PointerEvent, ) => { + const selectedElements = this.scene.getSelectedElements(this.state); + + // If Ctrl is not held, ensure isBindingEnabled reflects the user preference. + if (!event.ctrlKey) { + const preferenceEnabled = this.state.bindingPreference === "enabled"; + if (this.state.isBindingEnabled !== preferenceEnabled) { + this.setState({ isBindingEnabled: preferenceEnabled }); + } + } + + const scenePointer = viewportCoordsToSceneCoords(event, this.state); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; + const target = event.target as HTMLElement; // capture subsequent pointer events to the canvas // this makes other elements non-interactive until pointer up @@ -6459,6 +7553,10 @@ class App extends React.Component { this.setAppState({ snapLines: [] }); } + if (this.state.openPopup) { + this.setState({ openPopup: null }); + } + this.updateGestureOnPointerDown(event); // if dragging element is freedraw and another pointerdown event occurs @@ -6484,7 +7582,7 @@ class App extends React.Component { newElement: null, editingTextElement: null, startBoundElement: null, - suggestedBindings: [], + suggestedBinding: null, selectedElementIds: makeNextSelectedElementIds( Object.keys(this.state.selectedElementIds) .filter((key) => key !== element.id) @@ -6524,10 +7622,12 @@ class App extends React.Component { } if ( - !this.device.isTouchScreen && + !this.editorInterface.isTouchScreen && ["pen", "touch"].includes(event.pointerType) ) { - this.device = updateObject(this.device, { isTouchScreen: true }); + this.editorInterface = updateObject(this.editorInterface, { + isTouchScreen: true, + }); } if (isPanning) { @@ -6620,12 +7720,20 @@ class App extends React.Component { selectedElementsAreBeingDragged: false, }); + if ( + this.handleTextAutoResizeHandlePointerDown( + selectedElements, + pointerDownState.origin, + ) + ) { + return; + } + if (this.handleDraggingScrollBar(event, pointerDownState)) { return; } this.clearSelectionIfNotUsingSelection(); - this.updateBindingEnabledOnPointerMove(event); if (this.handleSelectionOnPointerDown(event, pointerDownState)) { return; @@ -6644,11 +7752,125 @@ class App extends React.Component { } if (this.state.activeTool.type === "lasso") { - this.lassoTrail.startPath( - pointerDownState.origin.x, - pointerDownState.origin.y, - event.shiftKey, - ); + const hitSelectedElement = + pointerDownState.hit.element && + this.isASelectedElement(pointerDownState.hit.element); + const shouldForceLassoReselect = + event.altKey && + event[KEYS.CTRL_OR_CMD] && + !pointerDownState.resize.handleType; + const shouldStartLassoSelection = + shouldForceLassoReselect || + (!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + !pointerDownState.resize.handleType && + !hitSelectedElement); + + if (shouldStartLassoSelection) { + if (!this.lassoTrail.hasCurrentTrail) { + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + } + + // block dragging after lasso selection on PCs until the next pointer down + // (on mobile or tablet, we want to allow user to drag immediately) + pointerDownState.drag.blockDragging = + this.editorInterface.formFactor === "desktop"; + } + + // only for mobile or tablet, if we hit an element, select it immediately like normal selection + if ( + this.editorInterface.formFactor !== "desktop" && + pointerDownState.hit.element && + !hitSelectedElement + ) { + this.setState((prevState) => { + const nextSelectedElementIds: { [id: string]: true } = { + ...prevState.selectedElementIds, + [pointerDownState.hit.element!.id]: true, + }; + + const previouslySelectedElements: ExcalidrawElement[] = []; + + Object.keys(prevState.selectedElementIds).forEach((id) => { + const element = this.scene.getElement(id); + element && previouslySelectedElements.push(element); + }); + + const hitElement = pointerDownState.hit.element!; + + // if hitElement is frame-like, deselect all of its elements + // if they are selected + if (isFrameLikeElement(hitElement)) { + getFrameChildren(previouslySelectedElements, hitElement.id).forEach( + (element) => { + delete nextSelectedElementIds[element.id]; + }, + ); + } else if (hitElement.frameId) { + // if hitElement is in a frame and its frame has been selected + // disable selection for the given element + if (nextSelectedElementIds[hitElement.frameId]) { + delete nextSelectedElementIds[hitElement.id]; + } + } else { + // hitElement is neither a frame nor an element in a frame + // but since hitElement could be in a group with some frames + // this means selecting hitElement will have the frames selected as well + // because we want to keep the invariant: + // - frames and their elements are not selected at the same time + // we deselect elements in those frames that were previously selected + + const groupIds = hitElement.groupIds; + const framesInGroups = new Set( + groupIds + .flatMap((gid) => + getElementsInGroup(this.scene.getNonDeletedElements(), gid), + ) + .filter((element) => isFrameLikeElement(element)) + .map((frame) => frame.id), + ); + + if (framesInGroups.size > 0) { + previouslySelectedElements.forEach((element) => { + if (element.frameId && framesInGroups.has(element.frameId)) { + // deselect element and groups containing the element + delete nextSelectedElementIds[element.id]; + element.groupIds + .flatMap((gid) => + getElementsInGroup( + this.scene.getNonDeletedElements(), + gid, + ), + ) + .forEach((element) => { + delete nextSelectedElementIds[element.id]; + }); + } + }); + } + } + + return { + ...selectGroupsForSelectedElements( + { + editingGroupId: prevState.editingGroupId, + selectedElementIds: nextSelectedElementIds, + }, + this.scene.getNonDeletedElements(), + prevState, + this, + ), + showHyperlinkPopup: + hitElement.link || isEmbeddableElement(hitElement) + ? "info" + : false, + }; + }); + pointerDownState.hit.wasAddedToSelection = true; + } } else if (this.state.activeTool.type === "text") { this.handleTextOnPointerDown(event, pointerDownState); } else if ( @@ -6734,36 +7956,39 @@ class App extends React.Component { private handleCanvasPointerUp = ( event: React.PointerEvent, ) => { + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.removePointer(event); + this.lastPointerUpIsDoubleClick = this.isDoubleClick( + this.lastPointerUpEvent, + event, + ); this.lastPointerUpEvent = event; + if (!event.ctrlKey) { + const preferenceEnabled = this.state.bindingPreference === "enabled"; + if (this.state.isBindingEnabled !== preferenceEnabled) { + this.setState({ isBindingEnabled: preferenceEnabled }); + } + } + const scenePointer = viewportCoordsToSceneCoords( { clientX: event.clientX, clientY: event.clientY }, this.state, ); - const clicklength = - event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0); + const { x: scenePointerX, y: scenePointerY } = scenePointer; + this.lastPointerMoveCoords = { + x: scenePointerX, + y: scenePointerY, + }; - if (this.device.editor.isMobile && clicklength < 300) { - const hitElement = this.getElementAtPosition( - scenePointer.x, - scenePointer.y, - ); - if ( - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - event, - scenePointer.x, - scenePointer.y, - ) - ) { - this.handleEmbeddableCenterClick(hitElement); - return; - } + if (this.handleIframeLikeCenterClick()) { + return; } - if (this.device.isTouchScreen) { + if (this.editorInterface.isTouchScreen) { const hitElement = this.getElementAtPosition( scenePointer.x, scenePointer.y, @@ -6781,20 +8006,7 @@ class App extends React.Component { this.hitLinkElement && !this.state.selectedElementIds[this.hitLinkElement.id] ) { - if ( - clicklength < 300 && - isIframeLikeElement(this.hitLinkElement) && - !isPointHittingLinkIcon( - this.hitLinkElement, - this.scene.getNonDeletedElementsMap(), - this.state, - pointFrom(scenePointer.x, scenePointer.y), - ) - ) { - this.handleEmbeddableCenterClick(this.hitLinkElement); - } else { - this.redirectToLink(event, this.device.isTouchScreen); - } + this.handleElementLinkClick(event); } else if (this.state.viewModeEnabled) { this.setState({ activeEmbeddable: null, @@ -6854,7 +8066,8 @@ class App extends React.Component { (event.button === POINTER_BUTTON.WHEEL || (event.button === POINTER_BUTTON.MAIN && isHoldingSpace) || isHandToolActive(this.state) || - this.state.viewModeEnabled) + (this.state.viewModeEnabled && + this.state.activeTool.type !== "laser")) ) ) { return false; @@ -6932,7 +8145,10 @@ class App extends React.Component { lastPointerUp = null; isPanning = false; if (!isHoldingSpace) { - if (this.state.viewModeEnabled) { + if ( + this.state.viewModeEnabled && + this.state.activeTool.type !== "laser" + ) { setCursor(this.interactiveCanvas, CURSOR_TYPE.GRAB); } else { setCursorForShape(this.interactiveCanvas, this.state); @@ -7028,6 +8244,7 @@ class App extends React.Component { hasOccurred: false, offset: null, origin: { ...origin }, + blockDragging: false, }, eventListeners: { onMove: null, @@ -7083,10 +8300,7 @@ class App extends React.Component { } private clearSelectionIfNotUsingSelection = (): void => { - if ( - this.state.activeTool.type !== "selection" && - this.state.activeTool.type !== "lasso" - ) { + if (!isSelectionLikeTool(this.state.activeTool.type)) { this.setState({ selectedElementIds: makeNextSelectedElementIds({}, this.state), selectedGroupIds: {}, @@ -7103,7 +8317,7 @@ class App extends React.Component { event: React.PointerEvent, pointerDownState: PointerDownState, ): boolean => { - if (this.state.activeTool.type === "selection") { + if (isSelectionLikeTool(this.state.activeTool.type)) { const elements = this.scene.getNonDeletedElements(); const elementsMap = this.scene.getNonDeletedElementsMap(); const selectedElements = this.scene.getSelectedElements(this.state); @@ -7112,6 +8326,11 @@ class App extends React.Component { selectedElements.length === 1 && !this.state.selectedLinearElement?.isEditing && !isElbowArrow(selectedElements[0]) && + !( + isLinearElement(selectedElements[0]) && + (this.editorInterface.userAgent.isMobileDevice || + selectedElements[0].points.length === 2) + ) && !( this.state.selectedLinearElement && this.state.selectedLinearElement.hoverPointIndex !== -1 @@ -7126,7 +8345,7 @@ class App extends React.Component { this.state.zoom, event.pointerType, this.scene.getNonDeletedElementsMap(), - this.device, + this.editorInterface, ); if (elementWithTransformHandleType != null) { if ( @@ -7155,7 +8374,7 @@ class App extends React.Component { pointerDownState.origin.y, this.state.zoom, event.pointerType, - this.device, + this.editorInterface, ); } if (pointerDownState.resize.handleType) { @@ -7199,6 +8418,37 @@ class App extends React.Component { if (ret.didAddPoint) { return true; } + + // Also check at current pointer position if focus point is being hovered + // (in case we're clicking directly without a prior move event) + const elementsMap = this.scene.getNonDeletedElementsMap(); + const arrow = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ) as any; + + if (arrow && isBindingElement(arrow)) { + const { hitFocusPoint, pointerOffset } = + handleFocusPointPointerDown( + arrow, + pointerDownState, + elementsMap, + this.state, + ); + + // If focus point is hit, update state and prevent element selection + if (hitFocusPoint) { + this.setState({ + selectedLinearElement: { + ...linearElementEditor, + hoveredFocusPointBinding: hitFocusPoint, + draggedFocusPointBinding: hitFocusPoint, + pointerOffset, + }, + }); + return false; + } + } } const allHitElements = this.getElementsAtPosition( @@ -7290,27 +8540,52 @@ class App extends React.Component { if ( (hitElement === null || !someHitElementIsSelected) && !event.shiftKey && - !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements + !pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements && + (!this.state.selectedLinearElement?.isEditing || + (hitElement && + hitElement?.id !== this.state.selectedLinearElement?.elementId)) ) { this.clearSelection(hitElement); } if (this.state.selectedLinearElement?.isEditing) { - this.setState({ - selectedElementIds: makeNextSelectedElementIds( - { - [this.state.selectedLinearElement.elementId]: true, - }, - this.state, - ), - }); + this.setState((prevState) => ({ + selectedLinearElement: prevState.selectedLinearElement + ? { + ...prevState.selectedLinearElement, + isEditing: + !!hitElement && + hitElement.id === + this.state.selectedLinearElement?.elementId, + } + : null, + selectedElementIds: prevState.selectedLinearElement + ? makeNextSelectedElementIds( + { + [prevState.selectedLinearElement.elementId]: true, + }, + this.state, + ) + : makeNextSelectedElementIds({}, prevState), + })); // If we click on something } else if (hitElement != null) { // == deep selection == // on CMD/CTRL, drill down to hit element regardless of groups etc. if (event[KEYS.CTRL_OR_CMD]) { if (event.altKey) { - // ctrl + alt means we're lasso selecting + // ctrl + alt means we're lasso selecting - start lasso trail and switch to lasso tool + + // Close any open dialogs that might interfere with lasso selection + if (this.state.openDialog?.name === "elementLinkSelector") { + this.setOpenDialog(null); + } + this.lassoTrail.startPath( + pointerDownState.origin.x, + pointerDownState.origin.y, + event.shiftKey, + ); + this.setActiveTool({ type: "lasso", fromSelection: true }); return false; } if (!this.state.selectedElementIds[hitElement.id]) { @@ -7486,12 +8761,14 @@ class App extends React.Component { DEFAULT_COLLISION_THRESHOLD / this.state.zoom.value, 1, ); + const boundsPadding = + (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / this.state.zoom.value; const [x1, y1, x2, y2] = getCommonBounds(selectedElements); return ( - point.x > x1 - threshold && - point.x < x2 + threshold && - point.y > y1 - threshold && - point.y < y2 + threshold + point.x > x1 - boundsPadding - threshold && + point.x < x2 + boundsPadding + threshold && + point.y > y1 - boundsPadding - threshold && + point.y < y2 + boundsPadding + threshold ); } @@ -7526,12 +8803,15 @@ class App extends React.Component { insertAtParentCenter: !event.altKey, container, autoEdit: false, + initialCaretSceneCoords: { x: sceneX, y: sceneY }, }); resetCursor(this.interactiveCanvas); if (!this.state.activeTool.locked) { this.setState({ - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), }); } }; @@ -7590,16 +8870,18 @@ class App extends React.Component { }); const boundElement = getHoveredElementForBinding( - pointerDownState.origin, + pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), this.scene.getNonDeletedElements(), this.scene.getNonDeletedElementsMap(), - this.state.zoom, ); this.setState({ newElement: element, startBoundElement: boundElement, - suggestedBindings: [], + suggestedBinding: null, }); }; @@ -7698,16 +8980,14 @@ class App extends React.Component { return element; }; - private createImageElement = async ({ + private newImagePlaceholder = ({ sceneX, sceneY, addToFrameUnderCursor = true, - imageFile, }: { sceneX: number; sceneY: number; addToFrameUnderCursor?: boolean; - imageFile: File; }) => { const [gridX, gridY] = getGridPoint( sceneX, @@ -7726,7 +9006,7 @@ class App extends React.Component { const placeholderSize = 100 / this.state.zoom.value; - const placeholderImageElement = newImageElement({ + return newImageElement({ type: "image", strokeColor: this.state.currentItemStrokeColor, backgroundColor: this.state.currentItemBackgroundColor, @@ -7743,13 +9023,6 @@ class App extends React.Component { width: placeholderSize, height: placeholderSize, }); - - const initializedImageElement = await this.insertImageElement( - placeholderImageElement, - imageFile, - ); - - return initializedImageElement; }; private handleLinearElementOnPointerDown = ( @@ -7757,17 +9030,39 @@ class App extends React.Component { elementType: ExcalidrawLinearElement["type"], pointerDownState: PointerDownState, ): void => { + if (event.ctrlKey) { + flushSync(() => { + this.setState({ + isBindingEnabled: this.state.bindingPreference !== "enabled", + }); + }); + } + if (this.state.multiElement) { - const { multiElement } = this.state; + const { multiElement, selectedLinearElement } = this.state; + + invariant( + selectedLinearElement, + "selectedLinearElement is expected to be set", + ); // finalize if completing a loop if ( multiElement.type === "line" && isPathALoop(multiElement.points, this.state.zoom.value) ) { - this.scene.mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + flushSync(() => { + this.setState({ + selectedLinearElement: { + ...selectedLinearElement, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + initialState: { + ...selectedLinearElement.initialState, + lastClickedPoint: -1, // Disable dragging + }, + }, + }); }); this.actionManager.executeAction(actionFinalize); return; @@ -7776,29 +9071,52 @@ class App extends React.Component { // Elbow arrows cannot be created by putting down points // only the start and end points can be defined if (isElbowArrow(multiElement) && multiElement.points.length > 1) { - this.scene.mutateElement(multiElement, { - lastCommittedPoint: - multiElement.points[multiElement.points.length - 1], + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, }); - this.actionManager.executeAction(actionFinalize); return; } - const { x: rx, y: ry, lastCommittedPoint } = multiElement; + const { x: rx, y: ry } = multiElement; + const { lastCommittedPoint } = selectedLinearElement; + + const hoveredElementForBinding = + isBindingEnabled(this.state) && + getHoveredElementForBinding( + pointFrom( + this.lastPointerMoveCoords?.x ?? + rx + multiElement.points[multiElement.points.length - 1][0], + this.lastPointerMoveCoords?.y ?? + ry + multiElement.points[multiElement.points.length - 1][1], + ), + this.scene.getNonDeletedElements(), + this.scene.getNonDeletedElementsMap(), + ); // clicking inside commit zone → finalize arrow if ( - multiElement.points.length > 1 && - lastCommittedPoint && - pointDistance( - pointFrom( - pointerDownState.origin.x - rx, - pointerDownState.origin.y - ry, - ), - lastCommittedPoint, - ) < LINE_CONFIRM_THRESHOLD + (isBindingElement(multiElement) && hoveredElementForBinding) || + (multiElement.points.length > 1 && + lastCommittedPoint && + pointDistance( + pointFrom( + pointerDownState.origin.x - rx, + pointerDownState.origin.y - ry, + ), + lastCommittedPoint, + ) < LINE_CONFIRM_THRESHOLD) ) { - this.actionManager.executeAction(actionFinalize); + this.actionManager.executeAction(actionFinalize, "ui", { + event: event.nativeEvent, + sceneCoords: { + x: pointerDownState.origin.x, + y: pointerDownState.origin.y, + }, + }); return; } @@ -7811,11 +9129,7 @@ class App extends React.Component { prevState, ), })); - // clicking outside commit zone → update reference for last committed - // point - this.scene.mutateElement(multiElement, { - lastCommittedPoint: multiElement.points[multiElement.points.length - 1], - }); + setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); } else { const [gridX, gridY] = getGridPoint( @@ -7887,36 +9201,111 @@ class App extends React.Component { locked: false, frameId: topLayerFrame ? topLayerFrame.id : null, }); - this.setState((prevState) => { - const nextSelectedElementIds = { - ...prevState.selectedElementIds, - }; - delete nextSelectedElementIds[element.id]; - return { - selectedElementIds: makeNextSelectedElementIds( - nextSelectedElementIds, - prevState, - ), - }; - }); - this.scene.mutateElement(element, { - points: [...element.points, pointFrom(0, 0)], - }); - const boundElement = getHoveredElementForBinding( - pointerDownState.origin, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - isElbowArrow(element), - isElbowArrow(element), + + const point = pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, ); + const elementsMap = this.scene.getNonDeletedElementsMap(); + const boundElement = isBindingEnabled(this.state) + ? getHoveredElementForBinding( + point, + this.scene.getNonDeletedElements(), + elementsMap, + ) + : null; + + this.scene.mutateElement(element, { + points: [pointFrom(0, 0), pointFrom(0, 0)], + }); this.scene.insertElement(element); - this.setState({ - newElement: element, - startBoundElement: boundElement, - suggestedBindings: [], + + if (isBindingElement(element)) { + // Do the initial binding so the binding strategy has the initial state + bindOrUnbindBindingElement( + element, + new Map([ + [ + 0, + { + point: pointFrom(0, 0), + isDragging: false, + }, + ], + ]), + point[0], + point[1], + this.scene, + this.state, + { + newArrow: true, + altKey: event.altKey, + initialBinding: true, + angleLocked: shouldRotateWithDiscreteAngle(event.nativeEvent), + }, + ); + } + + // NOTE: We need the flushSync here for the + // delayed bind mode change to see the right state + // (specifically the `newElement`) + flushSync(() => { + this.setState((prevState) => { + let linearElementEditor = null; + let nextSelectedElementIds = prevState.selectedElementIds; + if (isLinearElement(element)) { + linearElementEditor = new LinearElementEditor( + element, + this.scene.getNonDeletedElementsMap(), + ); + + const endIdx = element.points.length - 1; + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [endIdx], + initialState: { + ...linearElementEditor.initialState, + arrowStartIsInside: event.altKey, + lastClickedPoint: endIdx, + origin: pointFrom( + pointerDownState.origin.x, + pointerDownState.origin.y, + ), + }, + }; + } + + nextSelectedElementIds = !this.state.activeTool.locked + ? makeNextSelectedElementIds({ [element.id]: true }, prevState) + : prevState.selectedElementIds; + + return { + ...prevState, + bindMode: "orbit", + newElement: element, + startBoundElement: boundElement, + suggestedBinding: + boundElement && isBindingElement(element) + ? { + element: boundElement, + midPoint: getSnapOutlineMidPoint( + point, + boundElement, + elementsMap, + this.state.zoom, + ), + } + : null, + selectedElementIds: nextSelectedElementIds, + selectedLinearElement: linearElementEditor, + }; + }); }); + + if (isBindingElement(element) && getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, boundElement); + } } }; @@ -8119,7 +9508,7 @@ class App extends React.Component { if ( this.state.selectedLinearElement && this.state.selectedLinearElement.elbowed && - this.state.selectedLinearElement.pointerDownState.segmentMidpoint.index + this.state.selectedLinearElement.initialState.segmentMidpoint.index ) { const [gridX, gridY] = getGridPoint( pointerCoords.x, @@ -8128,8 +9517,7 @@ class App extends React.Component { ); let index = - this.state.selectedLinearElement.pointerDownState.segmentMidpoint - .index; + this.state.selectedLinearElement.initialState.segmentMidpoint.index; if (index < 0) { const nextCoords = LinearElementEditor.getSegmentMidpointHitCoords( { @@ -8161,16 +9549,17 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, + isDragging: true, segmentMidPointHoveredCoords: ret.segmentMidPointHoveredCoords, - pointerDownState: ret.pointerDownState, + initialState: ret.initialState, }, }); return; } const lastPointerCoords = - this.lastPointerMoveCoords ?? pointerDownState.origin; - this.lastPointerMoveCoords = pointerCoords; + this.previousPointerMoveCoords ?? pointerDownState.origin; + this.previousPointerMoveCoords = pointerCoords; // We need to initialize dragOffsetXY only after we've updated // `state.selectedElementIds` on pointerDown. Doing it here in pointerMove @@ -8209,26 +9598,6 @@ class App extends React.Component { event[KEYS.CTRL_OR_CMD] ? null : this.getEffectiveGridSize(), ); - // for arrows/lines, don't start dragging until a given threshold - // to ensure we don't create a 2-point arrow by mistake when - // user clicks mouse in a way that it moves a tiny bit (thus - // triggering pointermove) - if ( - !pointerDownState.drag.hasOccurred && - (this.state.activeTool.type === "arrow" || - this.state.activeTool.type === "line") - ) { - if ( - pointDistance( - pointFrom(pointerCoords.x, pointerCoords.y), - pointFrom(pointerDownState.origin.x, pointerDownState.origin.y), - ) * - this.state.zoom.value < - MINIMUM_ARROW_SIZE - ) { - return; - } - } if (pointerDownState.resize.isResizing) { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -8244,6 +9613,31 @@ class App extends React.Component { if (this.state.selectedLinearElement) { const linearElementEditor = this.state.selectedLinearElement; + // Handle focus point dragging if needed + if (linearElementEditor.draggedFocusPointBinding) { + handleFocusPointDrag( + linearElementEditor, + elementsMap, + pointerCoords, + this.scene, + this.state, + this.getEffectiveGridSize(), + event.altKey, + ); + this.setState({ + selectedLinearElement: { + ...linearElementEditor, + isDragging: false, + selectedPointsIndices: [], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: -1, + }, + }, + }); + return; + } + if ( LinearElementEditor.shouldAddMidpoint( this.state.selectedLinearElement, @@ -8272,7 +9666,7 @@ class App extends React.Component { this.setState({ selectedLinearElement: { ...this.state.selectedLinearElement, - pointerDownState: ret.pointerDownState, + initialState: ret.pointerDownState, selectedPointsIndices: ret.selectedPointsIndices, segmentMidPointHoveredCoords: null, }, @@ -8282,27 +9676,93 @@ class App extends React.Component { return; } else if ( - linearElementEditor.pointerDownState.segmentMidpoint.value !== null && - !linearElementEditor.pointerDownState.segmentMidpoint.added + linearElementEditor.initialState.segmentMidpoint.value !== null && + !linearElementEditor.initialState.segmentMidpoint.added ) { return; - } + } else if (linearElementEditor.initialState.lastClickedPoint > -1) { + const element = LinearElementEditor.getElement( + linearElementEditor.elementId, + elementsMap, + ); - const newState = LinearElementEditor.handlePointDragging( - event, - this, - pointerCoords.x, - pointerCoords.y, - linearElementEditor, - ); - if (newState) { - pointerDownState.lastCoords.x = pointerCoords.x; - pointerDownState.lastCoords.y = pointerCoords.y; - pointerDownState.drag.hasOccurred = true; + if (element?.isDeleted) { + return; + } - this.setState(newState); + if (isBindingElement(element)) { + const hoveredElement = getHoveredElementForBinding( + pointFrom(pointerCoords.x, pointerCoords.y), + this.scene.getNonDeletedElements(), + elementsMap, + ); - return; + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.handleDelayedBindModeChange(element, hoveredElement); + } + } + + if ( + event.altKey && + !this.state.selectedLinearElement?.initialState + ?.arrowStartIsInside && + getFeatureFlag("COMPLEX_BINDINGS") + ) { + this.handleSkipBindMode(); + } + + // Ignore drag requests if the arrow modification already happened + if (linearElementEditor.initialState.lastClickedPoint === -1) { + return; + } + + const newState = LinearElementEditor.handlePointDragging( + event, + this, + pointerCoords.x, + pointerCoords.y, + linearElementEditor, + ); + + if (newState) { + pointerDownState.lastCoords.x = pointerCoords.x; + pointerDownState.lastCoords.y = pointerCoords.y; + pointerDownState.drag.hasOccurred = true; + const nextSnapLines = updateStable( + this.state.snapLines, + newState.snapLines, + ); + const nextState = + nextSnapLines === newState.snapLines + ? newState + : { + ...newState, + snapLines: nextSnapLines, + }; + + // NOTE: Optimize setState calls because it + // affects history and performance + if ( + nextState.suggestedBinding !== this.state.suggestedBinding || + !isShallowEqual( + nextState.selectedLinearElement?.selectedPointsIndices ?? [], + this.state.selectedLinearElement?.selectedPointsIndices ?? [], + ) || + nextState.selectedLinearElement?.hoverPointIndex !== + this.state.selectedLinearElement?.hoverPointIndex || + nextState.selectedLinearElement?.customLineAngle !== + this.state.selectedLinearElement?.customLineAngle || + this.state.selectedLinearElement.isDragging !== + nextState.selectedLinearElement?.isDragging || + this.state.selectedLinearElement?.initialState?.altFocusPoint !== + nextState.selectedLinearElement?.initialState?.altFocusPoint || + nextState.snapLines !== this.state.snapLines + ) { + this.setState(nextState); + } + + return; + } } } @@ -8315,15 +9775,18 @@ class App extends React.Component { event.shiftKey && this.state.selectedLinearElement.elementId === pointerDownState.hit.element?.id; + if ( (hasHitASelectedElement || pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements) && !isSelectingPointsInLineEditor && - this.state.activeTool.type !== "lasso" + !pointerDownState.drag.blockDragging ) { const selectedElements = this.scene.getSelectedElements(this.state); - - if (selectedElements.every((element) => element.locked)) { + if ( + selectedElements.length > 0 && + selectedElements.every((element) => element.locked) + ) { return; } @@ -8344,6 +9807,32 @@ class App extends React.Component { // if elements should be deselected on pointerup pointerDownState.drag.hasOccurred = true; + // prevent immediate dragging during lasso selection to avoid element displacement + // only allow dragging if we're not in the middle of lasso selection + // (on mobile, allow dragging if we hit an element) + if ( + this.state.activeTool.type === "lasso" && + this.lassoTrail.hasCurrentTrail && + !( + this.editorInterface.formFactor !== "desktop" && + pointerDownState.hit.element + ) && + !this.state.activeTool.fromSelection + ) { + return; + } + + // Clear lasso trail when starting to drag selected elements with lasso tool + // Only clear if we're actually dragging (not during lasso selection) + if ( + this.state.activeTool.type === "lasso" && + selectedElements.length > 0 && + pointerDownState.drag.hasOccurred && + !this.state.activeTool.fromSelection + ) { + this.lassoTrail.endPath(); + } + // prevent dragging even if we're no longer holding cmd/ctrl otherwise // it would have weird results (stuff jumping all over the screen) // Checking for editingTextElement to avoid jump while editing on mobile #6503 @@ -8399,14 +9888,21 @@ class App extends React.Component { this.imageCache.get(croppingElement.fileId)?.image; if (image && !(image instanceof Promise)) { - const instantDragOffset = vectorScale( - vector( - pointerCoords.x - lastPointerCoords.x, - pointerCoords.y - lastPointerCoords.y, - ), - Math.max(this.state.zoom.value, 2), + const uncroppedSize = + getUncroppedWidthAndHeight(croppingElement); + const instantDragOffset = vector( + pointerCoords.x - lastPointerCoords.x, + pointerCoords.y - lastPointerCoords.y, ); + // to reduce cursor:image drift, we need to take into account + // the canvas image element scaling so we can accurately + // track the pixels on movement + instantDragOffset[0] *= + image.naturalWidth / uncroppedSize.width; + instantDragOffset[1] *= + image.naturalHeight / uncroppedSize.height; + const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords( croppingElement, elementsMap, @@ -8507,19 +10003,6 @@ class App extends React.Component { selectionElement: null, }); - if ( - selectedElements.length !== 1 || - !isElbowArrow(selectedElements[0]) - ) { - this.setState({ - suggestedBindings: getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ), - }); - } - // We duplicate the selected element if alt is pressed on pointer move if (event.altKey && !pointerDownState.hit.hasBeenDuplicated) { // Move the currently selected elements to the top of the z index stack, and @@ -8653,6 +10136,15 @@ class App extends React.Component { })); this.scene.replaceAllElements(elementsWithIndices); + selectedElements.forEach((element) => { + if ( + isBindableElement(element) && + element.boundElements?.some((other) => other.type === "arrow") + ) { + updateBoundElements(element, this.scene); + } + }); + this.maybeCacheVisibleGaps(event, selectedElements, true); this.maybeCacheReferenceSnapPoints(event, selectedElements, true); }); @@ -8735,115 +10227,40 @@ class App extends React.Component { newElement, }); } - } else if (isLinearElement(newElement)) { + } else if (isLinearElement(newElement) && !newElement.isDeleted) { pointerDownState.drag.hasOccurred = true; const points = newElement.points; - let dx = gridX - newElement.x; - let dy = gridY - newElement.y; - const rotateWithDiscreteAngle = - shouldRotateWithDiscreteAngle(event) && points.length === 2; + invariant( + points.length > 1, + "Do not create linear elements with less than 2 points", + ); - if (rotateWithDiscreteAngle) { - ({ width: dx, height: dy } = getLockedLinearCursorAlignSize( - newElement.x, - newElement.y, - pointerCoords.x, - pointerCoords.y, - )); - } - - const effectiveGridX = newElement.x + dx; - const effectiveGridY = newElement.y + dy; - - // Snap a two-point line/arrow as well - if (!isElbowArrow(newElement)) { - const { snapOffset, snapLines } = snapLinearElementPoint( - this.scene.getNonDeletedElements(), + let linearElementEditor = this.state.selectedLinearElement; + if (!linearElementEditor) { + linearElementEditor = new LinearElementEditor( newElement, - points.length - 1, - pointFrom(effectiveGridX, effectiveGridY), - this, - event, this.scene.getNonDeletedElementsMap(), - { - includeSelfPoints: true, - selectedPointsIndices: [points.length - 1], - }, ); - - if (snapLines.length > 0) { - if (rotateWithDiscreteAngle) { - const angleLine = line( - pointFrom(effectiveGridX, effectiveGridY), - pointFrom(newElement.x, newElement.y), - ); - - const result = snapToDiscreteAngle( - snapLines, - angleLine, - pointFrom(gridX, gridY), - pointFrom(newElement.x, newElement.y), - ); - - dx = result.dxFromReference; - dy = result.dyFromReference; - - this.setState({ - snapLines: result.snapLines, - }); - } else { - dx = gridX + snapOffset.x - newElement.x; - dy = gridY + snapOffset.y - newElement.y; - - this.setState({ - snapLines, - }); - } - } else { - this.setState({ - snapLines: [], - }); - } + linearElementEditor = { + ...linearElementEditor, + selectedPointsIndices: [1], + initialState: { + ...linearElementEditor.initialState, + lastClickedPoint: 1, + }, + }; } - - if (points.length === 1) { - this.scene.mutateElement( - newElement, - { - points: [...points, pointFrom(dx, dy)], - }, - { informMutation: false, isDragging: false }, - ); - } else if ( - points.length === 2 || - (points.length > 1 && isElbowArrow(newElement)) - ) { - this.scene.mutateElement( - newElement, - { - points: [...points.slice(0, -1), pointFrom(dx, dy)], - }, - { isDragging: true, informMutation: false }, - ); - } - this.setState({ newElement, + ...LinearElementEditor.handlePointDragging( + event, + this, + gridX, + gridY, + linearElementEditor, + )!, }); - - if (isBindingElement(newElement, false)) { - // When creating a linear element by dragging - this.setState({ - suggestedBindings: maybeSuggestBindingsForLinearElementAtCoords( - newElement, - [pointerCoords], - this.scene, - this.state.zoom, - this.state.startBoundElement, - ), - }); - } } else { pointerDownState.lastCoords.x = pointerCoords.x; pointerDownState.lastCoords.y = pointerCoords.y; @@ -8896,6 +10313,7 @@ class App extends React.Component { this.state.selectionElement, this.scene.getNonDeletedElementsMap(), false, + this.state.boxSelectionMode, ) : []; @@ -8994,7 +10412,10 @@ class App extends React.Component { pointerDownState: PointerDownState, ): (event: PointerEvent) => void { return withBatchedUpdates((childEvent: PointerEvent) => { + const elementsMap = this.scene.getNonDeletedElementsMap(); + this.removePointer(childEvent); + pointerDownState.drag.blockDragging = false; if (pointerDownState.eventListeners.onMove) { pointerDownState.eventListeners.onMove.flush(); } @@ -9024,7 +10445,7 @@ class App extends React.Component { // just in case, tool changes mid drag, always clean up this.lassoTrail.endPath(); - this.lastPointerMoveCoords = null; + this.previousPointerMoveCoords = null; SnapCache.setReferenceSnapPoints(null); SnapCache.setVisibleGaps(null); @@ -9076,10 +10497,14 @@ class App extends React.Component { }); } + if (getFeatureFlag("COMPLEX_BINDINGS")) { + this.resetDelayedBindMode(); + } + this.setState({ selectedElementsAreBeingDragged: false, + bindMode: "orbit", }); - const elementsMap = this.scene.getNonDeletedElementsMap(); if ( pointerDownState.drag.hasOccurred && @@ -9100,11 +10525,16 @@ class App extends React.Component { // Handle end of dragging a point of a linear element, might close a loop // and sets binding element - if (this.state.selectedLinearElement?.isEditing) { + if ( + this.state.selectedLinearElement?.isEditing && + !this.state.newElement && + this.state.selectedLinearElement.draggedFocusPointBinding === null + ) { if ( !pointerDownState.boxSelection.hasOccurred && pointerDownState.hit?.element?.id !== - this.state.selectedLinearElement.elementId + this.state.selectedLinearElement.elementId && + this.state.selectedLinearElement.draggedFocusPointBinding === null ) { this.actionManager.executeAction(actionFinalize); } else { @@ -9114,10 +10544,14 @@ class App extends React.Component { this.state, this.scene, ); + this.actionManager.executeAction(actionFinalize, "ui", { + event: childEvent, + sceneCoords, + }); if (editingLinearElement !== this.state.selectedLinearElement) { this.setState({ selectedLinearElement: editingLinearElement, - suggestedBindings: [], + suggestedBinding: null, }); } } @@ -9136,7 +10570,18 @@ class App extends React.Component { } } - if ( + if (this.state.selectedLinearElement.draggedFocusPointBinding) { + handleFocusPointPointerUp( + this.state.selectedLinearElement, + this.scene, + ); + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + draggedFocusPointBinding: null, + }, + }); + } else if ( pointerDownState.hit?.element?.id !== this.state.selectedLinearElement.elementId ) { @@ -9146,11 +10591,34 @@ class App extends React.Component { this.setState({ selectedLinearElement: null }); } } else if (this.state.selectedLinearElement.isDragging) { + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + isDragging: false, + }, + }); this.actionManager.executeAction(actionFinalize, "ui", { event: childEvent, sceneCoords, }); } + + if ( + this.state.newElement && + this.state.multiElement && + isLinearElement(this.state.newElement) && + this.state.selectedLinearElement + ) { + const { multiElement } = this.state; + + this.setState({ + selectedLinearElement: { + ...this.state.selectedLinearElement, + lastCommittedPoint: + multiElement.points[multiElement.points.length - 1], + }, + }); + } } this.missingPointerEventCleanupEmitter.clear(); @@ -9202,7 +10670,6 @@ class App extends React.Component { this.scene.mutateElement(newElement, { points: [...points, pointFrom(dx, dy)], pressures, - lastCommittedPoint: pointFrom(dx, dy), }); this.actionManager.executeAction(actionFinalize); @@ -9211,7 +10678,11 @@ class App extends React.Component { } if (isLinearElement(newElement)) { - if (newElement!.points.length > 1) { + if ( + newElement!.points.length > 1 && + newElement.points[1][0] !== 0 && + newElement.points[1][1] !== 0 + ) { this.store.scheduleCapture(); } const pointerCoords = viewportCoordsToSceneCoords( @@ -9231,7 +10702,7 @@ class App extends React.Component { newElement && !multiElement ) { - if (this.device.isTouchScreen) { + if (this.editorInterface.isTouchScreen) { const FIXED_DELTA_X = Math.min( (this.state.width * 0.7) / this.state.zoom.value, 100, @@ -9257,7 +10728,7 @@ class App extends React.Component { this.scene.mutateElement( newElement, { - points: [...newElement.points, pointFrom(dx, dy)], + points: [newElement.points[0], pointFrom(dx, dy)], }, { informMutation: false, isDragging: false }, ); @@ -9268,22 +10739,19 @@ class App extends React.Component { }); } } else if (pointerDownState.drag.hasOccurred && !multiElement) { - if ( - isBindingEnabled(this.state) && - isBindingElement(newElement, false) - ) { + if (isLinearElement(newElement)) { this.actionManager.executeAction(actionFinalize, "ui", { event: childEvent, sceneCoords, }); } - this.setState({ suggestedBindings: [], startBoundElement: null }); + this.setState({ suggestedBinding: null, startBoundElement: null }); if (!activeTool.locked) { resetCursor(this.interactiveCanvas); this.setState((prevState) => ({ newElement: null, activeTool: updateActiveTool(this.state, { - type: "selection", + type: this.state.preferredSelectionTool.type, }), selectedElementIds: makeNextSelectedElementIds( { @@ -9834,6 +11302,35 @@ class App extends React.Component { return; } + const selectedTextEditingContainer = + this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords); + + if ( + activeTool.type === this.state.preferredSelectionTool.type && + !this.state.editingTextElement && + !pointerDownState.drag.hasOccurred && + !pointerDownState.hit.wasAddedToSelection && + !childEvent.shiftKey && + !childEvent[KEYS.CTRL_OR_CMD] && + !childEvent.altKey && + childEvent.pointerType !== "touch" && + hitElement && + ((isTextElement(hitElement) && + this.state.selectedElementIds[hitElement.id] && + this.scene.getSelectedElements(this.state).length === 1) || + selectedTextEditingContainer) + ) { + this.startTextEditing({ + sceneX: sceneCoords.x, + sceneY: sceneCoords.y, + container: selectedTextEditingContainer, + initialCaretSceneCoords: this.lastPointerUpIsDoubleClick + ? undefined + : sceneCoords, + }); + return; + } + if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds( @@ -9872,15 +11369,9 @@ class App extends React.Component { // the endpoints ("start" or "end"). const linearElements = this.scene .getSelectedElements(this.state) - .filter(isLinearElement); + .filter(isArrowElement); - bindOrUnbindLinearElements( - linearElements, - isBindingEnabled(this.state), - this.state.selectedLinearElement?.selectedPointsIndices ?? [], - this.scene, - this.state.zoom, - ); + bindOrUnbindBindingElements(linearElements, this.scene, this.state); } if (activeTool.type === "laser") { @@ -9898,34 +11389,17 @@ class App extends React.Component { resetCursor(this.interactiveCanvas); this.setState({ newElement: null, - suggestedBindings: [], - activeTool: updateActiveTool(this.state, { type: "selection" }), + suggestedBinding: null, + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), }); } else { this.setState({ newElement: null, - suggestedBindings: [], + suggestedBinding: null, }); } - - if ( - hitElement && - this.lastPointerUpEvent && - this.lastPointerDownEvent && - this.lastPointerUpEvent.timeStamp - - this.lastPointerDownEvent.timeStamp < - 300 && - gesture.pointers.size <= 1 && - isIframeLikeElement(hitElement) && - this.isIframeLikeElementCenter( - hitElement, - this.lastPointerUpEvent, - pointerDownState.origin.x, - pointerDownState.origin.y, - ) - ) { - this.handleEmbeddableCenterClick(hitElement); - } }); } @@ -9936,6 +11410,67 @@ class App extends React.Component { private eraseElements = () => { let didChange = false; + + // Binding is double accounted on both elements and if one of them is + // deleted, the binding should be removed + this.elementsPendingErasure.forEach((id) => { + const element = this.scene.getElement(id); + if (isBindingElement(element)) { + if (element.startBinding) { + const bindable = this.scene.getElement( + element.startBinding.elementId, + )!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + if (element.endBinding) { + const bindable = this.scene.getElement(element.endBinding.elementId)!; + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement(bindable, this.scene.getElementsMapIncludingDeleted(), { + boundElements: bindable.boundElements!.filter( + (e) => e.id !== element.id, + ), + }); + } + } else if (isBindableElement(element)) { + element.boundElements?.forEach((boundElement) => { + if (boundElement.type === "arrow") { + const arrow = this.scene.getElement( + boundElement.id, + ) as ExcalidrawArrowElement; + if (arrow?.startBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + startBinding: null, + }, + ); + } + if (arrow?.endBinding?.elementId === element.id) { + // NOTE: We use the raw mutateElement() because we don't want history + // entries or multiplayer updates + mutateElement( + arrow, + this.scene.getElementsMapIncludingDeleted(), + { + endBinding: null, + }, + ); + } + } + }); + } + }); + const elements = this.scene.getElementsIncludingDeleted().map((ele) => { if ( this.elementsPendingErasure.has(ele.id) || @@ -10099,64 +11634,7 @@ class App extends React.Component { ); }; - /** - * inserts image into elements array and rerenders - */ - private insertImageElement = async ( - placeholderImageElement: ExcalidrawImageElement, - imageFile: File, - ) => { - // we should be handling all cases upstream, but in case we forget to handle - // a future case, let's throw here - if (!this.isToolSupported("image")) { - this.setState({ errorMessage: t("errors.imageToolNotSupported") }); - return; - } - - this.scene.insertElement(placeholderImageElement); - - try { - const initializedImageElement = await this.initializeImage( - placeholderImageElement, - imageFile, - ); - - const nextElements = this.scene - .getElementsIncludingDeleted() - .map((element) => { - if (element.id === initializedImageElement.id) { - return initializedImageElement; - } - - return element; - }); - - this.updateScene({ - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - elements: nextElements, - appState: { - selectedElementIds: makeNextSelectedElementIds( - { [initializedImageElement.id]: true }, - this.state, - ), - }, - }); - - return initializedImageElement; - } catch (error: any) { - this.store.scheduleAction(CaptureUpdateAction.NEVER); - this.scene.mutateElement(placeholderImageElement, { - isDeleted: true, - }); - this.actionManager.executeAction(actionFinalize); - this.setState({ - errorMessage: error.message || t("errors.imageInsertError"), - }); - return null; - } - }; - - private onImageAction = async () => { + private onImageToolbarButtonClick = async () => { try { const clientX = this.state.width / 2 + this.state.offsetLeft; const clientY = this.state.height / 2 + this.state.offsetTop; @@ -10166,24 +11644,15 @@ class App extends React.Component { this.state, ); - const imageFile = await fileOpen({ + const imageFiles = await fileOpen({ description: "Image", extensions: Object.keys( IMAGE_MIME_TYPES, ) as (keyof typeof IMAGE_MIME_TYPES)[], + multiple: true, }); - await this.createImageElement({ - sceneX: x, - sceneY: y, - addToFrameUnderCursor: false, - imageFile, - }); - - // avoid being batched (just in case) - this.setState({}, () => { - this.actionManager.executeAction(actionFinalize); - }); + this.insertImages(imageFiles, x, y); } catch (error: any) { if (error.name !== "AbortError") { console.error(error); @@ -10193,7 +11662,9 @@ class App extends React.Component { this.setState( { newElement: null, - activeTool: updateActiveTool(this.state, { type: "selection" }), + activeTool: updateActiveTool(this.state, { + type: this.state.preferredSelectionTool.type, + }), }, () => { this.actionManager.executeAction(actionFinalize); @@ -10245,7 +11716,7 @@ class App extends React.Component { if (erroredFiles.size) { this.store.scheduleAction(CaptureUpdateAction.NEVER); this.scene.replaceAllElements( - elements.map((element) => { + this.scene.getElementsIncludingDeleted().map((element) => { if ( isInitializedImageElement(element) && erroredFiles.has(element.fileId) @@ -10299,36 +11770,6 @@ class App extends React.Component { this.addNewImagesToImageCache(); }, IMAGE_RENDER_TIMEOUT); - private updateBindingEnabledOnPointerMove = ( - event: React.PointerEvent, - ) => { - const shouldEnableBinding = shouldEnableBindingForPointerEvent(event); - if (this.state.isBindingEnabled !== shouldEnableBinding) { - this.setState({ isBindingEnabled: shouldEnableBinding }); - } - }; - - private maybeSuggestBindingAtCursor = ( - pointerCoords: { - x: number; - y: number; - }, - considerAll: boolean, - ): void => { - const hoveredBindableElement = getHoveredElementForBinding( - pointerCoords, - this.scene.getNonDeletedElements(), - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - false, - considerAll, - ); - this.setState({ - suggestedBindings: - hoveredBindableElement != null ? [hoveredBindableElement] : [], - }); - }; - private clearSelection(hitElement: ExcalidrawElement | null): void { this.setState((prevState) => ({ selectedElementIds: makeNextSelectedElementIds({}, prevState), @@ -10347,6 +11788,7 @@ class App extends React.Component { selectedElementIds: makeNextSelectedElementIds({}, this.state), activeEmbeddable: null, previousSelectedElementIds: this.state.selectedElementIds, + selectedLinearElement: null, }); } @@ -10378,84 +11820,174 @@ class App extends React.Component { } }; + private insertImages = async ( + imageFiles: File[], + sceneX: number, + sceneY: number, + ) => { + const gridPadding = 50 / this.state.zoom.value; + // Create, position, and insert placeholders + const placeholders = positionElementsOnGrid( + imageFiles.map(() => this.newImagePlaceholder({ sceneX, sceneY })), + sceneX, + sceneY, + gridPadding, + ); + placeholders.forEach((el) => this.scene.insertElement(el)); + + // Create, position, insert and select initialized (replacing placeholders) + const initialized = await Promise.all( + placeholders.map(async (placeholder, i) => { + try { + return await this.initializeImage( + placeholder, + await normalizeFile(imageFiles[i]), + ); + } catch (error: any) { + this.setState({ + errorMessage: error.message || t("errors.imageInsertError"), + }); + return newElementWith(placeholder, { isDeleted: true }); + } + }), + ); + const initializedMap = arrayToMap(initialized); + + const positioned = positionElementsOnGrid( + initialized.filter((el) => !el.isDeleted), + sceneX, + sceneY, + gridPadding, + ); + const positionedMap = arrayToMap(positioned); + + const nextElements = this.scene + .getElementsIncludingDeleted() + .map((el) => positionedMap.get(el.id) ?? initializedMap.get(el.id) ?? el); + + this.updateScene({ + appState: { + selectedElementIds: makeNextSelectedElementIds( + Object.fromEntries(positioned.map((el) => [el.id, true])), + this.state, + ), + }, + elements: nextElements, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + + this.setState({}, () => { + // actionFinalize after all state values have been updated + this.actionManager.executeAction(actionFinalize); + }); + }; + private handleAppOnDrop = async (event: React.DragEvent) => { - // must be retrieved first, in the same frame - const { file, fileHandle } = await getFileFromEvent(event); const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords( event, this.state, ); + const dataTransferList = await parseDataTransferEvent(event); - try { - // if image tool not supported, don't show an error here and let it fall - // through so we still support importing scene data from images. If no - // scene data encoded, we'll show an error then - if (isSupportedImageFile(file) && this.isToolSupported("image")) { - // first attempt to decode scene from the image if it's embedded - // --------------------------------------------------------------------- + // must be retrieved first, in the same frame + const fileItems = dataTransferList.getFiles(); - if (file?.type === MIME_TYPES.png || file?.type === MIME_TYPES.svg) { - try { - const scene = await loadFromBlob( - file, - this.state, - this.scene.getElementsIncludingDeleted(), - fileHandle, - ); - this.syncActionResult({ - ...scene, - appState: { - ...(scene.appState || this.state), - isLoading: false, - }, - replaceFiles: true, - captureUpdate: CaptureUpdateAction.IMMEDIATELY, - }); - return; - } catch (error: any) { - // Don't throw for image scene daa - if (error.name !== "EncodingError") { - throw new Error(t("alerts.couldNotLoadInvalidFile")); - } + if (fileItems.length === 1) { + const { file, fileHandle } = fileItems[0]; + + if ( + file && + (file.type === MIME_TYPES.png || file.type === MIME_TYPES.svg) + ) { + try { + const scene = await loadFromBlob( + file, + this.state, + this.scene.getElementsIncludingDeleted(), + fileHandle, + ); + this.syncActionResult({ + ...scene, + appState: { + ...(scene.appState || this.state), + isLoading: false, + }, + replaceFiles: true, + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + return; + } catch (error: any) { + if (error.name !== "EncodingError") { + throw new Error(t("alerts.couldNotLoadInvalidFile")); } + // if EncodingError, fall through to insert as regular image } - - // if no scene is embedded or we fail for whatever reason, fall back - // to importing as regular image - // --------------------------------------------------------------------- - this.createImageElement({ sceneX, sceneY, imageFile: file }); - - return; } - } catch (error: any) { - return this.setState({ - isLoading: false, - errorMessage: error.message, - }); } - const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib); - if (libraryJSON && typeof libraryJSON === "string") { + const imageFiles = fileItems + .map((data) => data.file) + .filter((file) => isSupportedImageFile(file)); + + if (imageFiles.length > 0 && this.isToolSupported("image")) { + return this.insertImages(imageFiles, sceneX, sceneY); + } + const excalidrawLibrary_ids = dataTransferList.getData( + MIME_TYPES.excalidrawlibIds, + ); + const excalidrawLibrary_data = dataTransferList.getData( + MIME_TYPES.excalidrawlib, + ); + if (excalidrawLibrary_ids || excalidrawLibrary_data) { try { - const libraryItems = parseLibraryJSON(libraryJSON); - this.addElementsFromPasteOrLibrary({ - elements: distributeLibraryItemsOnSquareGrid(libraryItems), - position: event, - files: null, - }); + let libraryItems: LibraryItems | null = null; + if (excalidrawLibrary_ids) { + const { itemIds } = JSON.parse( + excalidrawLibrary_ids, + ) as ExcalidrawLibraryIds; + const allLibraryItems = await this.library.getLatestLibrary(); + libraryItems = allLibraryItems.filter((item) => + itemIds.includes(item.id), + ); + // legacy library dataTransfer format + } else if (excalidrawLibrary_data) { + libraryItems = parseLibraryJSON(excalidrawLibrary_data); + } + if (libraryItems?.length) { + libraryItems = libraryItems.map((item) => ({ + ...item, + // #6465 + elements: duplicateElements({ + type: "everything", + elements: item.elements, + randomizeSeed: true, + }).duplicatedElements, + })); + + this.addElementsFromPasteOrLibrary({ + elements: distributeLibraryItemsOnSquareGrid(libraryItems), + position: event, + files: null, + }); + } } catch (error: any) { this.setState({ errorMessage: error.message }); } return; } - if (file) { - // Attempt to parse an excalidraw/excalidrawlib file - await this.loadFileToCanvas(file, fileHandle); + if (fileItems.length > 0) { + const { file, fileHandle } = fileItems[0]; + if (file) { + // Attempt to parse an excalidraw/excalidrawlib file + await this.loadFileToCanvas(file, fileHandle); + } } - if (event.dataTransfer?.types?.includes("text/plain")) { - const text = event.dataTransfer?.getData("text"); + const textItem = dataTransferList.findByType(MIME_TYPES.text); + + if (textItem) { + const text = textItem.value; if ( text && embeddableURLValidator(text, this.props.validateEmbeddable) && @@ -10477,7 +12009,7 @@ class App extends React.Component { loadFileToCanvas = async ( file: File, - fileHandle: FileSystemHandle | null, + fileHandle: FileSystemFileHandle | null, ) => { file = await normalizeFile(file); try { @@ -10566,7 +12098,7 @@ class App extends React.Component { event.nativeEvent.pointerType === "pen" && // always allow if user uses a pen secondary button event.button !== POINTER_BUTTON.SECONDARY)) && - this.state.activeTool.type !== "selection" + this.state.activeTool.type !== this.state.preferredSelectionTool.type ) { return; } @@ -10633,7 +12165,11 @@ class App extends React.Component { ): void => { const selectionElement = this.state.selectionElement; const pointerCoords = pointerDownState.lastCoords; - if (selectionElement && this.state.activeTool.type !== "eraser") { + if ( + selectionElement && + pointerDownState.boxSelection.hasOccurred && + this.state.activeTool.type !== "eraser" + ) { dragNewElement({ newElement: selectionElement, elementType: this.state.activeTool.type, @@ -10697,25 +12233,27 @@ class App extends React.Component { snapLines, }); - dragNewElement({ - newElement, - elementType: this.state.activeTool.type, - originX: pointerDownState.originInGrid.x, - originY: pointerDownState.originInGrid.y, - x: gridX, - y: gridY, - width: distance(pointerDownState.originInGrid.x, gridX), - height: distance(pointerDownState.originInGrid.y, gridY), - shouldMaintainAspectRatio: isImageElement(newElement) - ? !shouldMaintainAspectRatio(event) - : shouldMaintainAspectRatio(event), - shouldResizeFromCenter: shouldResizeFromCenter(event), - zoom: this.state.zoom.value, - scene: this.scene, - widthAspectRatio: aspectRatio, - originOffset: this.state.originSnapOffset, - informMutation, - }); + if (!isBindingElement(newElement)) { + dragNewElement({ + newElement, + elementType: this.state.activeTool.type, + originX: pointerDownState.originInGrid.x, + originY: pointerDownState.originInGrid.y, + x: gridX, + y: gridY, + width: distance(pointerDownState.originInGrid.x, gridX), + height: distance(pointerDownState.originInGrid.y, gridY), + shouldMaintainAspectRatio: isImageElement(newElement) + ? !shouldMaintainAspectRatio(event) + : shouldMaintainAspectRatio(event), + shouldResizeFromCenter: shouldResizeFromCenter(event), + zoom: this.state.zoom.value, + scene: this.scene, + widthAspectRatio: aspectRatio, + originOffset: this.state.originSnapOffset, + informMutation, + }); + } this.setState({ newElement, @@ -10815,12 +12353,7 @@ class App extends React.Component { ), ); - updateBoundElements(croppingElement, this.scene, { - newSize: { - width: croppingElement.width, - height: croppingElement.height, - }, - }); + updateBoundElements(croppingElement, this.scene); this.setState({ isCropping: transformHandleType && transformHandleType !== "rotation", @@ -10946,12 +12479,6 @@ class App extends React.Component { pointerDownState.resize.center.y, ) ) { - const suggestedBindings = getSuggestedBindingsForArrows( - selectedElements, - this.scene.getNonDeletedElementsMap(), - this.state.zoom, - ); - const elementsToHighlight = new Set(); selectedFrames.forEach((frame) => { getElementsInResizingFrame( @@ -10964,7 +12491,6 @@ class App extends React.Component { this.setState({ elementsToHighlight: [...elementsToHighlight], - suggestedBindings, }); return true; @@ -11005,6 +12531,8 @@ class App extends React.Component { CONTEXT_MENU_SEPARATOR, actionToggleGridMode, actionToggleObjectsSnapMode, + actionToggleArrowBinding, + actionToggleMidpointSnapping, actionToggleZenMode, actionToggleViewMode, actionToggleStats, @@ -11020,6 +12548,17 @@ class App extends React.Component { return [actionCopy, ...options]; } + const zIndexActions: ContextMenuItems = + this.editorInterface.formFactor === "desktop" + ? [ + CONTEXT_MENU_SEPARATOR, + actionSendBackward, + actionBringForward, + actionSendToBack, + actionBringToFront, + ] + : []; + return [ CONTEXT_MENU_SEPARATOR, actionCut, @@ -11045,11 +12584,7 @@ class App extends React.Component { actionUngroup, CONTEXT_MENU_SEPARATOR, actionAddToLibrary, - CONTEXT_MENU_SEPARATOR, - actionSendBackward, - actionBringForward, - actionSendToBack, - actionBringToFront, + ...zIndexActions, CONTEXT_MENU_SEPARATOR, actionFlipHorizontal, actionFlipVertical, @@ -11070,12 +12605,13 @@ class App extends React.Component { ( event: WheelEvent | React.WheelEvent, ) => { - // if not scrolling on canvas/wysiwyg, ignore if ( !( event.target instanceof HTMLCanvasElement || event.target instanceof HTMLTextAreaElement || - event.target instanceof HTMLIFrameElement + event.target instanceof HTMLIFrameElement || + (event.target instanceof HTMLElement && + event.target.classList.contains(CLASSES.FRAME_NAME)) ) ) { // prevent zooming the browser (but allow scrolling DOM) @@ -11270,6 +12806,8 @@ class App extends React.Component { }; } + watchState = () => {}; + private async updateLanguage() { const currentLang = languages.find((lang) => lang.code === this.props.langCode) || @@ -11289,6 +12827,7 @@ declare global { elements: readonly ExcalidrawElement[]; state: AppState; setState: React.Component["setState"]; + watchState: (prev: any, next: any) => void | undefined; app: InstanceType; history: History; store: Store; diff --git a/packages/excalidraw/components/AppStateObserver.ts b/packages/excalidraw/components/AppStateObserver.ts new file mode 100644 index 0000000000..68faabec58 --- /dev/null +++ b/packages/excalidraw/components/AppStateObserver.ts @@ -0,0 +1,208 @@ +import type { AppState, UnsubscribeCallback } from "../types"; + +type StateChangeSelector = + | keyof AppState + | (keyof AppState)[] + | ((appState: AppState) => unknown); + +type StateChangePredicateOptions = { + predicate: (appState: AppState) => boolean; + callback?: (appState: AppState) => void; + once?: boolean; +}; + +type StateChangeArg = StateChangeSelector | StateChangePredicateOptions; + +type StateChangeListener = { + predicate: (appState: AppState, prevState: AppState) => boolean; + getValue: (appState: AppState) => unknown; + callback: (value: any, appState: AppState) => void; + once: boolean; +}; + +type NormalizedStateChange = { + predicate: StateChangeListener["predicate"]; + getValue: StateChangeListener["getValue"]; + callback?: StateChangeListener["callback"]; + once: boolean; + matchesImmediately: boolean; +}; + +export type OnStateChange = { + ( + prop: K, + callback: (value: AppState[K], appState: AppState) => void, + opts?: { once: boolean }, + ): UnsubscribeCallback; + (prop: K): Promise; + ( + prop: (keyof AppState)[], + callback: (appState: AppState, appState2: AppState) => void, + opts?: { once: boolean }, + ): UnsubscribeCallback; + (prop: (keyof AppState)[]): Promise; + ( + prop: (appState: AppState) => T, + callback: (value: T, appState: AppState) => void, + opts?: { once: boolean }, + ): UnsubscribeCallback; + (prop: (appState: AppState) => T): Promise; + (opts: { + predicate: (appState: AppState) => boolean; + callback: (appState: AppState) => void; + once?: boolean; + }): UnsubscribeCallback; + (opts: { predicate: (appState: AppState) => boolean }): Promise; + ( + selector: StateChangeSelector, + callback: (value: any, appState: AppState) => void, + ): any; +}; + +export class AppStateObserver { + private listeners: StateChangeListener[] = []; + + constructor(private readonly getState: () => AppState) {} + + private isStateChangePredicateOptions( + propOrOpts: StateChangeArg, + ): propOrOpts is StateChangePredicateOptions { + return ( + typeof propOrOpts === "object" && + !Array.isArray(propOrOpts) && + "predicate" in propOrOpts + ); + } + + private subscribe(listener: StateChangeListener): UnsubscribeCallback { + this.listeners.push(listener); + return () => { + this.listeners = this.listeners.filter( + (existingListener) => existingListener !== listener, + ); + }; + } + + private normalize( + propOrOpts: StateChangeArg, + callback?: (value: any, appState: AppState) => void, + opts?: { once: boolean }, + ): NormalizedStateChange { + let predicate: StateChangeListener["predicate"]; + let getValue: StateChangeListener["getValue"]; + let normalizedCallback = callback; + let once = opts?.once ?? false; + let matchesImmediately = false; + + if (this.isStateChangePredicateOptions(propOrOpts)) { + const { + predicate: predicateFn, + callback: callbackFromOpts, + once: onceFromOpts, + } = propOrOpts; + + predicate = predicateFn; + getValue = (appState: AppState) => appState; + normalizedCallback = callbackFromOpts + ? (_value: AppState, appState: AppState) => callbackFromOpts(appState) + : undefined; + once = onceFromOpts ?? false; + matchesImmediately = predicateFn(this.getState()); + } else if (typeof propOrOpts === "function") { + const selector = propOrOpts; + predicate = (appState: AppState, prevState: AppState) => + selector(appState) !== selector(prevState); + getValue = (appState: AppState) => selector(appState); + } else if (Array.isArray(propOrOpts)) { + const keys = propOrOpts; + predicate = (appState: AppState, prevState: AppState) => + keys.some((key) => appState[key] !== prevState[key]); + getValue = (appState: AppState) => appState; + } else { + const key = propOrOpts; + predicate = (appState: AppState, prevState: AppState) => + appState[key] !== prevState[key]; + getValue = (appState: AppState) => appState[key]; + } + + return { + predicate, + getValue, + callback: normalizedCallback, + once, + matchesImmediately, + }; + } + + public onStateChange: OnStateChange = (( + propOrOpts: StateChangeArg, + callback?: any, + opts?: { once: boolean }, + ) => { + const { + predicate, + getValue, + callback: stateChangeCallback, + once, + matchesImmediately, + } = this.normalize(propOrOpts, callback, opts); + + if (stateChangeCallback) { + if (matchesImmediately) { + queueMicrotask(() => { + const state = this.getState(); + stateChangeCallback(getValue(state), state); + }); + if (once) { + return () => {}; + } + } + + return this.subscribe({ + predicate, + getValue, + callback: stateChangeCallback, + once, + }); + } + + if (matchesImmediately) { + return Promise.resolve(getValue(this.getState())); + } + + return new Promise((resolve) => { + this.subscribe({ + predicate, + getValue, + callback: (value) => resolve(value), + once: true, + }); + }); + }) as OnStateChange; + + public flush(prevState: AppState) { + if (!this.listeners.length) { + return; + } + + const state = this.getState(); + const listenersToKeep: StateChangeListener[] = []; + + for (const listener of this.listeners) { + if (listener.predicate(state, prevState)) { + listener.callback(listener.getValue(state), state); + if (!listener.once) { + listenersToKeep.push(listener); + } + } else { + listenersToKeep.push(listener); + } + } + + this.listeners = listenersToKeep; + } + + public clear() { + this.listeners = []; + } +} diff --git a/packages/excalidraw/components/Avatar.scss b/packages/excalidraw/components/Avatar.scss index 6565816e32..323a92188e 100644 --- a/packages/excalidraw/components/Avatar.scss +++ b/packages/excalidraw/components/Avatar.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .Avatar { diff --git a/packages/excalidraw/components/Button.scss b/packages/excalidraw/components/Button.scss index 9abcf59d11..d8cb6a1f6f 100644 --- a/packages/excalidraw/components/Button.scss +++ b/packages/excalidraw/components/Button.scss @@ -1,4 +1,4 @@ -@import "../css/theme"; +@use "../css/theme" as *; .excalidraw { .excalidraw-button { diff --git a/packages/excalidraw/components/ButtonIcon.scss b/packages/excalidraw/components/ButtonIcon.scss index e435b69e45..769a195e2f 100644 --- a/packages/excalidraw/components/ButtonIcon.scss +++ b/packages/excalidraw/components/ButtonIcon.scss @@ -1,4 +1,4 @@ -@import "../css/theme"; +@use "../css/theme" as *; .excalidraw { button.standalone { diff --git a/packages/excalidraw/components/Card.scss b/packages/excalidraw/components/Card.scss index ba0fbc437d..e76d933ae2 100644 --- a/packages/excalidraw/components/Card.scss +++ b/packages/excalidraw/components/Card.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module.scss" as *; .excalidraw { .Card { @@ -19,7 +19,7 @@ padding: 1.4rem; border-radius: 50%; background: var(--card-color); - color: $oc-white; + color: #fff; svg { width: 2.8rem; @@ -46,7 +46,7 @@ background-color: var(--card-color-darkest); } .ToolIcon__label { - color: $oc-white; + color: #fff; } .Spinner { diff --git a/packages/excalidraw/components/Card.tsx b/packages/excalidraw/components/Card.tsx index 2ba5a97a56..a7c38e51e4 100644 --- a/packages/excalidraw/components/Card.tsx +++ b/packages/excalidraw/components/Card.tsx @@ -1,25 +1,35 @@ -import OpenColor from "open-color"; - import "./Card.scss"; +// for open-color see https://github.com/yeun/open-color/blob/master/open-color.scss +const COLOR_MAP = { + primary: { + base: "var(--color-primary)", + darker: "var(--color-primary-darker)", + darkest: "var(--color-primary-darkest)", + }, + lime: { + base: "#74b816", // open-color lime[7] + darker: "#66a80f", // open-color lime[8] + darkest: "#5c940d", // open-color lime[9] + }, + pink: { + base: "#d6336c", // open-color pink[7] + darker: "#c2255c", // open-color pink[8] + darkest: "#a61e4d", // open-color pink[9] + }, +}; + export const Card: React.FC<{ - color: keyof OpenColor | "primary"; + color: "primary" | "lime" | "pink"; children?: React.ReactNode; }> = ({ children, color }) => { return (
{children} diff --git a/packages/excalidraw/components/CheckboxItem.scss b/packages/excalidraw/components/CheckboxItem.scss index 6aaf0aab69..3ad775f227 100644 --- a/packages/excalidraw/components/CheckboxItem.scss +++ b/packages/excalidraw/components/CheckboxItem.scss @@ -1,4 +1,5 @@ -@import "../css/variables.module.scss"; +@use "sass:color"; +@use "../css/variables.module" as *; .excalidraw { .Checkbox { @@ -12,7 +13,7 @@ -webkit-tap-highlight-color: transparent; &:hover:not(.is-checked) .Checkbox-box:not(:focus) { - box-shadow: 0 0 0 2px #{$oc-blue-4}; + box-shadow: 0 0 0 2px #{$color-blue-4}; } &:hover:not(.is-checked) .Checkbox-box:not(:focus) { @@ -24,25 +25,25 @@ &:active { .Checkbox-box { - box-shadow: 0 0 2px 1px inset #{$oc-blue-7} !important; + box-shadow: 0 0 2px 1px inset #{$color-blue-7} !important; } } &:hover { .Checkbox-box { - background-color: fade-out($oc-blue-1, 0.8); + background-color: color.adjust($color-blue-1, $alpha: -0.8); } } &.is-checked { .Checkbox-box { - background-color: #{$oc-blue-1}; + background-color: #{$color-blue-1}; svg { display: block; } } &:hover .Checkbox-box { - background-color: #{$oc-blue-2}; + background-color: #{$color-blue-2}; } } @@ -58,16 +59,16 @@ align-items: center; justify-content: center; - box-shadow: 0 0 0 2px #{$oc-blue-7}; + box-shadow: 0 0 0 2px #{$color-blue-7}; background-color: transparent; border-radius: 4px; - color: #{$oc-blue-7}; + color: #{$color-blue-7}; border: 0; &:focus { - box-shadow: 0 0 0 3px #{$oc-blue-7}; + box-shadow: 0 0 0 3px #{$color-blue-7}; } svg { diff --git a/packages/excalidraw/components/ColorPicker/ColorInput.tsx b/packages/excalidraw/components/ColorPicker/ColorInput.tsx index e5e6f3a771..97704ffc09 100644 --- a/packages/excalidraw/components/ColorPicker/ColorInput.tsx +++ b/packages/excalidraw/components/ColorPicker/ColorInput.tsx @@ -1,35 +1,33 @@ import clsx from "clsx"; import { useCallback, useEffect, useRef, useState } from "react"; -import { KEYS, getShortcutKey } from "@excalidraw/common"; +import { KEYS, normalizeInputColor } from "@excalidraw/common"; +import { getShortcutKey } from "../..//shortcut"; import { useAtom } from "../../editor-jotai"; import { t } from "../../i18n"; -import { useDevice } from "../App"; +import { useEditorInterface } from "../App"; import { activeEyeDropperAtom } from "../EyeDropper"; import { eyeDropperIcon } from "../icons"; -import { getColor } from "./ColorPicker"; import { activeColorPickerSectionAtom } from "./colorPickerUtils"; import type { ColorPickerType } from "./colorPickerUtils"; -interface ColorInputProps { - color: string; - onChange: (color: string) => void; - label: string; - colorPickerType: ColorPickerType; - placeholder?: string; -} - export const ColorInput = ({ color, onChange, label, colorPickerType, placeholder, -}: ColorInputProps) => { - const device = useDevice(); +}: { + color: string; + onChange: (color: string) => void; + label: string; + colorPickerType: ColorPickerType; + placeholder?: string; +}) => { + const editorInterface = useEditorInterface(); const [innerValue, setInnerValue] = useState(color); const [activeSection, setActiveColorPickerSection] = useAtom( activeColorPickerSectionAtom, @@ -42,7 +40,7 @@ export const ColorInput = ({ const changeColor = useCallback( (inputValue: string) => { const value = inputValue.toLowerCase(); - const color = getColor(value); + const color = normalizeInputColor(value); if (color) { onChange(color); @@ -98,7 +96,7 @@ export const ColorInput = ({ placeholder={placeholder} /> {/* TODO reenable on mobile with a better UX */} - {!device.editor.isMobile && ( + {editorInterface.formFactor !== "phone" && ( <>
{ - const style = new Option().style; - style.color = color; - return !!style.color; -}; - -export const getColor = (color: string): string | null => { - if (isTransparent(color)) { - return color; - } - - // testing for `#` first fixes a bug on Electron (more specfically, an - // Obsidian popout window), where a hex color without `#` is (incorrectly) - // considered valid - return isValidColor(`#${color}`) - ? `#${color}` - : isValidColor(color) - ? color - : null; -}; - interface ColorPickerProps { type: ColorPickerType; /** @@ -77,6 +62,8 @@ const ColorPickerPopupContent = ({ elements, palette = COLOR_PALETTE, updateData, + getOpenPopup, + appState, }: Pick< ColorPickerProps, | "type" @@ -86,8 +73,14 @@ const ColorPickerPopupContent = ({ | "elements" | "palette" | "updateData" ->) => { + | "appState" +> & { + getOpenPopup: () => AppState["openPopup"]; +}) => { const { container } = useExcalidrawContainer(); + const stylesPanelMode = useStylesPanelMode(); + const isCompactMode = stylesPanelMode !== "full"; + const isMobileMode = stylesPanelMode === "mobile"; const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom); const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom); @@ -117,9 +110,13 @@ const ColorPickerPopupContent = ({ { // refocus due to eye dropper - focusPickerContent(); + if (!isWritableElement(event.target)) { + focusPickerContent(); + } event.preventDefault(); }} onPointerDownOutside={(event) => { @@ -131,8 +128,23 @@ const ColorPickerPopupContent = ({ } }} onClose={() => { - updateData({ openPopup: null }); + // only clear if we're still the active popup (avoid racing with switch) + if (getOpenPopup() === type) { + updateData({ openPopup: null }); + } setActiveColorPickerSection(null); + + // Refocus text editor when popover closes if we were editing text + if (appState.editingTextElement) { + setTimeout(() => { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor) { + textEditor.focus(); + } + }, 0); + } }} > {palette ? ( @@ -141,7 +153,17 @@ const ColorPickerPopupContent = ({ palette={palette} color={color} onChange={(changedColor) => { + // Save caret position before color change if editing text + const savedSelection = appState.editingTextElement + ? saveCaretPosition() + : null; + onChange(changedColor); + + // Restore caret position after color change if editing text + if (appState.editingTextElement && savedSelection) { + restoreCaretPosition(savedSelection); + } }} onEyeDropperToggle={(force) => { setEyeDropperState((state) => { @@ -168,12 +190,15 @@ const ColorPickerPopupContent = ({ if (eyeDropperState) { setEyeDropperState(null); } else { + // close explicitly on Escape updateData({ openPopup: null }); } }} type={type} elements={elements} updateData={updateData} + showTitle={isCompactMode} + showHotKey={!isMobileMode} > {colorInputJSX} @@ -188,11 +213,33 @@ const ColorPickerTrigger = ({ label, color, type, + mode = "background", + onToggle, + editingTextElement, }: { color: string | null; label: string; type: ColorPickerType; + mode?: "background" | "stroke"; + onToggle: () => void; + editingTextElement?: boolean; }) => { + const stylesPanelMode = useStylesPanelMode(); + const isCompactMode = stylesPanelMode !== "full"; + const isMobileMode = stylesPanelMode === "mobile"; + const handleClick = (e: React.MouseEvent) => { + // use pointerdown so we run before outside-close logic + e.preventDefault(); + e.stopPropagation(); + + // If editing text, temporarily disable the wysiwyg blur event + if (editingTextElement) { + temporarilyDisableTextEditorBlur(); + } + + onToggle(); + }; + return (
{!color && slashIcon}
+ {isCompactMode && color && mode === "stroke" && ( +
+ + {strokeIcon} + +
+ )}
); }; @@ -225,24 +290,59 @@ export const ColorPicker = ({ updateData, appState, }: ColorPickerProps) => { + const openRef = useRef(appState.openPopup); + useEffect(() => { + openRef.current = appState.openPopup; + }, [appState.openPopup]); + const stylesPanelMode = useStylesPanelMode(); + const isCompactMode = stylesPanelMode !== "full"; + return (
-
- - +
+ {!isCompactMode && ( + + )} + {!isCompactMode && } { - updateData({ openPopup: open ? type : null }); + if (open) { + updateData({ openPopup: type }); + } }} > {/* serves as an active color indicator as well */} - + { + // atomic switch: if another popup is open, close it first, then open this one next tick + if (appState.openPopup === type) { + // toggle off on same trigger + updateData({ openPopup: null }); + } else if (appState.openPopup) { + updateData({ openPopup: type }); + } else { + // open this one + updateData({ openPopup: type }); + } + }} + /> {/* popup content */} {appState.openPopup === type && ( openRef.current} + appState={appState} /> )} diff --git a/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx index 898a289703..d164c4119f 100644 --- a/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx +++ b/packages/excalidraw/components/ColorPicker/HotkeyLabel.tsx @@ -1,6 +1,5 @@ import React from "react"; - -import { isColorDark } from "./colorPickerUtils"; +import { isColorDark } from "@excalidraw/common"; interface HotkeyLabelProps { color: string; diff --git a/packages/excalidraw/components/ColorPicker/Picker.tsx b/packages/excalidraw/components/ColorPicker/Picker.tsx index f784912f4c..9c48c58075 100644 --- a/packages/excalidraw/components/ColorPicker/Picker.tsx +++ b/packages/excalidraw/components/ColorPicker/Picker.tsx @@ -37,8 +37,10 @@ interface PickerProps { palette: ColorPaletteCustom; updateData: (formData?: any) => void; children?: React.ReactNode; + showTitle?: boolean; onEyeDropperToggle: (force?: boolean) => void; onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void; + showHotKey?: boolean; } export const Picker = React.forwardRef( @@ -51,11 +53,21 @@ export const Picker = React.forwardRef( palette, updateData, children, + showTitle, onEyeDropperToggle, onEscape, + showHotKey = true, }: PickerProps, ref, ) => { + const title = showTitle + ? type === "elementStroke" + ? t("labels.stroke") + : type === "elementBackground" + ? t("labels.background") + : null + : null; + const [customColors] = React.useState(() => { if (type === "canvasBackground") { return []; @@ -154,6 +166,8 @@ export const Picker = React.forwardRef( // to allow focusing by clicking but not by tabbing tabIndex={-1} > + {title &&
{title}
} + {!!customColors.length && (
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef( palette={palette} onChange={onChange} activeShade={activeShade} + showHotKey={showHotKey} />
{t("colorPicker.shades")} - +
{children}
diff --git a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx index 4fd6815e44..13928f0239 100644 --- a/packages/excalidraw/components/ColorPicker/PickerColorList.tsx +++ b/packages/excalidraw/components/ColorPicker/PickerColorList.tsx @@ -20,6 +20,7 @@ interface PickerColorListProps { color: string | null; onChange: (color: string) => void; activeShade: number; + showHotKey?: boolean; } const PickerColorList = ({ @@ -27,6 +28,7 @@ const PickerColorList = ({ color, onChange, activeShade, + showHotKey = true, }: PickerColorListProps) => { const colorObj = getColorNameAndShadeFromColor({ color, @@ -82,7 +84,7 @@ const PickerColorList = ({ key={key} >
- + {showHotKey && } ); })} diff --git a/packages/excalidraw/components/ColorPicker/ShadeList.tsx b/packages/excalidraw/components/ColorPicker/ShadeList.tsx index db33402b0c..2c17c57ede 100644 --- a/packages/excalidraw/components/ColorPicker/ShadeList.tsx +++ b/packages/excalidraw/components/ColorPicker/ShadeList.tsx @@ -16,9 +16,15 @@ interface ShadeListProps { color: string | null; onChange: (color: string) => void; palette: ColorPaletteCustom; + showHotKey?: boolean; } -export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { +export const ShadeList = ({ + color, + onChange, + palette, + showHotKey, +}: ShadeListProps) => { const colorObj = getColorNameAndShadeFromColor({ color: color || "transparent", palette, @@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => { }} >
- + {showHotKey && ( + + )} ))}
diff --git a/packages/excalidraw/components/ColorPicker/TopPicks.tsx b/packages/excalidraw/components/ColorPicker/TopPicks.tsx index 6a00b18178..5b5070a5b6 100644 --- a/packages/excalidraw/components/ColorPicker/TopPicks.tsx +++ b/packages/excalidraw/components/ColorPicker/TopPicks.tsx @@ -5,10 +5,9 @@ import { DEFAULT_CANVAS_BACKGROUND_PICKS, DEFAULT_ELEMENT_BACKGROUND_PICKS, DEFAULT_ELEMENT_STROKE_PICKS, + isColorDark, } from "@excalidraw/common"; -import { isColorDark } from "./colorPickerUtils"; - import type { ColorPickerType } from "./colorPickerUtils"; interface TopPicksProps { diff --git a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts index d5a6f5b812..fee8f8cfc2 100644 --- a/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts +++ b/packages/excalidraw/components/ColorPicker/colorPickerUtils.ts @@ -96,63 +96,6 @@ export type ActiveColorPickerSectionAtomType = export const activeColorPickerSectionAtom = atom(null); -const calculateContrast = (r: number, g: number, b: number): number => { - const yiq = (r * 299 + g * 587 + b * 114) / 1000; - return yiq; -}; - -// YIQ algo, inspiration from https://stackoverflow.com/a/11868398 -export const isColorDark = (color: string, threshold = 160): boolean => { - // no color ("") -> assume it default to black - if (!color) { - return true; - } - - if (color === "transparent") { - return false; - } - - // a string color (white etc) or any other format -> convert to rgb by way - // of creating a DOM node and retrieving the computeStyle - if (!color.startsWith("#")) { - const node = document.createElement("div"); - node.style.color = color; - - if (node.style.color) { - // making invisible so document doesn't reflow (hopefully). - // display=none works too, but supposedly not in all browsers - node.style.position = "absolute"; - node.style.visibility = "hidden"; - node.style.width = "0"; - node.style.height = "0"; - - // needs to be in DOM else browser won't compute the style - document.body.appendChild(node); - const computedColor = getComputedStyle(node).color; - document.body.removeChild(node); - // computed style is in rgb() format - const rgb = computedColor - .replace(/^(rgb|rgba)\(/, "") - .replace(/\)$/, "") - .replace(/\s/g, "") - .split(","); - const r = parseInt(rgb[0]); - const g = parseInt(rgb[1]); - const b = parseInt(rgb[2]); - - return calculateContrast(r, g, b) < threshold; - } - // invalid color -> assume it default to black - return true; - } - - const r = parseInt(color.slice(1, 3), 16); - const g = parseInt(color.slice(3, 5), 16); - const b = parseInt(color.slice(5, 7), 16); - - return calculateContrast(r, g, b) < threshold; -}; - export type ColorPickerType = | "canvasBackground" | "elementBackground" diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.scss b/packages/excalidraw/components/CommandPalette/CommandPalette.scss index 90db95db69..3706244877 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.scss +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.scss @@ -1,4 +1,4 @@ -@import "../../css/variables.module.scss"; +@use "../../css/variables.module" as *; $verticalBreakpoint: 861px; @@ -100,6 +100,19 @@ $verticalBreakpoint: 861px; border-radius: var(--border-radius-lg); cursor: pointer; + --icon-size: 1rem; + + &.command-item-large { + height: 2.75rem; + --icon-size: 1.5rem; + + .icon { + width: var(--icon-size); + height: var(--icon-size); + margin-right: 0.625rem; + } + } + &:active { background-color: var(--color-surface-low); } @@ -130,9 +143,17 @@ $verticalBreakpoint: 861px; } .icon { - width: 16px; - height: 16px; - margin-right: 6px; + width: var(--icon-size, 1rem); + height: var(--icon-size, 1rem); + margin-right: 0.375rem; + + .library-item-icon { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + } } } } diff --git a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx index 3c6f110d27..7a2b7344cf 100644 --- a/packages/excalidraw/components/CommandPalette/CommandPalette.tsx +++ b/packages/excalidraw/components/CommandPalette/CommandPalette.tsx @@ -1,20 +1,20 @@ import clsx from "clsx"; import fuzzy from "fuzzy"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useMemo, useState } from "react"; import { DEFAULT_SIDEBAR, EVENT, KEYS, capitalizeString, - getShortcutKey, isWritableElement, } from "@excalidraw/common"; -import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch"; - import type { MarkRequired } from "@excalidraw/common/utility-types"; +import { actionToggleShapeSwitch } from "../../actions/actionToggleShapeSwitch"; +import { getShortcutKey } from "../../shortcut"; + import { actionClearCanvas, actionLink, @@ -43,7 +43,6 @@ import { getSelectedElements } from "../../scene"; import { LockedIcon, UnlockedIcon, - clockIcon, searchIcon, boltIcon, bucketFillIcon, @@ -51,6 +50,7 @@ import { mermaidLogoIcon, brainIconThin, LibraryIcon, + historyCommandIcon, } from "../icons"; import { SHAPES } from "../shapes"; @@ -61,12 +61,21 @@ import { useStable } from "../../hooks/useStable"; import { Ellipsify } from "../Ellipsify"; -import * as defaultItems from "./defaultCommandPaletteItems"; +import { + distributeLibraryItemsOnSquareGrid, + libraryItemsAtom, +} from "../../data/library"; +import { + useLibraryCache, + useLibraryItemSvg, +} from "../../hooks/useLibraryItemSvg"; + +import * as defaultItems from "./defaultCommandPaletteItems"; import "./CommandPalette.scss"; import type { CommandPaletteItem } from "./types"; -import type { AppProps, AppState, UIAppState } from "../../types"; +import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types"; import type { ShortcutName } from "../../actions/shortcuts"; import type { TranslationKeys } from "../../i18n"; import type { Action } from "../../actions/types"; @@ -80,6 +89,7 @@ export const DEFAULT_CATEGORIES = { editor: "Editor", elements: "Elements", links: "Links", + library: "Library", }; const getCategoryOrder = (category: string) => { @@ -207,6 +217,34 @@ function CommandPaletteInner({ appProps, }); + const [libraryItemsData] = useAtom(libraryItemsAtom); + const libraryCommands: CommandPaletteItem[] = useMemo(() => { + return ( + libraryItemsData.libraryItems + ?.filter( + (libraryItem): libraryItem is MarkRequired => + !!libraryItem.name, + ) + .map((libraryItem) => ({ + label: libraryItem.name, + icon: ( + + ), + category: "Library", + order: getCategoryOrder("Library"), + haystack: deburr(libraryItem.name), + perform: () => { + app.onInsertElements( + distributeLibraryItemsOnSquareGrid([libraryItem]), + ); + }, + })) || [] + ); + }, [app, libraryItemsData.libraryItems]); + useEffect(() => { // these props change often and we don't want them to re-run the effect // which would renew `allCommands`, cascading down and resetting state. @@ -438,7 +476,6 @@ function CommandPaletteInner({ }, perform: () => { setAppState((prevState) => ({ - openMenu: prevState.openMenu === "shape" ? null : "shape", openPopup: "elementStroke", })); }, @@ -458,7 +495,6 @@ function CommandPaletteInner({ }, perform: () => { setAppState((prevState) => ({ - openMenu: prevState.openMenu === "shape" ? null : "shape", openPopup: "elementBackground", })); }, @@ -588,8 +624,9 @@ function CommandPaletteInner({ setAllCommands(allCommands); setLastUsed( - allCommands.find((command) => command.label === lastUsed?.label) ?? - null, + [...allCommands, ...libraryCommands].find( + (command) => command.label === lastUsed?.label, + ) ?? null, ); } }, [ @@ -600,6 +637,7 @@ function CommandPaletteInner({ lastUsed?.label, setLastUsed, setAppState, + libraryCommands, ]); const [commandSearch, setCommandSearch] = useState(""); @@ -796,9 +834,17 @@ function CommandPaletteInner({ return nextCommandsByCategory; }; - let matchingCommands = allCommands - .filter(isCommandAvailable) - .sort((a, b) => a.order - b.order); + let matchingCommands = + commandSearch?.length > 1 + ? [ + ...allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order), + ...libraryCommands, + ] + : allCommands + .filter(isCommandAvailable) + .sort((a, b) => a.order - b.order); const showLastUsed = !commandSearch && lastUsed && isCommandAvailable(lastUsed); @@ -822,14 +868,20 @@ function CommandPaletteInner({ ); matchingCommands = fuzzy .filter(_query, matchingCommands, { - extract: (command) => command.haystack, + extract: (command) => command.haystack ?? "", }) .sort((a, b) => b.score - a.score) .map((item) => item.original); setCommandsByCategory(getNextCommandsByCategory(matchingCommands)); setCurrentCommand(matchingCommands[0] ?? null); - }, [commandSearch, allCommands, isCommandAvailable, lastUsed]); + }, [ + commandSearch, + allCommands, + isCommandAvailable, + lastUsed, + libraryCommands, + ]); return ( - {!app.device.viewport.isMobile && ( + {app.editorInterface.formFactor !== "phone" && (
{t("commandPalette.shortcuts.select")} @@ -875,7 +927,7 @@ function CommandPaletteInner({ marginLeft: "6px", }} > - {clockIcon} + {historyCommandIcon}
executeCommand(lastUsed, event)} disabled={!isCommandAvailable(lastUsed)} onMouseMove={() => setCurrentCommand(lastUsed)} - showShortcut={!app.device.viewport.isMobile} + showShortcut={app.editorInterface.formFactor !== "phone"} appState={uiAppState} />
@@ -902,8 +954,9 @@ function CommandPaletteInner({ isSelected={command.label === currentCommand?.label} onClick={(event) => executeCommand(command, event)} onMouseMove={() => setCurrentCommand(command)} - showShortcut={!app.device.viewport.isMobile} + showShortcut={app.editorInterface.formFactor !== "phone"} appState={uiAppState} + size={category === "Library" ? "large" : "small"} /> ))}
@@ -919,6 +972,20 @@ function CommandPaletteInner({ ); } +const LibraryItemIcon = ({ + id, + elements, +}: { + id: LibraryItem["id"] | null; + elements: LibraryItem["elements"] | undefined; +}) => { + const ref = useRef(null); + const { svgCache } = useLibraryCache(); + + useLibraryItemSvg(id, elements, svgCache, ref); + + return
; +}; const CommandItem = ({ command, @@ -928,6 +995,7 @@ const CommandItem = ({ onClick, showShortcut, appState, + size = "small", }: { command: CommandPaletteItem; isSelected: boolean; @@ -936,6 +1004,7 @@ const CommandItem = ({ onClick: (event: React.MouseEvent) => void; showShortcut: boolean; appState: UIAppState; + size?: "small" | "large"; }) => { const noop = () => {}; @@ -944,6 +1013,7 @@ const CommandItem = ({ className={clsx("command-item", { "item-selected": isSelected, "item-disabled": disabled, + "command-item-large": size === "large", })} ref={(ref) => { if (isSelected && !disabled) { @@ -959,9 +1029,11 @@ const CommandItem = ({
{command.icon && ( diff --git a/packages/excalidraw/components/CommandPalette/types.ts b/packages/excalidraw/components/CommandPalette/types.ts index 957d699273..bb01d66b1e 100644 --- a/packages/excalidraw/components/CommandPalette/types.ts +++ b/packages/excalidraw/components/CommandPalette/types.ts @@ -1,6 +1,5 @@ import type { ActionManager } from "../../actions/manager"; import type { Action } from "../../actions/types"; -import type { UIAppState } from "../../types"; export type CommandPaletteItem = { label: string; @@ -12,11 +11,11 @@ export type CommandPaletteItem = { * (deburred name + keywords) */ haystack?: string; - icon?: React.ReactNode | ((appState: UIAppState) => React.ReactNode); + icon?: Action["icon"]; category: string; order?: number; predicate?: boolean | Action["predicate"]; - shortcut?: string; + shortcut?: string | null; /** if false, command will not show while in view mode */ viewMode?: boolean; perform: (data: { diff --git a/packages/excalidraw/components/ConfirmDialog.scss b/packages/excalidraw/components/ConfirmDialog.scss index 1fa36fc41d..7dfd956adb 100644 --- a/packages/excalidraw/components/ConfirmDialog.scss +++ b/packages/excalidraw/components/ConfirmDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .confirm-dialog { diff --git a/packages/excalidraw/components/ContextMenu.scss b/packages/excalidraw/components/ContextMenu.scss index 3bedf2ba7a..369d8f3d8e 100644 --- a/packages/excalidraw/components/ContextMenu.scss +++ b/packages/excalidraw/components/ContextMenu.scss @@ -1,10 +1,15 @@ -@import "../css/variables.module.scss"; +@use "sass:color"; +@use "../css/variables.module" as *; .excalidraw { + .context-menu-popover { + z-index: var(--zIndex-ui-context-menu); + } + .context-menu { position: relative; border-radius: 4px; - box-shadow: 0 3px 10px transparentize($oc-black, 0.8); + box-shadow: 0 3px 10px color.adjust(#000, $alpha: -0.8); padding: 0; list-style: none; user-select: none; @@ -45,7 +50,7 @@ &.dangerous { .context-menu-item__label { - color: $oc-red-7; + color: $color-red-7; } } @@ -69,7 +74,7 @@ .context-menu-item__label { color: var(--popup-bg-color); } - background-color: $oc-red-6; + background-color: $color-red-6; } } @@ -93,6 +98,6 @@ .context-menu-item-separator { border: none; - border-top: 1px solid $oc-gray-5; + border-top: 1px solid $color-gray-5; } } diff --git a/packages/excalidraw/components/ContextMenu.tsx b/packages/excalidraw/components/ContextMenu.tsx index 3295d1d099..4cef2bb177 100644 --- a/packages/excalidraw/components/ContextMenu.tsx +++ b/packages/excalidraw/components/ContextMenu.tsx @@ -64,6 +64,7 @@ export const ContextMenu = React.memo( offsetTop={appState.offsetTop} viewportWidth={appState.width} viewportHeight={appState.height} + className="context-menu-popover" >
    { const [islandNode, setIslandNode] = useCallbackRefState(); const [lastActiveElement] = useState(document.activeElement); const { id } = useExcalidrawContainer(); - const isFullscreen = useDevice().viewport.isMobile; + const isFullscreen = useEditorInterface().formFactor === "phone"; useEffect(() => { if (!islandNode) { diff --git a/packages/excalidraw/components/ElementLinkDialog.scss b/packages/excalidraw/components/ElementLinkDialog.scss index 9923230741..ab501c204d 100644 --- a/packages/excalidraw/components/ElementLinkDialog.scss +++ b/packages/excalidraw/components/ElementLinkDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .ElementLinkDialog { @@ -59,7 +59,7 @@ } .ElementLinkDialog__remove { - color: $oc-red-9; + color: $color-red-9; margin-left: 1rem; .ToolIcon__icon { @@ -68,7 +68,7 @@ } .ToolIcon__icon svg { - color: $oc-red-6; + color: $color-red-6; } } } diff --git a/packages/excalidraw/components/ExcalidrawLogo.scss b/packages/excalidraw/components/ExcalidrawLogo.scss index e59e8a90c0..d42f98a325 100644 --- a/packages/excalidraw/components/ExcalidrawLogo.scss +++ b/packages/excalidraw/components/ExcalidrawLogo.scss @@ -1,5 +1,8 @@ .excalidraw { .ExcalidrawLogo { + --logo-icon--mobile: 1rem; + --logo-text--mobile: 0.75rem; + --logo-icon--xs: 2rem; --logo-text--xs: 1.5rem; @@ -30,6 +33,17 @@ color: var(--color-logo-text); } + &.is-mobile { + .ExcalidrawLogo-icon { + height: var(--logo-icon--mobile); + } + + .ExcalidrawLogo-text { + height: var(--logo-text--mobile); + margin-left: 0.5rem; + } + } + &.is-xs { .ExcalidrawLogo-icon { height: var(--logo-icon--xs); diff --git a/packages/excalidraw/components/ExcalidrawLogo.tsx b/packages/excalidraw/components/ExcalidrawLogo.tsx index 01d07fc505..8610249ba1 100644 --- a/packages/excalidraw/components/ExcalidrawLogo.tsx +++ b/packages/excalidraw/components/ExcalidrawLogo.tsx @@ -41,7 +41,7 @@ const LogoText = () => ( ); -type LogoSize = "xs" | "small" | "normal" | "large" | "custom"; +type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile"; interface LogoProps { size?: LogoSize; diff --git a/packages/excalidraw/components/ExportDialog.scss b/packages/excalidraw/components/ExportDialog.scss index d114f25be6..ed3ca6f605 100644 --- a/packages/excalidraw/components/ExportDialog.scss +++ b/packages/excalidraw/components/ExportDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .ExportDialog__preview { @@ -112,7 +112,7 @@ font-family: Cascadia; font-size: 1.8em; - color: $oc-white; + color: #fff; &:hover { background-color: var(--button-color-darker); diff --git a/packages/excalidraw/components/FilledButton.scss b/packages/excalidraw/components/FilledButton.scss index 1f689e9697..3257a81217 100644 --- a/packages/excalidraw/components/FilledButton.scss +++ b/packages/excalidraw/components/FilledButton.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; @keyframes successStatusAnimation { 0% { @@ -24,6 +24,14 @@ background-color: var(--back-color); border-color: var(--border-color); + border-radius: 0.5rem; + border-width: 1px; + border-style: solid; + + font-family: var(--font-family); + + user-select: none; + &:hover { transition: all 150ms ease-out; } @@ -45,6 +53,7 @@ &.ExcButton--status-loading, &.ExcButton--status-success { pointer-events: none; + background-color: var(--color-success); .ExcButton__contents { visibility: hidden; @@ -52,7 +61,19 @@ } &[disabled] { - pointer-events: none; + cursor: not-allowed; + + &.ExcButton--variant-filled, + &:hover { + --back-color: var(--color-surface-low) !important; + --text-color: var(--color-on-surface-variant) !important; + } + + &.ExcButton--variant-outlined, + &.ExcButton--variant-icon { + --text-color: var(--color-on-surface-variant); + --border-color: var(--color-surface-high); + } } &, @@ -266,14 +287,6 @@ } } - border-radius: 0.5rem; - border-width: 1px; - border-style: solid; - - font-family: var(--font-family); - - user-select: none; - &--size-large { font-weight: 600; font-size: 0.875rem; diff --git a/packages/excalidraw/components/FilledButton.tsx b/packages/excalidraw/components/FilledButton.tsx index 53b30a0469..fd34c04077 100644 --- a/packages/excalidraw/components/FilledButton.tsx +++ b/packages/excalidraw/components/FilledButton.tsx @@ -20,7 +20,7 @@ export type ButtonColor = export type ButtonSize = "medium" | "large"; export type FilledButtonProps = { - label: string; + label?: string; children?: React.ReactNode; onClick?: (event: React.MouseEvent) => void; @@ -33,6 +33,7 @@ export type FilledButtonProps = { fullWidth?: boolean; icon?: React.ReactNode; + disabled?: boolean; }; export const FilledButton = forwardRef( @@ -48,6 +49,7 @@ export const FilledButton = forwardRef( fullWidth, className, status, + disabled, }, ref, ) => { @@ -94,7 +96,7 @@ export const FilledButton = forwardRef( type="button" aria-label={label} ref={ref} - disabled={_status === "loading" || _status === "success"} + disabled={disabled || _status === "loading" || _status === "success"} >
    {_status === "loading" ? ( diff --git a/packages/excalidraw/components/FixedSideContainer.scss b/packages/excalidraw/components/FixedSideContainer.scss index cc99d47d50..15710a551e 100644 --- a/packages/excalidraw/components/FixedSideContainer.scss +++ b/packages/excalidraw/components/FixedSideContainer.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .FixedSideContainer { diff --git a/packages/excalidraw/components/FontPicker/FontPicker.scss b/packages/excalidraw/components/FontPicker/FontPicker.scss index 5a572585e1..19492772ab 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.scss +++ b/packages/excalidraw/components/FontPicker/FontPicker.scss @@ -1,4 +1,4 @@ -@import "../../css/variables.module.scss"; +@use "../../css/variables.module" as *; .excalidraw { .FontPicker__container { @@ -11,5 +11,10 @@ 2rem + 4 * var(--default-button-size) ); // 4 gaps + 4 buttons } + + &--compact { + display: block; + grid-template-columns: none; + } } } diff --git a/packages/excalidraw/components/FontPicker/FontPicker.test.tsx b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx new file mode 100644 index 0000000000..ab92464fd5 --- /dev/null +++ b/packages/excalidraw/components/FontPicker/FontPicker.test.tsx @@ -0,0 +1,31 @@ +import { KEYS } from "@excalidraw/common"; + +import { Excalidraw } from "../.."; +import { Keyboard } from "../../tests/helpers/ui"; +import { act, render } from "../../tests/test-utils"; + +describe("FontPicker", () => { + it("should be able to open font picker", async () => { + (global as any).ResizeObserver = + (global as any).ResizeObserver || + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }; + + const { queryByTestId } = await render( + , + ); + + Keyboard.keyPress(KEYS.T); + + const fontPickerTrigger = queryByTestId("font-family-show-fonts"); + + expect(fontPickerTrigger).not.toBeNull(); + + act(() => { + fontPickerTrigger!.click(); + }); + }); +}); diff --git a/packages/excalidraw/components/FontPicker/FontPicker.tsx b/packages/excalidraw/components/FontPicker/FontPicker.tsx index 118c6fac3c..4325147e17 100644 --- a/packages/excalidraw/components/FontPicker/FontPicker.tsx +++ b/packages/excalidraw/components/FontPicker/FontPicker.tsx @@ -1,4 +1,5 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; +import clsx from "clsx"; import React, { useCallback, useMemo } from "react"; import { FONT_FAMILY } from "@excalidraw/common"; @@ -58,6 +59,7 @@ interface FontPickerProps { onHover: (fontFamily: FontFamilyValues) => void; onLeave: () => void; onPopupChange: (open: boolean) => void; + compactMode?: boolean; } export const FontPicker = React.memo( @@ -69,6 +71,7 @@ export const FontPicker = React.memo( onHover, onLeave, onPopupChange, + compactMode = false, }: FontPickerProps) => { const defaultFonts = useMemo(() => DEFAULT_FONTS, []); const onSelectCallback = useCallback( @@ -81,18 +84,30 @@ export const FontPicker = React.memo( ); return ( -
    -
    - - type="button" - options={defaultFonts} - value={selectedFontFamily} - onClick={onSelectCallback} - /> -
    - +
    + {!compactMode && ( +
    + + type="button" + options={defaultFonts} + value={selectedFontFamily} + onClick={onSelectCallback} + /> +
    + )} + {!compactMode && } - + {isOpened && ( { } }; +const getFontFamilyLabel = ( + fontFamily: FontFamilyValues, + fontFaces: ExcalidrawFontFace[], +) => + // prefer our config as the browser resolved names may be wrapped in quotes and such + Object.entries(FONT_FAMILY).find(([, id]) => id === fontFamily)?.[0] ?? + fontFaces[0]?.fontFace?.family ?? + "Unknown"; + export const FontPickerList = React.memo( ({ selectedFontFamily, @@ -90,8 +107,10 @@ export const FontPickerList = React.memo( onClose, }: FontPickerListProps) => { const { container } = useExcalidrawContainer(); - const { fonts } = useApp(); + const app = useApp(); + const { fonts } = app; const { showDeprecatedFonts } = useAppProps(); + const stylesPanelMode = useStylesPanelMode(); const [searchTerm, setSearchTerm] = useState(""); const inputRef = useRef(null); @@ -105,7 +124,7 @@ export const FontPickerList = React.memo( const fontDescriptor = { value: familyId, icon: getFontFamilyIcon(familyId), - text: fontFaces[0]?.fontFace?.family ?? "Unknown", + text: getFontFamilyLabel(familyId, fontFaces), }; if (metadata.deprecated) { @@ -187,6 +206,42 @@ export const FontPickerList = React.memo( onLeave, ]); + // Create a wrapped onSelect function that preserves caret position + const wrappedOnSelect = useCallback( + (fontFamily: FontFamilyValues) => { + // Save caret position before font selection if editing text + let savedSelection: { start: number; end: number } | null = null; + if (app.state.editingTextElement) { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor) { + savedSelection = { + start: textEditor.selectionStart, + end: textEditor.selectionEnd, + }; + } + } + + onSelect(fontFamily); + + // Restore caret position after font selection if editing text + if (app.state.editingTextElement && savedSelection) { + setTimeout(() => { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor && savedSelection) { + textEditor.focus(); + textEditor.selectionStart = savedSelection.start; + textEditor.selectionEnd = savedSelection.end; + } + }, 0); + } + }, + [onSelect, app.state.editingTextElement], + ); + const onKeyDown = useCallback>( (event) => { const handled = fontPickerKeyHandler({ @@ -194,7 +249,7 @@ export const FontPickerList = React.memo( inputRef, hoveredFont, filteredFonts, - onSelect, + onSelect: wrappedOnSelect, onHover, onClose, }); @@ -204,7 +259,7 @@ export const FontPickerList = React.memo( event.stopPropagation(); } }, - [hoveredFont, filteredFonts, onSelect, onHover, onClose], + [hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose], ); useEffect(() => { @@ -226,43 +281,74 @@ export const FontPickerList = React.memo( [filteredFonts, sceneFamilies], ); - const renderFont = (font: FontDescriptor, index: number) => ( - { - onSelect(Number(e.currentTarget.value)); - }} - onMouseMove={() => { - if (hoveredFont?.value !== font.value) { - onHover(font.value); - } - }} - > - {font.text} - {font.badge && ( - - {font.badge.placeholder} - - )} - - ); + const FontPickerListItem = ({ + font, + order, + }: { + font: FontDescriptor; + order: number; + }) => { + const ref = useRef(null); + const isHovered = font.value === hoveredFont?.value; + const isSelected = font.value === selectedFontFamily; + + useEffect(() => { + if (!isHovered) { + return; + } + if (order === 0) { + // scroll into the first item differently, so it's visible what is above (i.e. group title) + ref.current?.scrollIntoView?.({ block: "end" }); + } else { + ref.current?.scrollIntoView?.({ block: "nearest" }); + } + }, [isHovered, order]); + + return ( + + ); + }; const groups = []; if (sceneFilteredFonts.length) { groups.push( - {sceneFilteredFonts.map(renderFont)} + {sceneFilteredFonts.map((font, index) => ( + + ))} , ); } @@ -270,9 +356,13 @@ export const FontPickerList = React.memo( if (availableFilteredFonts.length) { groups.push( - {availableFilteredFonts.map((font, index) => - renderFont(font, index + sceneFilteredFonts.length), - )} + {availableFilteredFonts.map((font, index) => ( + + ))} , ); } @@ -282,15 +372,32 @@ export const FontPickerList = React.memo( className="properties-content" container={container} style={{ width: "15rem" }} - onClose={onClose} + onClose={() => { + onClose(); + + // Refocus text editor when font picker closes if we were editing text + if (app.state.editingTextElement) { + setTimeout(() => { + const textEditor = document.querySelector( + ".excalidraw-wysiwyg", + ) as HTMLTextAreaElement; + if (textEditor) { + textEditor.focus(); + } + }, 0); + } + }} onPointerLeave={onLeave} onKeyDown={onKeyDown} + preventAutoFocusOnTouch={!!app.state.editingTextElement} > - + {stylesPanelMode === "full" && ( + + )} { - const isTriggerActive = useMemo( - () => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)), - [selectedFontFamily], - ); + const setAppState = useExcalidrawSetAppState(); + + const compactStyle = compactMode + ? { + ...MOBILE_ACTION_BUTTON_BG, + width: "2rem", + height: "2rem", + } + : {}; return ( - {/* Empty div as trigger so it's stretched 100% due to different button sizes */} -
    +
    {}} + active={isOpened} + onClick={() => { + setAppState((appState) => ({ + openPopup: + appState.openPopup === "fontFamily" ? null : appState.openPopup, + })); + }} + style={{ + border: "none", + ...compactStyle, + }} />
    diff --git a/packages/excalidraw/components/HandButton.tsx b/packages/excalidraw/components/HandButton.tsx index 5ebfdf9d3f..db653a8103 100644 --- a/packages/excalidraw/components/HandButton.tsx +++ b/packages/excalidraw/components/HandButton.tsx @@ -18,7 +18,7 @@ type LockIconProps = { export const HandButton = (props: LockIconProps) => { return ( span { padding: 0.25rem; } + + kbd { + display: inline-block; + margin: 0 1px; + font-family: monospace; + border: 1px solid var(--color-gray-40); + border-radius: 4px; + padding: 1px 3px; + font-size: 10px; + } } &.theme--dark { .HintViewer { color: var(--color-gray-60); + kbd { + border-color: var(--color-gray-60); + } } } } diff --git a/packages/excalidraw/components/HintViewer.tsx b/packages/excalidraw/components/HintViewer.tsx index bc30971323..7dea64ea2a 100644 --- a/packages/excalidraw/components/HintViewer.tsx +++ b/packages/excalidraw/components/HintViewer.tsx @@ -3,34 +3,41 @@ import { CANVAS_SEARCH_TAB, DEFAULT_SIDEBAR } from "@excalidraw/common"; import { isFlowchartNodeElement, isImageElement, + isGridModeEnabled, isLinearElement, isLineElement, isTextBindableContainer, isTextElement, } from "@excalidraw/element"; -import { getShortcutKey } from "@excalidraw/common"; +import { isNodeInFlowchart } from "@excalidraw/element"; -import { isNodeInFlowchart, isGridModeEnabled } from "@excalidraw/element"; +import type { EditorInterface } from "@excalidraw/common"; import { t } from "../i18n"; +import { getShortcutKey } from "../shortcut"; import { isEraserActive } from "../appState"; import "./HintViewer.scss"; -import type { AppClassProperties, Device, UIAppState } from "../types"; +import type { AppClassProperties, UIAppState } from "../types"; interface HintViewerProps { appState: UIAppState; isMobile: boolean; - device: Device; + editorInterface: EditorInterface; app: AppClassProperties; } +const getTaggedShortcutKey = (key: string | string[]) => + Array.isArray(key) + ? `${key.map(getShortcutKey).join(" + ")}` + : `${getShortcutKey(key)}`; + const getHints = ({ appState, isMobile, - device, + editorInterface, app, }: HintViewerProps): null | string | string[] => { const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState; @@ -41,22 +48,45 @@ const getHints = ({ appState.openSidebar.tab === CANVAS_SEARCH_TAB && appState.searchMatches?.matches.length ) { - return t("hints.dismissSearch"); + return t("hints.dismissSearch", { + shortcut: getTaggedShortcutKey("Escape"), + }); } - if (appState.openSidebar && !device.editor.canFitSidebar) { + if (appState.openSidebar && !editorInterface.canFitSidebar) { return null; } if (isEraserActive(appState)) { - return t("hints.eraserRevert"); + return t("hints.eraserRevert", { + shortcut: getTaggedShortcutKey("Alt"), + }); } + + const selectedElements = app.scene.getSelectedElements(appState); + + // creating or dragging arrow point + if ( + appState.selectedLinearElement?.isDragging && + selectedElements[0]?.type === "arrow" + ) { + return t("hints.arrowBindModifiers", { + shortcut_1: getTaggedShortcutKey("Ctrl"), + shortcut_2: getTaggedShortcutKey("Alt"), + }); + } + if (activeTool.type === "arrow" || activeTool.type === "line") { if (multiMode) { - return t("hints.linearElementMulti"); + return t("hints.linearElementMulti", { + shortcut_1: getTaggedShortcutKey("Escape"), + shortcut_2: getTaggedShortcutKey("Enter"), + }); } if (activeTool.type === "arrow") { - return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") }); + return t("hints.arrowTool", { + shortcut: getTaggedShortcutKey("A"), + }); } return t("hints.linearElement"); } @@ -73,8 +103,6 @@ const getHints = ({ return t("hints.embeddable"); } - const selectedElements = app.scene.getSelectedElements(appState); - if ( isResizing && lastPointerDownWith === "mouse" && @@ -82,31 +110,51 @@ const getHints = ({ ) { const targetElement = selectedElements[0]; if (isLinearElement(targetElement) && targetElement.points.length === 2) { - return t("hints.lockAngle"); + return t("hints.lockAngle", { + shortcut: getTaggedShortcutKey("Shift"), + }); } return isImageElement(targetElement) - ? t("hints.resizeImage") - : t("hints.resize"); + ? t("hints.resizeImage", { + shortcut_1: getTaggedShortcutKey("Shift"), + shortcut_2: getTaggedShortcutKey("Alt"), + }) + : t("hints.resize", { + shortcut_1: getTaggedShortcutKey("Shift"), + shortcut_2: getTaggedShortcutKey("Alt"), + }); } if (isRotating && lastPointerDownWith === "mouse") { - return t("hints.rotate"); + return t("hints.rotate", { + shortcut: getTaggedShortcutKey("Shift"), + }); } if (selectedElements.length === 1 && isTextElement(selectedElements[0])) { - return t("hints.text_selected"); + return t("hints.text_selected", { + shortcut: getTaggedShortcutKey("Enter"), + }); } if (appState.editingTextElement) { - return t("hints.text_editing"); + return t("hints.text_editing", { + shortcut_1: getTaggedShortcutKey("Escape"), + shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]), + }); } if (appState.croppingElementId) { - return t("hints.leaveCropEditor"); + return t("hints.leaveCropEditor", { + shortcut_1: getTaggedShortcutKey("Enter"), + shortcut_2: getTaggedShortcutKey("Escape"), + }); } if (selectedElements.length === 1 && isImageElement(selectedElements[0])) { - return t("hints.enterCropEditor"); + return t("hints.enterCropEditor", { + shortcut: getTaggedShortcutKey("Enter"), + }); } if (activeTool.type === "selection") { @@ -116,33 +164,57 @@ const getHints = ({ !appState.editingTextElement && !appState.selectedLinearElement?.isEditing ) { - return [t("hints.deepBoxSelect")]; + return t("hints.deepBoxSelect", { + shortcut: getTaggedShortcutKey("CtrlOrCmd"), + }); } if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) { - return t("hints.disableSnapping"); + return t("hints.disableSnapping", { + shortcut: getTaggedShortcutKey("CtrlOrCmd"), + }); } if (!selectedElements.length && !isMobile) { - return [t("hints.canvasPanning")]; + return t("hints.canvasPanning", { + shortcut_1: getTaggedShortcutKey(t("keys.mmb")), + shortcut_2: getTaggedShortcutKey("Space"), + }); } if (selectedElements.length === 1) { if (isLinearElement(selectedElements[0])) { if (appState.selectedLinearElement?.isEditing) { return appState.selectedLinearElement.selectedPointsIndices - ? t("hints.lineEditor_pointSelected") - : t("hints.lineEditor_nothingSelected"); + ? t("hints.lineEditor_pointSelected", { + shortcut_1: getTaggedShortcutKey("Delete"), + shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]), + }) + : t("hints.lineEditor_nothingSelected", { + shortcut_1: getTaggedShortcutKey("Shift"), + shortcut_2: getTaggedShortcutKey("Alt"), + }); } return isLineElement(selectedElements[0]) - ? t("hints.lineEditor_line_info") - : t("hints.lineEditor_info"); + ? t("hints.lineEditor_line_info", { + shortcut: getTaggedShortcutKey("Enter"), + }) + : t("hints.lineEditor_info", { + shortcut_1: getTaggedShortcutKey("CtrlOrCmd"), + shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]), + }); } if ( !appState.newElement && !appState.selectedElementsAreBeingDragged && isTextBindableContainer(selectedElements[0]) ) { + const bindTextToElement = t("hints.bindTextToElement", { + shortcut: getTaggedShortcutKey("Enter"), + }); + const createFlowchart = t("hints.createFlowchart", { + shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]), + }); if (isFlowchartNodeElement(selectedElements[0])) { if ( isNodeInFlowchart( @@ -150,13 +222,13 @@ const getHints = ({ app.scene.getNonDeletedElementsMap(), ) ) { - return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; + return [bindTextToElement, createFlowchart]; } - return [t("hints.bindTextToElement"), t("hints.createFlowchart")]; + return [bindTextToElement, createFlowchart]; } - return t("hints.bindTextToElement"); + return bindTextToElement; } } } @@ -167,13 +239,13 @@ const getHints = ({ export const HintViewer = ({ appState, isMobile, - device, + editorInterface, app, }: HintViewerProps) => { const hints = getHints({ appState, isMobile, - device, + editorInterface, app, }); @@ -182,16 +254,21 @@ export const HintViewer = ({ } const hint = Array.isArray(hints) - ? hints - .map((hint) => { - return getShortcutKey(hint).replace(/\. ?$/, ""); - }) - .join(". ") - : getShortcutKey(hints); + ? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ") + : hints; + + const hintJSX = hint.split(/([^<]+<\/kbd>)/g).map((part, index) => { + if (index % 2 === 1) { + const shortcutMatch = + part[0] === "<" && part.match(/^([^<]+)<\/kbd>$/); + return {shortcutMatch ? shortcutMatch[1] : part}; + } + return part; + }); return (
    - {hint} + {hintJSX}
    ); }; diff --git a/packages/excalidraw/components/IconPicker.scss b/packages/excalidraw/components/IconPicker.scss index b9b47b39e6..cf99f81329 100644 --- a/packages/excalidraw/components/IconPicker.scss +++ b/packages/excalidraw/components/IconPicker.scss @@ -1,18 +1,25 @@ -@import "../css/variables.module.scss"; +@use "sass:color"; +@use "../css/variables.module" as *; .excalidraw { .picker { padding: 0.5rem; background: var(--popup-bg-color); - border: 0 solid transparentize($oc-white, 0.75); - box-shadow: var(--shadow-island); + border: 0 solid color.adjust(#fff, $alpha: -0.75); + box-shadow: var(--shadow-island-stronger); border-radius: 4px; - position: absolute; :root[dir="rtl"] & { padding: 0.4rem; } } + .picker-sections, + .picker-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + .picker-container button, .picker button { position: relative; @@ -61,7 +68,13 @@ .picker-collapsible { font-size: 0.75rem; - padding: 0.5rem 0; + padding: 0; + color: var(--text-primary-color); + } + + .picker-section-label { + font-size: 0.75rem; + color: var(--text-primary-color); } .picker-keybinding { @@ -87,7 +100,7 @@ } .picker-type-elementBackground .picker-keybinding { - color: $oc-white; + color: #fff; } .picker-swatch[aria-label="transparent"] .picker-keybinding { @@ -100,10 +113,10 @@ &.theme--dark { .picker-type-elementBackground .picker-keybinding { - color: $oc-black; + color: #000; } .picker-swatch[aria-label="transparent"] .picker-keybinding { - color: $oc-black; + color: #000; } } } diff --git a/packages/excalidraw/components/IconPicker.tsx b/packages/excalidraw/components/IconPicker.tsx index 5630ae8d7a..ac05a93d44 100644 --- a/packages/excalidraw/components/IconPicker.tsx +++ b/packages/excalidraw/components/IconPicker.tsx @@ -1,6 +1,6 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; -import React, { useEffect } from "react"; +import React, { useEffect, useMemo } from "react"; import { isArrowKey, KEYS } from "@excalidraw/common"; @@ -8,13 +8,15 @@ import { atom, useAtom } from "../editor-jotai"; import { getLanguage, t } from "../i18n"; import Collapsible from "./Stats/Collapsible"; -import { useDevice } from "./App"; +import { useExcalidrawContainer } from "./App"; import "./IconPicker.scss"; import type { JSX } from "react"; const moreOptionsAtom = atom(false); +const PICKER_COLUMNS = 4; +const DEFAULT_SECTION_NAME = "default"; type Option = { value: T; @@ -23,27 +25,73 @@ type Option = { keyBinding: string | null; }; +type PickerSection = { + name: string; + options: readonly Option[]; +}; + +const flattenOptions = (sections: readonly PickerSection[]) => + sections.flatMap((section) => section.options); + +const findOption = ( + sections: readonly PickerSection[], + predicate: (option: Option) => boolean, +) => { + for (const section of sections) { + const option = section.options.find(predicate); + if (option) { + return option; + } + } + + return null; +}; + +const hasOption = ( + sections: readonly PickerSection[], + predicate: (option: Option) => boolean, +) => sections.some((section) => section.options.some(predicate)); + +const getNavigationRows = (sections: readonly PickerSection[]) => + sections.flatMap((section) => + Array.from( + { length: Math.ceil(section.options.length / PICKER_COLUMNS) }, + (_, index) => + section.options.slice( + index * PICKER_COLUMNS, + index * PICKER_COLUMNS + PICKER_COLUMNS, + ), + ), + ); + function Picker({ - options, + visibleSections, + hiddenSections = [], value, label, onChange, onClose, - numberOfOptionsToAlwaysShow = options.length, }: { label: string; value: T; - options: readonly Option[]; + visibleSections: readonly PickerSection[]; + hiddenSections?: readonly PickerSection[]; onChange: (value: T) => void; onClose: () => void; - numberOfOptionsToAlwaysShow?: number; }) { - const device = useDevice(); + const { container } = useExcalidrawContainer(); + const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom); + const allSections = [...visibleSections, ...hiddenSections]; + const allOptions = flattenOptions(allSections); + const navigationRows = getNavigationRows([ + ...visibleSections, + ...(showMoreOptions ? hiddenSections : []), + ]); const handleKeyDown = (event: React.KeyboardEvent) => { - const pressedOption = options.find( + const pressedOption = allOptions.find( (option) => option.keyBinding === event.key.toLowerCase(), - )!; + ); if (!(event.metaKey || event.altKey || event.ctrlKey) && pressedOption) { // Keybinding navigation @@ -51,17 +99,17 @@ function Picker({ event.preventDefault(); } else if (event.key === KEYS.TAB) { - const index = options.findIndex((option) => option.value === value); + const index = allOptions.findIndex((option) => option.value === value); const nextIndex = event.shiftKey - ? (options.length + index - 1) % options.length - : (index + 1) % options.length; - onChange(options[nextIndex].value); + ? (allOptions.length + index - 1) % allOptions.length + : (index + 1) % allOptions.length; + onChange(allOptions[nextIndex].value); } else if (isArrowKey(event.key)) { // Arrow navigation const isRTL = getLanguage().rtl; - const index = options.findIndex((option) => option.value === value); + const index = allOptions.findIndex((option) => option.value === value); if (index !== -1) { - const length = options.length; + const length = allOptions.length; let nextIndex = index; switch (event.key) { @@ -75,18 +123,60 @@ function Picker({ break; // Go the next row case KEYS.ARROW_DOWN: { - nextIndex = (index + (numberOfOptionsToAlwaysShow ?? 1)) % length; + const currentRowIndex = navigationRows.findIndex((row) => + row.some((option) => option.value === value), + ); + const currentRow = navigationRows[currentRowIndex]; + + if (currentRowIndex !== -1 && currentRow) { + const column = currentRow.findIndex( + (option) => option.value === value, + ); + const nextRow = + navigationRows[(currentRowIndex + 1) % navigationRows.length]; + const nextOption = + nextRow[Math.min(column, nextRow.length - 1)] ?? + allOptions[index]; + + onChange(nextOption.value); + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation(); + event.stopPropagation(); + return; + } break; } // Go the previous row case KEYS.ARROW_UP: { - nextIndex = - (length + index - (numberOfOptionsToAlwaysShow ?? 1)) % length; + const currentRowIndex = navigationRows.findIndex((row) => + row.some((option) => option.value === value), + ); + const currentRow = navigationRows[currentRowIndex]; + + if (currentRowIndex !== -1 && currentRow) { + const column = currentRow.findIndex( + (option) => option.value === value, + ); + const previousRow = + navigationRows[ + (navigationRows.length + currentRowIndex - 1) % + navigationRows.length + ]; + const previousOption = + previousRow[Math.min(column, previousRow.length - 1)] ?? + allOptions[index]; + + onChange(previousOption.value); + event.preventDefault(); + event.nativeEvent.stopImmediatePropagation(); + event.stopPropagation(); + return; + } break; } } - onChange(options[nextIndex].value); + onChange(allOptions[nextIndex].value); } event.preventDefault(); } else if (event.key === KEYS.ESCAPE || event.key === KEYS.ENTER) { @@ -98,38 +188,29 @@ function Picker({ event.stopPropagation(); }; - const [showMoreOptions, setShowMoreOptions] = useAtom(moreOptionsAtom); - - const alwaysVisibleOptions = React.useMemo( - () => options.slice(0, numberOfOptionsToAlwaysShow), - [options, numberOfOptionsToAlwaysShow], - ); - const moreOptions = React.useMemo( - () => options.slice(numberOfOptionsToAlwaysShow), - [options, numberOfOptionsToAlwaysShow], - ); - useEffect(() => { - if (!alwaysVisibleOptions.some((option) => option.value === value)) { + if (hasOption(hiddenSections, (option) => option.value === value)) { setShowMoreOptions(true); } - }, [value, alwaysVisibleOptions, setShowMoreOptions]); + }, [value, hiddenSections, setShowMoreOptions]); - const renderOptions = (options: Option[]) => { + const renderOptions = (options: readonly Option[]) => { return (
    - {options.map((option, i) => ( + {options.map((option) => ( + {(appState.toast || appState.scrolledOutside) && ( +
    + {appState.toast && ( + setAppState({ toast: null })} + duration={appState.toast.duration} + closable={appState.toast.closable} + /> + )} + {!appState.toast && appState.scrolledOutside && ( + + )} +
    )}
    {renderSidebars()} @@ -581,14 +650,8 @@ const LayerUI = ({ }; const stripIrrelevantAppStateProps = (appState: AppState): UIAppState => { - const { - suggestedBindings, - startBoundElement, - cursorButton, - scrollX, - scrollY, - ...ret - } = appState; + const { startBoundElement, cursorButton, scrollX, scrollY, ...ret } = + appState; return ret; }; diff --git a/packages/excalidraw/components/LibraryMenu.scss b/packages/excalidraw/components/LibraryMenu.scss index ac2c354b05..b0892a07f7 100644 --- a/packages/excalidraw/components/LibraryMenu.scss +++ b/packages/excalidraw/components/LibraryMenu.scss @@ -1,4 +1,4 @@ -@import "open-color/open-color"; +@use "../css/variables.module" as *; .excalidraw { .layer-ui__library { @@ -46,15 +46,15 @@ } &-close.ToolIcon_type_button { - background-color: $oc-blue-6; + background-color: $color-blue-6; align-self: flex-end; &:hover { - background-color: $oc-blue-8; + background-color: $color-blue-8; } .ToolIcon__icon { width: auto; font-size: 1rem; - color: $oc-white; + color: #fff; padding: 0 0.5rem; } } @@ -90,7 +90,7 @@ border-radius: var(--border-radius-lg); background-color: var(--color-primary); - color: $oc-white; + color: #fff; text-align: center; white-space: nowrap; text-decoration: none !important; @@ -126,22 +126,18 @@ .dropdown-menu-container { width: 196px; - box-shadow: var(--library-dropdown-shadow); border-radius: var(--border-radius-lg); padding: 0.25rem 0.5rem; + + --box-shadow: var(--library-dropdown-shadow); } } .layer-ui__library .library-menu-dropdown-container { + z-index: 1; position: relative; - &--in-heading { - padding: 0; - position: absolute; - top: 1rem; - right: 0.75rem; - z-index: 1; - + margin-left: auto; .dropdown-menu { top: 100%; } diff --git a/packages/excalidraw/components/LibraryMenu.tsx b/packages/excalidraw/components/LibraryMenu.tsx index 62bd235c26..9a4f29f179 100644 --- a/packages/excalidraw/components/LibraryMenu.tsx +++ b/packages/excalidraw/components/LibraryMenu.tsx @@ -11,6 +11,11 @@ import { LIBRARY_DISABLED_TYPES, randomId, isShallowEqual, + KEYS, + isWritableElement, + addEventListener, + EVENT, + CLASSES, } from "@excalidraw/common"; import type { @@ -266,11 +271,52 @@ export const LibraryMenu = memo(() => { const memoizedLibrary = useMemo(() => app.library, [app.library]); const pendingElements = usePendingElementsMemo(appState, app); + useEffect(() => { + return addEventListener( + document, + EVENT.KEYDOWN, + (event) => { + if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) { + const target = event.target; + if (target.closest(`.${CLASSES.SIDEBAR}`)) { + // stop propagation so that we don't prevent it downstream + // (default browser behavior is to clear search input on ESC) + if (selectedItems.length > 0) { + event.stopPropagation(); + setSelectedItems([]); + } else if ( + isWritableElement(target) && + target instanceof HTMLInputElement && + !target.value + ) { + event.stopPropagation(); + // if search input empty -> close library + // (maybe not a good idea?) + setAppState({ openSidebar: null }); + app.focusContainer(); + } + } else if (selectedItems.length > 0) { + const { x, y } = app.lastViewportPosition; + const elementUnderCursor = document.elementFromPoint(x, y); + // also deselect elements if sidebar doesn't have focus but the + // cursor is over it + if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) { + event.stopPropagation(); + setSelectedItems([]); + } + } + } + }, + { capture: true }, + ); + }, [selectedItems, setAppState, app]); + const onInsertLibraryItems = useCallback( (libraryItems: LibraryItems) => { onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems)); + app.focusContainer(); }, - [onInsertElements], + [onInsertElements, app], ); const deselectItems = useCallback(() => { diff --git a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx index 5b003effa1..9d7e0d1c84 100644 --- a/packages/excalidraw/components/LibraryMenuHeaderContent.tsx +++ b/packages/excalidraw/components/LibraryMenuHeaderContent.tsx @@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{ {t("buttons.export")} )} - {!!items.length && ( - setShowRemoveLibAlert(true)} - icon={TrashIcon} - > - {resetLabel} - - )} {itemsSelected && ( )} + {!!items.length && ( + setShowRemoveLibAlert(true)} + icon={TrashIcon} + > + {resetLabel} + + )} ); diff --git a/packages/excalidraw/components/LibraryMenuItems.scss b/packages/excalidraw/components/LibraryMenuItems.scss index 59cd9f1cf9..a1e5c0da42 100644 --- a/packages/excalidraw/components/LibraryMenuItems.scss +++ b/packages/excalidraw/components/LibraryMenuItems.scss @@ -1,24 +1,42 @@ -@import "open-color/open-color"; +@use "../css/variables.module" as *; .excalidraw { - --container-padding-y: 1.5rem; + --container-padding-y: 1rem; --container-padding-x: 0.75rem; + .library-menu-items-header { + display: flex; + padding-top: 1rem; + padding-bottom: 0.5rem; + gap: 0.5rem; + } + .library-menu-items__no-items { text-align: center; color: var(--color-gray-70); line-height: 1.5; font-size: 0.875rem; width: 100%; + min-height: 55px; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; &__label { color: var(--color-primary); font-weight: 700; font-size: 1.125rem; - margin-bottom: 0.75rem; + margin-bottom: 0.25rem; } } + .library-menu-items__no-items__hint { + color: var(--color-border-outline); + padding: 0.75rem 1rem; + } + &.theme--dark { .library-menu-items__no-items { color: var(--color-gray-40); @@ -34,7 +52,7 @@ overflow-y: auto; flex-direction: column; height: 100%; - justify-content: center; + justify-content: flex-start; margin: 0; position: relative; @@ -51,26 +69,45 @@ } &__items { + // so that spinner is relative-positioned to this container + position: relative; + row-gap: 0.5rem; - padding: var(--container-padding-y) 0; + padding: 1rem 0 var(--container-padding-y) 0; flex: 1; overflow-y: auto; overflow-x: hidden; - margin-bottom: 1rem; } &__header { + display: flex; + align-items: center; + flex: 1 1 auto; + color: var(--color-primary); font-size: 1.125rem; font-weight: 700; margin-bottom: 0.75rem; width: 100%; - padding-right: 4rem; // due to dropdown button box-sizing: border-box; &--excal { margin-top: 2rem; } + + &__hint { + margin-left: auto; + font-size: 10px; + color: var(--color-border-outline); + font-weight: 400; + + kbd { + font-family: monospace; + border: 1px solid var(--color-border-outline); + border-radius: 4px; + padding: 1px 3px; + } + } } &__grid { @@ -79,6 +116,24 @@ grid-gap: 1rem; } + &__search { + flex: 1 1 auto; + margin: 0; + + .ExcTextField__input { + height: var(--lg-button-size); + input { + font-size: 0.875rem; + } + } + + &.hideCancelButton input::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + display: none; + } + } + .separator { width: 100%; display: flex; diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index 8e06632aad..547c3bd66f 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -6,11 +6,14 @@ import React, { useState, } from "react"; -import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; +import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common"; import { duplicateElements } from "@excalidraw/element"; -import { serializeLibraryAsJSON } from "../data/json"; +import clsx from "clsx"; + +import { deburr } from "../deburr"; + import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; @@ -27,6 +30,14 @@ import Stack from "./Stack"; import "./LibraryMenuItems.scss"; +import { TextField } from "./TextField"; + +import { useEditorInterface } from "./App"; + +import { Button } from "./Button"; + +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { ExcalidrawProps, LibraryItem, @@ -64,6 +75,7 @@ export default function LibraryMenuItems({ selectedItems: LibraryItem["id"][]; onSelectItems: (id: LibraryItem["id"][]) => void; }) { + const editorInterface = useEditorInterface(); const libraryContainerRef = useRef(null); const scrollPosition = useScrollPosition(libraryContainerRef); @@ -75,6 +87,30 @@ export default function LibraryMenuItems({ }, []); // eslint-disable-line react-hooks/exhaustive-deps const { svgCache } = useLibraryCache(); + const [lastSelectedItem, setLastSelectedItem] = useState< + LibraryItem["id"] | null + >(null); + + const [searchInputValue, setSearchInputValue] = useState(""); + + const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length; + + const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim(); + + const filteredItems = useMemo(() => { + const searchQuery = deburr(searchInputValue.trim().toLowerCase()); + if (!searchQuery) { + return []; + } + + return libraryItems.filter((item) => { + const itemName = item.name || ""; + return ( + itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery) + ); + }); + }, [libraryItems, searchInputValue]); + const unpublishedItems = useMemo( () => libraryItems.filter((item) => item.status !== "published"), [libraryItems], @@ -85,23 +121,10 @@ export default function LibraryMenuItems({ [libraryItems], ); - const showBtn = !libraryItems.length && !pendingElements.length; - - const isLibraryEmpty = - !pendingElements.length && - !unpublishedItems.length && - !publishedItems.length; - - const [lastSelectedItem, setLastSelectedItem] = useState< - LibraryItem["id"] | null - >(null); - const onItemSelectToggle = useCallback( (id: LibraryItem["id"], event: React.MouseEvent) => { const shouldSelect = !selectedItems.includes(id); - const orderedItems = [...unpublishedItems, ...publishedItems]; - if (shouldSelect) { if (event.shiftKey && lastSelectedItem) { const rangeStart = orderedItems.findIndex( @@ -115,10 +138,13 @@ export default function LibraryMenuItems({ } const selectedItemsMap = arrayToMap(selectedItems); + // Support both top-down and bottom-up selection by using min/max + const minRange = Math.min(rangeStart, rangeEnd); + const maxRange = Math.max(rangeStart, rangeEnd); const nextSelectedIds = orderedItems.reduce( (acc: LibraryItem["id"][], item, idx) => { if ( - (idx >= rangeStart && idx <= rangeEnd) || + (idx >= minRange && idx <= maxRange) || selectedItemsMap.has(item.id) ) { acc.push(item.id); @@ -127,7 +153,6 @@ export default function LibraryMenuItems({ }, [], ); - onSelectItems(nextSelectedIds); } else { onSelectItems([...selectedItems, id]); @@ -147,6 +172,14 @@ export default function LibraryMenuItems({ ], ); + useEffect(() => { + // if selection is removed (e.g. via esc), reset last selected item + // so that subsequent shift+clicks don't select a large range + if (!selectedItems.length) { + setLastSelectedItem(null); + } + }, [selectedItems]); + const getInsertedElements = useCallback( (id: string) => { let targetElements; @@ -175,12 +208,17 @@ export default function LibraryMenuItems({ const onItemDrag = useCallback( (id: LibraryItem["id"], event: React.DragEvent) => { + // we want to serialize just the ids so the operation is fast and there's + // no race condition if people drop the library items on canvas too fast + const data: ExcalidrawLibraryIds = { + itemIds: selectedItems.includes(id) ? selectedItems : [id], + }; event.dataTransfer.setData( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), + MIME_TYPES.excalidrawlibIds, + JSON.stringify(data), ); }, - [getInsertedElements], + [selectedItems], ); const isItemSelected = useCallback( @@ -188,7 +226,6 @@ export default function LibraryMenuItems({ if (!id) { return false; } - return selectedItems.includes(id); }, [selectedItems], @@ -208,10 +245,136 @@ export default function LibraryMenuItems({ ); const itemsRenderedPerBatch = - svgCache.size >= libraryItems.length + svgCache.size >= + (filteredItems.length ? filteredItems : libraryItems).length ? CACHED_ITEMS_RENDERED_PER_BATCH : ITEMS_RENDERED_PER_BATCH; + const searchInputRef = useRef(null); + useEffect(() => { + // focus could be stolen by tab trigger button + nextAnimationFrame(() => { + searchInputRef.current?.focus(); + }); + }, []); + + const JSX_whenNotSearching = !IS_SEARCHING && ( + <> + {!IS_LIBRARY_EMPTY && ( +
    + {t("labels.personalLib")} +
    + )} + {!pendingElements.length && !unpublishedItems.length ? ( +
    + {!publishedItems.length && ( +
    + {t("library.noItems")} +
    + )} +
    + {publishedItems.length > 0 + ? t("library.hint_emptyPrivateLibrary") + : t("library.hint_emptyLibrary")} +
    +
    + ) : ( + + {pendingElements.length > 0 && ( + + )} + + + )} + + {publishedItems.length > 0 && ( +
    + {t("labels.excalidrawLib")} +
    + )} + {publishedItems.length > 0 && ( + + + + )} + + ); + + const JSX_whenSearching = IS_SEARCHING && ( + <> +
    + {t("library.search.heading")} + {!isLoading && ( +
    e.preventDefault()} + onClick={(event) => { + setSearchInputValue(""); + }} + > + esc to clear +
    + )} +
    + {filteredItems.length > 0 ? ( + + + + ) : ( +
    +
    + {t("library.search.noResults")} +
    + +
    + )} + + ); + return (
    - {!isLibraryEmpty && ( +
    + {!IS_LIBRARY_EMPTY && ( + setSearchInputValue(value)} + /> + )} - )} +
    0 ? 1 : "0 1 auto", - marginBottom: 0, + margin: IS_LIBRARY_EMPTY ? "auto" : 0, }} ref={libraryContainerRef} > - <> - {!isLibraryEmpty && ( -
    - {t("labels.personalLib")} -
    - )} - {isLoading && ( -
    - -
    - )} - {!pendingElements.length && !unpublishedItems.length ? ( -
    -
    - {t("library.noItems")} -
    -
    - {publishedItems.length > 0 - ? t("library.hint_emptyPrivateLibrary") - : t("library.hint_emptyLibrary")} -
    -
    - ) : ( - - {pendingElements.length > 0 && ( - - )} - - - )} - + {isLoading && ( +
    + +
    + )} - <> - {(publishedItems.length > 0 || - pendingElements.length > 0 || - unpublishedItems.length > 0) && ( -
    - {t("labels.excalidrawLib")} -
    - )} - {publishedItems.length > 0 ? ( - - - - ) : unpublishedItems.length > 0 ? ( -
    - {t("library.noItems")} -
    - ) : null} - + {JSX_whenNotSearching} + {JSX_whenSearching} - {showBtn && ( + {IS_LIBRARY_EMPTY && ( - - + /> )}
    diff --git a/packages/excalidraw/components/LibraryMenuSection.tsx b/packages/excalidraw/components/LibraryMenuSection.tsx index d98b413fbb..9ff84f5724 100644 --- a/packages/excalidraw/components/LibraryMenuSection.tsx +++ b/packages/excalidraw/components/LibraryMenuSection.tsx @@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg"; import type { LibraryItem } from "../types"; import type { ReactNode } from "react"; -type LibraryOrPendingItem = ( +type LibraryOrPendingItem = readonly ( | LibraryItem | /* pending library item */ { id: null; diff --git a/packages/excalidraw/components/LibraryUnit.scss b/packages/excalidraw/components/LibraryUnit.scss index 5ebe83f414..286a00fc5f 100644 --- a/packages/excalidraw/components/LibraryUnit.scss +++ b/packages/excalidraw/components/LibraryUnit.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .library-unit { @@ -18,12 +18,12 @@ } &--hover { - border-color: var(--color-primary); + background-color: var(--color-surface-mid); } + &:active:not(:has(.library-unit__checkbox:hover)), &--selected { - border-color: var(--color-primary); - border-width: 1px; + background-color: var(--color-surface-high); } &--skeleton { diff --git a/packages/excalidraw/components/LibraryUnit.tsx b/packages/excalidraw/components/LibraryUnit.tsx index 9cd891715c..7d6a599526 100644 --- a/packages/excalidraw/components/LibraryUnit.tsx +++ b/packages/excalidraw/components/LibraryUnit.tsx @@ -1,9 +1,9 @@ import clsx from "clsx"; -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useRef, useState } from "react"; import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg"; -import { useDevice } from "./App"; +import { useEditorInterface } from "./App"; import { CheckboxItem } from "./CheckboxItem"; import { PlusIcon } from "./icons"; @@ -33,26 +33,10 @@ export const LibraryUnit = memo( svgCache: SvgCache; }) => { const ref = useRef(null); - const svg = useLibraryItemSvg(id, elements, svgCache); - - useEffect(() => { - const node = ref.current; - - if (!node) { - return; - } - - if (svg) { - node.innerHTML = svg.outerHTML; - } - - return () => { - node.innerHTML = ""; - }; - }, [svg]); + const svg = useLibraryItemSvg(id, elements, svgCache, ref); const [isHovered, setIsHovered] = useState(false); - const isMobile = useDevice().editor.isMobile; + const isMobile = useEditorInterface().formFactor === "phone"; const adder = isPending && (
    {PlusIcon}
    ); diff --git a/packages/excalidraw/components/LinkButton.tsx b/packages/excalidraw/components/LinkButton.tsx new file mode 100644 index 0000000000..6c53e2da0a --- /dev/null +++ b/packages/excalidraw/components/LinkButton.tsx @@ -0,0 +1,15 @@ +import { FilledButton } from "./FilledButton"; + +export const LinkButton = ({ + children, + href, +}: { + href: string; + children: React.ReactNode; +}) => { + return ( + + {children} + + ); +}; diff --git a/packages/excalidraw/components/MobileMenu.tsx b/packages/excalidraw/components/MobileMenu.tsx index 454c0f64e5..ed83263c3c 100644 --- a/packages/excalidraw/components/MobileMenu.tsx +++ b/packages/excalidraw/components/MobileMenu.tsx @@ -1,32 +1,25 @@ import React from "react"; -import { showSelectedShapeActions } from "@excalidraw/element"; - import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types"; -import { isHandToolActive } from "../appState"; import { useTunnels } from "../context/tunnels"; import { t } from "../i18n"; import { calculateScrollCenter } from "../scene"; import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars"; -import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; +import { ExitViewModeButton, MobileShapeActions } from "./Actions"; +import { MobileToolBar } from "./MobileToolBar"; import { FixedSideContainer } from "./FixedSideContainer"; -import { HandButton } from "./HandButton"; -import { HintViewer } from "./HintViewer"; + import { Island } from "./Island"; -import { LockButton } from "./LockButton"; + import { PenModeButton } from "./PenModeButton"; -import { Section } from "./Section"; -import Stack from "./Stack"; import type { ActionManager } from "../actions/manager"; import type { AppClassProperties, AppProps, AppState, - Device, - ExcalidrawProps, UIAppState, } from "../types"; import type { JSX } from "react"; @@ -38,7 +31,6 @@ type MobileMenuProps = { renderImageExportDialog: () => React.ReactNode; setAppState: React.Component["setState"]; elements: readonly NonDeletedExcalidrawElement[]; - onLockToggle: () => void; onHandToolToggle: () => void; onPenModeToggle: AppClassProperties["togglePenMode"]; @@ -46,9 +38,11 @@ type MobileMenuProps = { isMobile: boolean, appState: UIAppState, ) => JSX.Element | null; - renderCustomStats?: ExcalidrawProps["renderCustomStats"]; + renderTopLeftUI?: ( + isMobile: boolean, + appState: UIAppState, + ) => JSX.Element | null; renderSidebars: () => JSX.Element | null; - device: Device; renderWelcomeScreen: boolean; UIOptions: AppProps["UIOptions"]; app: AppClassProperties; @@ -59,140 +53,106 @@ export const MobileMenu = ({ elements, actionManager, setAppState, - onLockToggle, onHandToolToggle, - onPenModeToggle, - + renderTopLeftUI, renderTopRightUI, - renderCustomStats, renderSidebars, - device, renderWelcomeScreen, UIOptions, app, + onPenModeToggle, }: MobileMenuProps) => { const { WelcomeScreenCenterTunnel, MainMenuTunnel, DefaultSidebarTriggerTunnel, } = useTunnels(); - const renderToolbar = () => { + const renderAppTopBar = () => { + if (appState.openDialog?.name === "elementLinkSelector") { + return null; + } + + const topRightUI = ( +
    + {renderTopRightUI?.(true, appState) ?? + (!appState.viewModeEnabled && ( + <> + onPenModeToggle(null)} + title={t("toolBar.penMode")} + isMobile + penDetected={appState.penDetected} + /> + + + ))} + {appState.viewModeEnabled && ( + + )} +
    + ); + + const topLeftUI = ( +
    + {renderTopLeftUI?.(true, appState)} + +
    + ); + return ( - - {renderWelcomeScreen && } -
    - {(heading: React.ReactNode) => ( - - - - {heading} - - - - - {renderTopRightUI && renderTopRightUI(true, appState)} -
    - {!appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && ( - - )} - onPenModeToggle(null)} - title={t("toolBar.penMode")} - isMobile - penDetected={appState.penDetected} - /> - - onHandToolToggle()} - title={t("toolBar.hand")} - isMobile - /> -
    -
    -
    - )} -
    - -
    +
    + {topLeftUI} + {topRightUI} +
    ); }; - const renderAppToolbar = () => { - if ( - appState.viewModeEnabled || - appState.openDialog?.name === "elementLinkSelector" - ) { - return ( -
    - -
    - ); - } - + const renderToolbar = () => { return ( -
    - - {actionManager.renderAction("toggleEditMenu")} - {actionManager.renderAction( - appState.multiElement ? "finalize" : "duplicateSelection", - )} - {actionManager.renderAction("deleteSelectedElements")} -
    - {actionManager.renderAction("undo")} - {actionManager.renderAction("redo")} -
    -
    + ); }; return ( <> {renderSidebars()} - {!appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && - renderToolbar()} -
    - - {appState.openMenu === "shape" && - !appState.viewModeEnabled && - appState.openDialog?.name !== "elementLinkSelector" && - showSelectedShapeActions(appState, elements) ? ( -
    - -
    - ) : null} -
    - {renderAppToolbar()} + {/* welcome screen, bottom bar, and top bar all have the same z-index */} + {/* ordered in this reverse order so that top bar is on top */} +
    + {renderWelcomeScreen && } +
    + + {!appState.viewModeEnabled && ( +
    + + + + {!appState.viewModeEnabled && + appState.openDialog?.name !== "elementLinkSelector" && + renderToolbar()} {appState.scrolledOutside && !appState.openMenu && !appState.openSidebar && ( @@ -208,9 +168,13 @@ export const MobileMenu = ({ {t("buttons.scrollBackToContent")} )} -
    -
    -
    + +
    + )} + + + {renderAppTopBar()} + ); }; diff --git a/packages/excalidraw/components/MobileToolBar.scss b/packages/excalidraw/components/MobileToolBar.scss new file mode 100644 index 0000000000..985c676eee --- /dev/null +++ b/packages/excalidraw/components/MobileToolBar.scss @@ -0,0 +1,77 @@ +@use "../css/variables.module" as *; + +.excalidraw { + .mobile-toolbar { + display: flex; + flex: 1; + align-items: center; + padding: 0px; + gap: 4px; + border-radius: var(--space-factor); + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; + justify-content: space-between; + } + + .mobile-toolbar::-webkit-scrollbar { + display: none; + } + + .mobile-toolbar .ToolIcon { + min-width: 2rem; + min-height: 2rem; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + .ToolIcon__icon { + width: 2.25rem; + height: 2.25rem; + + &:hover { + background-color: transparent; + } + } + + &.active { + background: var( + --color-surface-primary-container, + var(--island-bg-color) + ); + border-color: var(--button-active-border, var(--color-primary-darkest)); + } + + svg { + width: 1rem; + height: 1rem; + } + } + + .mobile-toolbar .App-toolbar__extra-tools-dropdown { + min-width: 160px; + z-index: var(--zIndex-layerUI); + } + + .mobile-toolbar-separator { + width: 1px; + height: 24px; + background: var(--default-border-color); + margin: 0 2px; + flex-shrink: 0; + } + + .mobile-toolbar-undo { + display: flex; + align-items: center; + } + + .mobile-toolbar-undo .ToolIcon { + min-width: 32px; + min-height: 32px; + width: 32px; + height: 32px; + } +} diff --git a/packages/excalidraw/components/MobileToolBar.tsx b/packages/excalidraw/components/MobileToolBar.tsx new file mode 100644 index 0000000000..7b541bb311 --- /dev/null +++ b/packages/excalidraw/components/MobileToolBar.tsx @@ -0,0 +1,485 @@ +import { useState, useEffect } from "react"; +import clsx from "clsx"; + +import { KEYS, capitalizeString } from "@excalidraw/common"; + +import { trackEvent } from "../analytics"; + +import { t } from "../i18n"; + +import { isHandToolActive } from "../appState"; + +import { useTunnels } from "../context/tunnels"; + +import { HandButton } from "./HandButton"; +import { ToolButton } from "./ToolButton"; +import DropdownMenu from "./dropdownMenu/DropdownMenu"; +import { ToolPopover } from "./ToolPopover"; + +import { + SelectionIcon, + FreedrawIcon, + EraserIcon, + RectangleIcon, + ArrowIcon, + extraToolsIcon, + DiamondIcon, + EllipseIcon, + LineIcon, + TextIcon, + ImageIcon, + frameToolIcon, + EmbedIcon, + laserPointerToolIcon, + LassoIcon, + mermaidLogoIcon, + MagicIcon, +} from "./icons"; + +import "./ToolIcon.scss"; +import "./MobileToolBar.scss"; + +import type { AppClassProperties, ToolType, UIAppState } from "../types"; + +const SHAPE_TOOLS = [ + { + type: "rectangle", + icon: RectangleIcon, + title: capitalizeString(t("toolBar.rectangle")), + }, + { + type: "diamond", + icon: DiamondIcon, + title: capitalizeString(t("toolBar.diamond")), + }, + { + type: "ellipse", + icon: EllipseIcon, + title: capitalizeString(t("toolBar.ellipse")), + }, +] as const; + +const SELECTION_TOOLS = [ + { + type: "selection", + icon: SelectionIcon, + title: capitalizeString(t("toolBar.selection")), + }, + { + type: "lasso", + icon: LassoIcon, + title: capitalizeString(t("toolBar.lasso")), + }, +] as const; + +const LINEAR_ELEMENT_TOOLS = [ + { + type: "arrow", + icon: ArrowIcon, + title: capitalizeString(t("toolBar.arrow")), + }, + { type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) }, +] as const; + +type MobileToolBarProps = { + app: AppClassProperties; + onHandToolToggle: () => void; + setAppState: React.Component["setState"]; +}; + +export const MobileToolBar = ({ + app, + onHandToolToggle, + setAppState, +}: MobileToolBarProps) => { + const activeTool = app.state.activeTool; + const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false); + const [lastActiveGenericShape, setLastActiveGenericShape] = useState< + "rectangle" | "diamond" | "ellipse" + >("rectangle"); + const [lastActiveLinearElement, setLastActiveLinearElement] = useState< + "arrow" | "line" + >("arrow"); + + // keep lastActiveGenericShape in sync with active tool if user switches via other UI + useEffect(() => { + if ( + activeTool.type === "rectangle" || + activeTool.type === "diamond" || + activeTool.type === "ellipse" + ) { + setLastActiveGenericShape(activeTool.type); + } + }, [activeTool.type]); + + // keep lastActiveLinearElement in sync with active tool if user switches via other UI + useEffect(() => { + if (activeTool.type === "arrow" || activeTool.type === "line") { + setLastActiveLinearElement(activeTool.type); + } + }, [activeTool.type]); + + const frameToolSelected = activeTool.type === "frame"; + const laserToolSelected = activeTool.type === "laser"; + const embeddableToolSelected = activeTool.type === "embeddable"; + + const { TTDDialogTriggerTunnel } = useTunnels(); + + const handleToolChange = (toolType: string, pointerType?: string) => { + if (app.state.activeTool.type !== toolType) { + trackEvent("toolbar", toolType, "ui"); + } + + if (toolType === "selection") { + if (app.state.activeTool.type === "selection") { + // Toggle selection tool behavior if needed + } else { + app.setActiveTool({ type: "selection" }); + } + } else { + app.setActiveTool({ type: toolType as ToolType }); + } + }; + + const [toolbarWidth, setToolbarWidth] = useState(0); + + const WIDTH = 36; + const GAP = 4; + + // hand, selection, freedraw, eraser, rectangle, arrow, others + const MIN_TOOLS = 7; + const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP; + const ADDITIONAL_WIDTH = WIDTH + GAP; + + const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH; + const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH; + const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH; + + const extraTools = [ + "text", + "frame", + "embeddable", + "laser", + "magicframe", + ].filter((tool) => { + if (showTextToolOutside && tool === "text") { + return false; + } + if (showImageToolOutside && tool === "image") { + return false; + } + if (showFrameToolOutside && tool === "frame") { + return false; + } + return true; + }); + const extraToolSelected = extraTools.includes(activeTool.type); + const extraIcon = extraToolSelected + ? activeTool.type === "text" + ? TextIcon + : activeTool.type === "image" + ? ImageIcon + : activeTool.type === "frame" + ? frameToolIcon + : activeTool.type === "embeddable" + ? EmbedIcon + : activeTool.type === "laser" + ? laserPointerToolIcon + : activeTool.type === "magicframe" + ? MagicIcon + : extraToolsIcon + : extraToolsIcon; + + return ( +
    { + if (div) { + setToolbarWidth(div.getBoundingClientRect().width); + } + }} + > + {/* Hand Tool */} + + + {/* Selection Tool */} + { + if (type === "selection" || type === "lasso") { + app.setActiveTool({ type }); + setAppState({ + preferredSelectionTool: { type, initialized: true }, + }); + } + }} + displayedOption={ + SELECTION_TOOLS.find( + (tool) => tool.type === app.state.preferredSelectionTool.type, + ) || SELECTION_TOOLS[0] + } + /> + + {/* Free Draw */} + handleToolChange("freedraw")} + /> + + {/* Eraser */} + handleToolChange("eraser")} + /> + + {/* Rectangle */} + { + if ( + type === "rectangle" || + type === "diamond" || + type === "ellipse" + ) { + setLastActiveGenericShape(type); + app.setActiveTool({ type }); + } + }} + displayedOption={ + SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) || + SHAPE_TOOLS[0] + } + /> + + {/* Arrow/Line */} + { + if (type === "arrow" || type === "line") { + setLastActiveLinearElement(type); + app.setActiveTool({ type }); + } + }} + displayedOption={ + LINEAR_ELEMENT_TOOLS.find( + (tool) => tool.type === lastActiveLinearElement, + ) || LINEAR_ELEMENT_TOOLS[0] + } + /> + + {/* Text Tool */} + {showTextToolOutside && ( + handleToolChange("text")} + /> + )} + + {/* Image */} + {showImageToolOutside && ( + handleToolChange("image")} + /> + )} + + {/* Frame Tool */} + {showFrameToolOutside && ( + handleToolChange("frame")} + /> + )} + + {/* Other Shapes */} + + { + setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen); + setAppState({ openMenu: null, openPopup: null }); + }} + title={t("toolBar.extraTools")} + style={{ + width: WIDTH, + height: WIDTH, + display: "flex", + alignItems: "center", + justifyContent: "center", + }} + > + {extraIcon} + + setIsOtherShapesMenuOpen(false)} + onSelect={() => setIsOtherShapesMenuOpen(false)} + className="App-toolbar__extra-tools-dropdown" + align="start" + > + {!showTextToolOutside && ( + app.setActiveTool({ type: "text" })} + icon={TextIcon} + shortcut={KEYS.T.toLocaleUpperCase()} + data-testid="toolbar-text" + selected={activeTool.type === "text"} + > + {t("toolBar.text")} + + )} + + {!showImageToolOutside && ( + app.setActiveTool({ type: "image" })} + icon={ImageIcon} + data-testid="toolbar-image" + selected={activeTool.type === "image"} + > + {t("toolBar.image")} + + )} + {!showFrameToolOutside && ( + app.setActiveTool({ type: "frame" })} + icon={frameToolIcon} + shortcut={KEYS.F.toLocaleUpperCase()} + data-testid="toolbar-frame" + selected={frameToolSelected} + > + {t("toolBar.frame")} + + )} + app.setActiveTool({ type: "embeddable" })} + icon={EmbedIcon} + data-testid="toolbar-embeddable" + selected={embeddableToolSelected} + > + {t("toolBar.embeddable")} + + app.setActiveTool({ type: "laser" })} + icon={laserPointerToolIcon} + data-testid="toolbar-laser" + selected={laserToolSelected} + shortcut={KEYS.K.toLocaleUpperCase()} + > + {t("toolBar.laser")} + +
    + Generate +
    + {app.props.aiEnabled !== false && } + app.setOpenDialog({ name: "ttd", tab: "mermaid" })} + icon={mermaidLogoIcon} + data-testid="toolbar-embeddable" + > + {t("toolBar.mermaidToExcalidraw")} + + {app.props.aiEnabled !== false && app.plugins.diagramToCode && ( + <> + app.onMagicframeToolSelect()} + icon={MagicIcon} + data-testid="toolbar-magicframe" + badge={AI} + > + {t("toolBar.magicframe")} + + + )} +
    +
    +
    + ); +}; diff --git a/packages/excalidraw/components/Modal.scss b/packages/excalidraw/components/Modal.scss index 1a355e2e11..9154079e28 100644 --- a/packages/excalidraw/components/Modal.scss +++ b/packages/excalidraw/components/Modal.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { &.excalidraw-modal-container { diff --git a/packages/excalidraw/components/Modal.tsx b/packages/excalidraw/components/Modal.tsx index 32f42986fa..5281768b1f 100644 --- a/packages/excalidraw/components/Modal.tsx +++ b/packages/excalidraw/components/Modal.tsx @@ -49,7 +49,6 @@ export const Modal: React.FC<{ aria-modal="true" onKeyDown={handleKeydown} aria-labelledby={props.labelledBy} - data-prevent-outside-click >
    void; type OnInsertChart = (chartType: ChartType, elements: ChartElements) => void; +const getChartTypeLabel = (chartType: ChartType) => { + switch (chartType) { + case "bar": + return t("labels.chartType_bar"); + case "line": + return t("labels.chartType_line"); + case "radar": + return t("labels.chartType_radar"); + default: + return chartType; + } +}; + const ChartPreviewBtn = (props: { spreadsheet: Spreadsheet | null; chartType: ChartType; - selected: boolean; + colorSeed: number; onClick: OnInsertChart; }) => { const previewRef = useRef(null); const [chartElements, setChartElements] = useState( null, ); + const { theme } = useUIAppState(); useLayoutEffect(() => { if (!props.spreadsheet) { + setChartElements(null); return; } @@ -39,7 +60,13 @@ const ChartPreviewBtn = (props: { props.spreadsheet, 0, 0, + props.colorSeed, ); + if (!elements) { + setChartElements(null); + previewRef.current?.replaceChildren(); + return; + } setChartElements(elements); let svg: SVGSVGElement; const previewNode = previewRef.current!; @@ -49,7 +76,8 @@ const ChartPreviewBtn = (props: { elements, { exportBackground: false, - viewBackgroundColor: oc.white, + viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", }, null, // files { @@ -59,42 +87,108 @@ const ChartPreviewBtn = (props: { svg.querySelector(".style-fonts")?.remove(); previewNode.replaceChildren(); previewNode.appendChild(svg); - - if (props.selected) { - (previewNode.parentNode as HTMLDivElement).focus(); - } })(); return () => { previewNode.replaceChildren(); }; - }, [props.spreadsheet, props.chartType, props.selected]); + }, [props.spreadsheet, props.chartType, props.colorSeed, theme]); + + const chartTypeLabel = getChartTypeLabel(props.chartType); return ( + ); +}; + +const PlainTextPreviewBtn = (props: { + rawText: string; + onClick: OnPlainTextPaste; +}) => { + const previewRef = useRef(null); + const { theme } = useUIAppState(); + + useLayoutEffect(() => { + if (!props.rawText) { + return; + } + + const textElement = newTextElement({ + text: props.rawText, + x: 0, + y: 0, + }); + + const previewNode = previewRef.current!; + + (async () => { + const svg = await exportToSvg( + [textElement], + { + exportBackground: false, + viewBackgroundColor: "#fff", + exportWithDarkMode: theme === "dark", + }, + null, + { + skipInliningFonts: true, + }, + ); + svg.querySelector(".style-fonts")?.remove(); + previewNode.replaceChildren(); + previewNode.appendChild(svg); + })(); + + return () => { + previewNode.replaceChildren(); + }; + }, [props.rawText, theme]); + + return ( + ); }; export const PasteChartDialog = ({ - setAppState, - appState, + data, + rawText, onClose, }: { - appState: UIAppState; + data: Spreadsheet; + rawText: string; onClose: () => void; - setAppState: React.Component["setState"]; }) => { - const { onInsertElements } = useApp(); + const { onInsertElements, focusContainer } = useApp(); + const [colorSeed, setColorSeed] = useState(Math.random()); + + const handleReshuffleColors = React.useCallback(() => { + setColorSeed(Math.random()); + }, []); + const handleClose = React.useCallback(() => { if (onClose) { onClose(); @@ -104,36 +198,72 @@ export const PasteChartDialog = ({ const handleChartClick = (chartType: ChartType, elements: ChartElements) => { onInsertElements(elements); trackEvent("paste", "chart", chartType); - setAppState({ - currentChartType: chartType, - pasteDialog: { - shown: false, - data: null, - }, + onClose(); + focusContainer(); + }; + + const handlePlainTextClick = (rawText: string) => { + const textElement = newTextElement({ + text: rawText, + x: 0, + y: 0, }); + onInsertElements([textElement]); + trackEvent("paste", "chart", "plaintext"); + onClose(); + focusContainer(); }; return ( +
    + {t("labels.pasteCharts")} +
    +
    { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleReshuffleColors(); + } + }} + > + {bucketFillIcon} +
    +
    + } className={"PasteChartDialog"} autofocus={false} >
    - - + {(["bar", "line", "radar"] as const).map((chartType) => { + if (!isSpreadsheetValidForChartType(data, chartType)) { + return null; + } + + return ( + + ); + })} + {rawText && ( + + )}
    ); diff --git a/packages/excalidraw/components/Popover.tsx b/packages/excalidraw/components/Popover.tsx index 4864b37d16..d8ee76aa62 100644 --- a/packages/excalidraw/components/Popover.tsx +++ b/packages/excalidraw/components/Popover.tsx @@ -3,6 +3,8 @@ import { unstable_batchedUpdates } from "react-dom"; import { KEYS, queryFocusableElements } from "@excalidraw/common"; +import clsx from "clsx"; + import "./Popover.scss"; type Props = { @@ -15,6 +17,7 @@ type Props = { offsetTop?: number; viewportWidth?: number; viewportHeight?: number; + className?: string; }; export const Popover = ({ @@ -27,6 +30,7 @@ export const Popover = ({ offsetTop = 0, viewportWidth = window.innerWidth, viewportHeight = window.innerHeight, + className, }: Props) => { const popoverRef = useRef(null); @@ -146,7 +150,7 @@ export const Popover = ({ }, [onCloseRequest]); return ( -
    +
    {children}
    ); diff --git a/packages/excalidraw/components/PropertiesPopover.tsx b/packages/excalidraw/components/PropertiesPopover.tsx index d8372ea27b..ee8d0231c1 100644 --- a/packages/excalidraw/components/PropertiesPopover.tsx +++ b/packages/excalidraw/components/PropertiesPopover.tsx @@ -1,10 +1,10 @@ -import * as Popover from "@radix-ui/react-popover"; +import { Popover } from "radix-ui"; import clsx from "clsx"; import React, { type ReactNode } from "react"; import { isInteractive } from "@excalidraw/common"; -import { useDevice } from "./App"; +import { useEditorInterface } from "./App"; import { Island } from "./Island"; interface PropertiesPopoverProps { @@ -17,6 +17,7 @@ interface PropertiesPopoverProps { onPointerLeave?: React.PointerEventHandler; onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"]; onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"]; + preventAutoFocusOnTouch?: boolean; } export const PropertiesPopover = React.forwardRef< @@ -34,10 +35,13 @@ export const PropertiesPopover = React.forwardRef< onFocusOutside, onPointerLeave, onPointerDownOutside, + preventAutoFocusOnTouch = false, }, ref, ) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); + const isMobilePortrait = + editorInterface.formFactor === "phone" && !editorInterface.isLandscape; return ( @@ -45,25 +49,26 @@ export const PropertiesPopover = React.forwardRef< ref={ref} className={clsx("focus-visible-none", className)} data-prevent-outside-click - side={ - device.editor.isMobile && !device.viewport.isLandscape - ? "bottom" - : "right" - } - align={ - device.editor.isMobile && !device.viewport.isLandscape - ? "center" - : "start" - } + side={isMobilePortrait ? "bottom" : "right"} + align={isMobilePortrait ? "center" : "start"} alignOffset={-16} sideOffset={20} + collisionBoundary={container ?? undefined} style={{ - zIndex: "var(--zIndex-popup)", + zIndex: "var(--zIndex-ui-styles-popup)", + marginLeft: + editorInterface.formFactor === "phone" ? "0.5rem" : undefined, }} onPointerLeave={onPointerLeave} onKeyDown={onKeyDown} onFocusOutside={onFocusOutside} onPointerDownOutside={onPointerDownOutside} + onOpenAutoFocus={(e) => { + // prevent auto-focus on touch devices to avoid keyboard popup + if (preventAutoFocusOnTouch && editorInterface.isTouchScreen) { + e.preventDefault(); + } + }} onCloseAutoFocus={(e) => { e.stopPropagation(); // prevents focusing the trigger diff --git a/packages/excalidraw/components/PublishLibrary.scss b/packages/excalidraw/components/PublishLibrary.scss index fdcacf1291..c2746f5874 100644 --- a/packages/excalidraw/components/PublishLibrary.scss +++ b/packages/excalidraw/components/PublishLibrary.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .publish-library { @@ -14,7 +14,7 @@ span { font-weight: 500; font-size: 1rem; - color: $oc-gray-6; + color: $color-gray-6; } input, textarea { @@ -24,7 +24,7 @@ } .required { - color: $oc-red-8; + color: $color-red-8; margin: 0.2rem; } } @@ -48,22 +48,22 @@ } &--confirm.ToolIcon_type_button { - background-color: $oc-blue-6; + background-color: $color-blue-6; &:hover { - background-color: $oc-blue-8; + background-color: $color-blue-8; } } &--cancel.ToolIcon_type_button { - background-color: $oc-gray-5; + background-color: $color-gray-5; &:hover { - background-color: $oc-gray-6; + background-color: $color-gray-6; } } .ToolIcon__icon { - color: $oc-white; + color: #fff; .Spinner { --spinner-color: #fff; svg { @@ -83,7 +83,7 @@ } &-warning { - color: $oc-red-6; + color: $color-red-6; } &-note { @@ -102,14 +102,14 @@ top: 0.3rem; left: 0.3rem; font-size: 0.7rem; - color: $oc-red-7; + color: $color-red-7; background: rgba(255, 255, 255, 0.9); padding: 0.1rem 0.2rem; border-radius: 0.2rem; } &__svg { - background-color: $oc-white; + background-color: #fff; padding: 0.3rem; width: 7.5rem; height: 7.5rem; @@ -121,7 +121,7 @@ } .ToolIcon__icon { - background-color: $oc-white; + background-color: #fff; width: auto; height: auto; margin: 0 0.5rem; @@ -132,7 +132,7 @@ } .required, .error { - color: $oc-red-8; + color: $color-red-8; font-weight: 700; font-size: 1rem; margin: 0.2rem; @@ -152,16 +152,16 @@ margin: 0; } .ToolIcon__icon { - background-color: $oc-red-6; + background-color: $color-red-6; &:hover { - background-color: $oc-red-7; + background-color: $color-red-7; } &:active { - background-color: $oc-red-8; + background-color: $color-red-8; } } svg { - color: $oc-white; + color: #fff; padding: 0.26rem; border-radius: 0.3em; width: 1rem; diff --git a/packages/excalidraw/components/PublishLibrary.tsx b/packages/excalidraw/components/PublishLibrary.tsx index 076b303d70..0376f812a5 100644 --- a/packages/excalidraw/components/PublishLibrary.tsx +++ b/packages/excalidraw/components/PublishLibrary.tsx @@ -1,5 +1,4 @@ import { exportToCanvas, exportToSvg } from "@excalidraw/utils/export"; -import OpenColor from "open-color"; import { useCallback, useEffect, useRef, useState } from "react"; import { @@ -57,7 +56,7 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => { const ctx = canvas.getContext("2d")!; - ctx.fillStyle = OpenColor.white; + ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height); // draw items @@ -87,7 +86,7 @@ const generatePreviewImage = async (libraryItems: LibraryItems) => { // draw item border // ------------------------------------------------------------------------- ctx.lineWidth = BORDER_WIDTH; - ctx.strokeStyle = OpenColor.gray[4]; + ctx.strokeStyle = "#ced4da"; ctx.strokeRect( colOffset + BOX_PADDING / 2, rowOffset + BOX_PADDING / 2, @@ -131,7 +130,7 @@ const SingleLibraryItem = ({ elements: libItem.elements, appState: { ...appState, - viewBackgroundColor: OpenColor.white, + viewBackgroundColor: "#fff", exportBackground: true, }, files: null, @@ -175,7 +174,7 @@ const SingleLibraryItem = ({ }} >
    - + {t("publishDialog.itemName")}
    diff --git a/packages/excalidraw/components/RadioGroup.scss b/packages/excalidraw/components/RadioGroup.scss index 76ee20a16a..d550d95b3b 100644 --- a/packages/excalidraw/components/RadioGroup.scss +++ b/packages/excalidraw/components/RadioGroup.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { --RadioGroup-background: var(--island-bg-color); @@ -26,13 +26,16 @@ background: var(--RadioGroup-background); border: 1px solid var(--RadioGroup-border); + gap: 2px; + &__choice { position: relative; display: flex; align-items: center; justify-content: center; - width: 32px; + min-width: 20px; height: 24px; + padding: 0 0.375rem; color: var(--RadioGroup-choice-color-off); background: var(--RadioGroup-choice-background-off); diff --git a/packages/excalidraw/components/Range.scss b/packages/excalidraw/components/Range.scss index 8dcc705fea..7849763345 100644 --- a/packages/excalidraw/components/Range.scss +++ b/packages/excalidraw/components/Range.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { --slider-thumb-size: 16px; diff --git a/packages/excalidraw/components/Range.tsx b/packages/excalidraw/components/Range.tsx index 501ad7d188..7a4bf397e7 100644 --- a/packages/excalidraw/components/Range.tsx +++ b/packages/excalidraw/components/Range.tsx @@ -1,74 +1,78 @@ import React, { useEffect } from "react"; -import { t } from "../i18n"; - import "./Range.scss"; -import type { AppClassProperties } from "../types"; - export type RangeProps = { - updateData: (value: number) => void; - app: AppClassProperties; + label: React.ReactNode; + value: number; + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + minLabel?: React.ReactNode; + hasCommonValue?: boolean; testId?: string; }; -export const Range = ({ updateData, app, testId }: RangeProps) => { +export const Range = ({ + label, + value, + onChange, + min = 0, + max = 100, + step = 10, + minLabel = min, + hasCommonValue = true, + testId, +}: RangeProps) => { const rangeRef = React.useRef(null); const valueRef = React.useRef(null); - const selectedElements = app.scene.getSelectedElements(app.state); - let hasCommonOpacity = true; - const firstElement = selectedElements.at(0); - const leastCommonOpacity = selectedElements.reduce((acc, element) => { - if (acc != null && acc !== element.opacity) { - hasCommonOpacity = false; - } - if (acc == null || acc > element.opacity) { - return element.opacity; - } - return acc; - }, firstElement?.opacity ?? null); - - const value = leastCommonOpacity ?? app.state.currentItemOpacity; useEffect(() => { if (rangeRef.current && valueRef.current) { const rangeElement = rangeRef.current; const valueElement = valueRef.current; const inputWidth = rangeElement.offsetWidth; - const thumbWidth = 15; // 15 is the width of the thumb + const thumbWidth = + parseFloat( + getComputedStyle(rangeElement).getPropertyValue( + "--slider-thumb-size", + ), + ) || 16; + const progress = ((value - min) / (max - min || 1)) * 100; const position = - (value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; + (progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; valueElement.style.left = `${position}px`; - rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; + rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`; } - }, [value]); + }, [max, min, value]); return ( ); diff --git a/packages/excalidraw/components/SVGLayer.scss b/packages/excalidraw/components/SVGLayer.scss index 1ab5a6347b..e7121afd19 100644 --- a/packages/excalidraw/components/SVGLayer.scss +++ b/packages/excalidraw/components/SVGLayer.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .SVGLayer { diff --git a/packages/excalidraw/components/SearchMenu.scss b/packages/excalidraw/components/SearchMenu.scss index 4f9e36a668..707861c309 100644 --- a/packages/excalidraw/components/SearchMenu.scss +++ b/packages/excalidraw/components/SearchMenu.scss @@ -1,5 +1,4 @@ -@import "open-color/open-color"; -@import "../css//variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .layer-ui__search { diff --git a/packages/excalidraw/components/ShareableLinkDialog.scss b/packages/excalidraw/components/ShareableLinkDialog.scss index 2bdaaf0b78..f61ab1b825 100644 --- a/packages/excalidraw/components/ShareableLinkDialog.scss +++ b/packages/excalidraw/components/ShareableLinkDialog.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { .ShareableLinkDialog { diff --git a/packages/excalidraw/components/Sidebar/Sidebar.scss b/packages/excalidraw/components/Sidebar/Sidebar.scss index c7776d1c69..db26c14ffe 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.scss +++ b/packages/excalidraw/components/Sidebar/Sidebar.scss @@ -1,5 +1,4 @@ -@import "open-color/open-color"; -@import "../../css/variables.module.scss"; +@use "../../css/variables.module" as *; .excalidraw { .sidebar { @@ -9,7 +8,7 @@ top: 0; bottom: 0; right: 0; - z-index: 5; + z-index: var(--zIndex-ui-library); margin: 0; padding: 0; box-sizing: border-box; @@ -19,6 +18,12 @@ pointer-events: var(--ui-pointerEvents); + overflow: hidden; + border-radius: 0; + width: calc(var(--right-sidebar-width) - var(--space-factor) * 2); + + border-left: 1px solid var(--sidebar-border-color); + :root[dir="rtl"] & { left: 0; right: auto; @@ -28,12 +33,6 @@ box-shadow: none; } - overflow: hidden; - border-radius: 0; - width: calc(var(--right-sidebar-width) - var(--space-factor) * 2); - - border-left: 1px solid var(--sidebar-border-color); - :root[dir="rtl"] & { border-right: 1px solid var(--sidebar-border-color); border-left: 0; diff --git a/packages/excalidraw/components/Sidebar/Sidebar.tsx b/packages/excalidraw/components/Sidebar/Sidebar.tsx index d08ba5f597..8226eacef5 100644 --- a/packages/excalidraw/components/Sidebar/Sidebar.tsx +++ b/packages/excalidraw/components/Sidebar/Sidebar.tsx @@ -9,12 +9,18 @@ import React, { useCallback, } from "react"; -import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common"; +import { + CLASSES, + EVENT, + isDevEnv, + KEYS, + updateObject, +} from "@excalidraw/common"; import { useUIAppState } from "../../context/ui-appState"; import { atom, useSetAtom } from "../../editor-jotai"; import { useOutsideClick } from "../../hooks/useOutsideClick"; -import { useDevice, useExcalidrawSetAppState } from "../App"; +import { useEditorInterface, useExcalidrawSetAppState } from "../App"; import { Island } from "../Island"; import { SidebarHeader } from "./SidebarHeader"; @@ -90,7 +96,7 @@ export const SidebarInner = forwardRef( return islandRef.current!; }); - const device = useDevice(); + const editorInterface = useEditorInterface(); const closeLibrary = useCallback(() => { const isDialogOpen = !!document.querySelector(".Dialog"); @@ -111,11 +117,11 @@ export const SidebarInner = forwardRef( if ((event.target as Element).closest(".sidebar-trigger")) { return; } - if (!docked || !device.editor.canFitSidebar) { + if (!docked || !editorInterface.canFitSidebar) { closeLibrary(); } }, - [closeLibrary, docked, device.editor.canFitSidebar], + [closeLibrary, docked, editorInterface.canFitSidebar], ), ); @@ -123,7 +129,7 @@ export const SidebarInner = forwardRef( const handleKeyDown = (event: KeyboardEvent) => { if ( event.key === KEYS.ESCAPE && - (!docked || !device.editor.canFitSidebar) + (!docked || !editorInterface.canFitSidebar) ) { closeLibrary(); } @@ -132,12 +138,16 @@ export const SidebarInner = forwardRef( return () => { document.removeEventListener(EVENT.KEYDOWN, handleKeyDown); }; - }, [closeLibrary, docked, device.editor.canFitSidebar]); + }, [closeLibrary, docked, editorInterface.canFitSidebar]); return ( diff --git a/packages/excalidraw/components/Sidebar/SidebarHeader.tsx b/packages/excalidraw/components/Sidebar/SidebarHeader.tsx index 9c9e51cf53..5cfc7bca5e 100644 --- a/packages/excalidraw/components/Sidebar/SidebarHeader.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarHeader.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { useContext } from "react"; import { t } from "../../i18n"; -import { useDevice } from "../App"; +import { useEditorInterface } from "../App"; import { Button } from "../Button"; import { Tooltip } from "../Tooltip"; import { CloseIcon, PinIcon } from "../icons"; @@ -16,11 +16,11 @@ export const SidebarHeader = ({ children?: React.ReactNode; className?: string; }) => { - const device = useDevice(); + const editorInterface = useEditorInterface(); const props = useContext(SidebarPropsContext); const renderDockButton = !!( - device.editor.canFitSidebar && props.shouldRenderDockButton + editorInterface.canFitSidebar && props.shouldRenderDockButton ); return ( diff --git a/packages/excalidraw/components/Sidebar/SidebarTab.tsx b/packages/excalidraw/components/Sidebar/SidebarTab.tsx index 0525662f74..047f0c8a4c 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTab.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTab.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import type { SidebarTabName } from "../../types"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx index 9f2c09bcc5..e916babebe 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabTrigger.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import type { SidebarTabName } from "../../types"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx b/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx index 0be187b765..91eca1d9b5 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabTriggers.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; export const SidebarTabTriggers = ({ children, diff --git a/packages/excalidraw/components/Sidebar/SidebarTabs.tsx b/packages/excalidraw/components/Sidebar/SidebarTabs.tsx index 448840c4a1..de77a1b6ab 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTabs.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTabs.tsx @@ -1,4 +1,4 @@ -import * as RadixTabs from "@radix-ui/react-tabs"; +import { Tabs as RadixTabs } from "radix-ui"; import { useUIAppState } from "../../context/ui-appState"; import { useExcalidrawSetAppState } from "../App"; diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss index 5b003cdc5d..6deaa54d66 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.scss +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.scss @@ -1,4 +1,4 @@ -@import "../../css/variables.module.scss"; +@use "../../css/variables.module" as *; .excalidraw { .sidebar-trigger { diff --git a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx index 6e8bf374ce..706a6abe52 100644 --- a/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx +++ b/packages/excalidraw/components/Sidebar/SidebarTrigger.tsx @@ -30,7 +30,11 @@ export const SidebarTrigger = ({ .querySelector(".layer-ui__wrapper") ?.classList.remove("animate"); const isOpen = event.target.checked; - setAppState({ openSidebar: isOpen ? { name, tab } : null }); + setAppState({ + openSidebar: isOpen ? { name, tab } : null, + openMenu: null, + openPopup: null, + }); onToggle?.(isOpen); }} checked={appState.openSidebar?.name === name} diff --git a/packages/excalidraw/components/Spinner.scss b/packages/excalidraw/components/Spinner.scss index e2d90f8811..5f371b0494 100644 --- a/packages/excalidraw/components/Spinner.scss +++ b/packages/excalidraw/components/Spinner.scss @@ -1,4 +1,4 @@ -@import "open-color/open-color.scss"; +@use "../css/variables.module" as *; $duration: 1.6s; diff --git a/packages/excalidraw/components/Stats/Angle.tsx b/packages/excalidraw/components/Stats/Angle.tsx index 773f868880..c79e9bb3b1 100644 --- a/packages/excalidraw/components/Stats/Angle.tsx +++ b/packages/excalidraw/components/Stats/Angle.tsx @@ -34,6 +34,7 @@ const handleDegreeChange: DragInputCallbackType = ({ shouldChangeByStepSize, nextValue, scene, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -48,7 +49,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { @@ -74,7 +75,7 @@ const handleDegreeChange: DragInputCallbackType = ({ scene.mutateElement(latestElement, { angle: nextAngle, }); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, app.state); const boundTextElement = getBoundTextElement(latestElement, elementsMap); if (boundTextElement && !isArrowElement(latestElement)) { diff --git a/packages/excalidraw/components/Stats/MultiDimension.tsx b/packages/excalidraw/components/Stats/MultiDimension.tsx index 539a2ad59e..4680858dcd 100644 --- a/packages/excalidraw/components/Stats/MultiDimension.tsx +++ b/packages/excalidraw/components/Stats/MultiDimension.tsx @@ -94,9 +94,7 @@ const resizeElementInGroup = ( ); if (boundTextElement) { const newFontSize = boundTextElement.fontSize * scale; - updateBoundElements(latestElement, scene, { - newSize: { width: updates.width, height: updates.height }, - }); + updateBoundElements(latestElement, scene); const latestBoundTextElement = elementsMap.get(boundTextElement.id); if (latestBoundTextElement && isTextElement(latestBoundTextElement)) { scene.mutateElement(latestBoundTextElement, { diff --git a/packages/excalidraw/components/Stats/MultiPosition.tsx b/packages/excalidraw/components/Stats/MultiPosition.tsx index 19b52e2f49..35f6cfb897 100644 --- a/packages/excalidraw/components/Stats/MultiPosition.tsx +++ b/packages/excalidraw/components/Stats/MultiPosition.tsx @@ -38,6 +38,7 @@ const moveElements = ( originalElements: readonly ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { for (let i = 0; i < originalElements.length; i++) { const origElement = originalElements[i]; @@ -63,6 +64,7 @@ const moveElements = ( newTopLeftY, origElement, scene, + appState, originalElementsMap, false, ); @@ -75,6 +77,7 @@ const moveGroupTo = ( originalElements: ExcalidrawElement[], originalElementsMap: ElementsMap, scene: Scene, + appState: AppState, ) => { const elementsMap = scene.getNonDeletedElementsMap(); const [x1, y1, ,] = getCommonBounds(originalElements); @@ -107,6 +110,7 @@ const moveGroupTo = ( topLeftY + offsetY, origElement, scene, + appState, originalElementsMap, false, ); @@ -125,6 +129,7 @@ const handlePositionChange: DragInputCallbackType< property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); @@ -152,6 +157,7 @@ const handlePositionChange: DragInputCallbackType< elementsInUnit.map((el) => el.original), originalElementsMap, scene, + app.state, ); } else { const origElement = elementsInUnit[0]?.original; @@ -178,6 +184,7 @@ const handlePositionChange: DragInputCallbackType< newTopLeftY, origElement, scene, + app.state, originalElementsMap, false, ); @@ -203,6 +210,7 @@ const handlePositionChange: DragInputCallbackType< originalElements, originalElementsMap, scene, + app.state, ); scene.triggerUpdate(); diff --git a/packages/excalidraw/components/Stats/Position.tsx b/packages/excalidraw/components/Stats/Position.tsx index f89ce26151..8b57183308 100644 --- a/packages/excalidraw/components/Stats/Position.tsx +++ b/packages/excalidraw/components/Stats/Position.tsx @@ -34,6 +34,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ property, scene, originalAppState, + app, }) => { const elementsMap = scene.getNonDeletedElementsMap(); const origElement = originalElements[0]; @@ -131,6 +132,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); return; @@ -162,6 +164,7 @@ const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({ newTopLeftY, origElement, scene, + app.state, originalElementsMap, ); }; diff --git a/packages/excalidraw/components/Stats/index.tsx b/packages/excalidraw/components/Stats/index.tsx index 4b8754e88a..25c10ab73f 100644 --- a/packages/excalidraw/components/Stats/index.tsx +++ b/packages/excalidraw/components/Stats/index.tsx @@ -6,7 +6,7 @@ import { useEffect, useMemo, useState, memo } from "react"; import { STATS_PANELS } from "@excalidraw/common"; import { getCommonBounds } from "@excalidraw/element"; import { getUncroppedWidthAndHeight } from "@excalidraw/element"; -import { isElbowArrow, isImageElement } from "@excalidraw/element"; +import { isImageElement } from "@excalidraw/element"; import { frameAndChildrenSelectedTogether } from "@excalidraw/element"; @@ -334,16 +334,14 @@ export const StatsInner = memo( appState={appState} /> - {!isElbowArrow(singleElement) && ( - - - - )} + + + { mouse.up(200, 100); UI.clickTool("arrow"); - mouse.down(5, 0); + mouse.down(-5, 0); mouse.up(300, 50); elementStats = stats?.querySelector("#elementStats"); @@ -135,18 +135,7 @@ describe("binding with linear elements", () => { ) as HTMLInputElement; expect(linear.startBinding).not.toBe(null); expect(inputX).not.toBeNull(); - UI.updateInput(inputX, String("204")); - expect(linear.startBinding).not.toBe(null); - }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("1")); + UI.updateInput(inputX, String("184")); expect(linear.startBinding).not.toBe(null); }); @@ -161,17 +150,6 @@ describe("binding with linear elements", () => { UI.updateInput(inputX, String("254")); expect(linear.startBinding).toBe(null); }); - - it("should remain bound to linear element on small angle change", async () => { - const linear = h.elements[1] as ExcalidrawLinearElement; - const inputAngle = UI.queryStatsProperty("A")?.querySelector( - ".drag-input", - ) as HTMLInputElement; - - expect(linear.startBinding).not.toBe(null); - UI.updateInput(inputAngle, String("45")); - expect(linear.startBinding).toBe(null); - }); }); // single element @@ -383,12 +361,10 @@ describe("stats for a non-generic element", () => { mouse.clickAt(20, 30); const editor = await getTextEditor(); updateTextEditor(editor, "Hello!"); - act(() => { - editor.blur(); - }); + Keyboard.exitTextEditor(editor); const text = h.elements[0] as ExcalidrawTextElement; - mouse.clickOn(text); + API.setSelectedElements([text]); elementStats = stats?.querySelector("#elementStats"); @@ -774,7 +750,7 @@ describe("frame resizing behavior", () => { x: 0, y: 0, width: 100, - height: 100, + height: 103, }); // Create a rectangle outside the frame diff --git a/packages/excalidraw/components/Stats/utils.ts b/packages/excalidraw/components/Stats/utils.ts index 68d2020987..7628261840 100644 --- a/packages/excalidraw/components/Stats/utils.ts +++ b/packages/excalidraw/components/Stats/utils.ts @@ -1,6 +1,10 @@ import { pointFrom, pointRotateRads } from "@excalidraw/math"; -import { getBoundTextElement } from "@excalidraw/element"; +import { + getBoundTextElement, + isBindingElement, + unbindBindingElement, +} from "@excalidraw/element"; import { isFrameLikeElement } from "@excalidraw/element"; import { @@ -12,6 +16,7 @@ import { import { getFrameChildren } from "@excalidraw/element"; import { updateBindings } from "@excalidraw/element"; +import { DRAGGING_THRESHOLD } from "@excalidraw/common"; import type { Radians } from "@excalidraw/math"; @@ -110,9 +115,25 @@ export const moveElement = ( newTopLeftY: number, originalElement: ExcalidrawElement, scene: Scene, + appState: AppState, originalElementsMap: ElementsMap, shouldInformMutation = true, ) => { + if ( + isBindingElement(originalElement) && + (originalElement.startBinding || originalElement.endBinding) + ) { + if ( + Math.abs(newTopLeftX - originalElement.x) < DRAGGING_THRESHOLD && + Math.abs(newTopLeftY - originalElement.y) < DRAGGING_THRESHOLD + ) { + return; + } + + unbindBindingElement(originalElement, "start", scene); + unbindBindingElement(originalElement, "end", scene); + } + const elementsMap = scene.getNonDeletedElementsMap(); const latestElement = elementsMap.get(originalElement.id); if (!latestElement) { @@ -145,7 +166,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestElement, scene); + updateBindings(latestElement, scene, appState); const boundTextElement = getBoundTextElement( originalElement, @@ -203,7 +224,7 @@ export const moveElement = ( }, { informMutation: shouldInformMutation, isDragging: false }, ); - updateBindings(latestChildElement, scene, { + updateBindings(latestChildElement, scene, appState, { simultaneouslyUpdated: originalChildren, }); }); diff --git a/packages/excalidraw/components/Switch.scss b/packages/excalidraw/components/Switch.scss index 894f806060..3c56cd5fd1 100644 --- a/packages/excalidraw/components/Switch.scss +++ b/packages/excalidraw/components/Switch.scss @@ -1,4 +1,4 @@ -@import "../css/variables.module.scss"; +@use "../css/variables.module" as *; .excalidraw { --Switch-disabled-color: var(--color-border-outline); diff --git a/packages/excalidraw/components/TTDDialog/Chat/Chat.scss b/packages/excalidraw/components/TTDDialog/Chat/Chat.scss new file mode 100644 index 0000000000..d205d19a5a --- /dev/null +++ b/packages/excalidraw/components/TTDDialog/Chat/Chat.scss @@ -0,0 +1,403 @@ +@use "../../../css/variables.module.scss" as *; + +$verticalBreakpoint: 861px; + +.excalidraw { + &.theme--dark { + .chat-message { + &--assistant { + .chat-message__content { + background: var(--color-surface-lowest); + } + } + + &--system { + .chat-message__content { + color: var(--color-surface-low); + } + } + } + } + + .chat-interface { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + position: relative; + + @media screen and (max-width: $verticalBreakpoint) { + min-height: 200px; + } + + &__messages { + flex: 1 1 0; + overflow-y: auto; + margin-bottom: 0.5rem; + padding: 1rem 0.5rem 0 0.5rem; + display: flex; + flex-direction: column; + gap: 2rem; + min-height: 0; + border-top-left-radius: var(--border-radius-lg); + border-top-right-radius: var(--border-radius-lg); + + @media screen and (max-width: $verticalBreakpoint) { + min-height: 100px; + padding: 0.75rem; + } + } + + &__welcome-screen { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 200px; + + &__welcome-message { + text-align: center; + + h3 { + margin: 0 0 0.5rem 0; + font-size: 1.25rem; + font-weight: 600; + } + + p { + margin: 0 0 0.2rem 0; + font-size: 0.875rem; + color: var(--color-on-surface); + } + } + + @media screen and (max-width: $verticalBreakpoint) { + min-height: 100px; + } + } + + &__input-outer { + position: relative; + min-height: 71px; + } + + &__input-container { + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + &__input-wrapper { + position: absolute; + bottom: 0; + left: 0; + right: 0; + + display: flex; + align-items: flex-end; + gap: 0.5rem; + + border: 1px solid var(--dialog-border-color); + border-radius: var(--border-radius-lg); + + padding: 0.75rem; + background: var(--color-surface-lowest); + transition: border-color 0.2s ease; + + &:focus-within { + border-color: var(--color-primary); + } + + @media screen and (max-width: $verticalBreakpoint) { + padding: 0.5rem 0.75rem; + } + } + + &__input { + flex: 1; + border: none; + outline: none; + font-size: 0.875rem; + line-height: 1.5; + min-height: 24px; + max-height: 120px; + overflow-x: hidden; + border: none !important; + background: transparent !important; + color: var(--color-on-surface); + + &::placeholder { + color: var(--color-gray-40); + } + + @media screen and (max-width: $verticalBreakpoint) { + line-height: 1.4; + min-height: 20px; + max-height: 100px; + resize: none; + padding: 0; + + &::placeholder { + opacity: 0.6; + } + } + } + + &__send-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-surface-lowest); + cursor: pointer; + margin-bottom: 8px; + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; + } + + &:hover { + background-color: var(--color-primary-darker); + } + + svg { + transform: rotate(-90deg); + } + } + } + + .chat-message { + display: flex; + align-items: center; + gap: 0.5rem; + + &--user { + justify-content: flex-end; + + .chat-message__content { + background: var(--color-primary-light); + color: var(--text-primary-color); + border-radius: var(--border-radius-md); + min-width: 6rem; + } + } + + &--assistant { + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + + .chat-message__content { + background: var(--color-surface-low); + color: var(--color-on-surface); + border-radius: var(--border-radius-md); + min-width: 6rem; + + .chat-message__body { + font-family: monospace; + } + } + } + + &--system { + justify-content: flex-start; + margin-bottom: 0; + + .chat-message__content { + background: var(--color-warning); + color: var(--color-on-surface); + border-radius: var(--border-radius-md); + min-width: 6rem; + + .chat-message__body { + font-family: monospace; + } + } + } + + &__content { + max-width: 80%; + padding: 0.75rem 1rem; + box-shadow: var(--chat-msg-shadow); + } + + &__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; + font-size: 0.75rem; + } + + &__role { + font-weight: 600; + } + + &__timestamp { + font-size: 0.625rem; + } + + &__actions { + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__action { + display: inline-flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + padding: 0.25rem 0; + color: var(--color-gray-60); + transition: color 0.2s ease; + flex-shrink: 0; + + &.invisible { + visibility: hidden; + pointer-events: none; + } + + &:hover { + color: var(--color-primary); + } + + svg { + width: 14px; + height: 14px; + } + } + + &__action--danger { + &:hover { + color: var(--color-danger); + } + } + + &__body { + line-height: 1.5; + } + + &__text { + white-space: pre-wrap; + word-wrap: break-word; + } + + &__cursor { + display: inline-block; + margin-left: 2px; + color: currentColor; + animation: blink 1s infinite; + } + + &__loading { + display: flex; + align-items: center; + gap: 0.5rem; + } + + &__typing-indicator { + display: flex; + gap: 0.25rem; + + span { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.4; + animation: typing 1.4s infinite ease-in-out; + + &:nth-child(1) { + animation-delay: -0.32s; + } + &:nth-child(2) { + animation-delay: -0.16s; + } + &:nth-child(3) { + animation-delay: 0s; + } + } + } + + &__error { + color: var(--color-danger); + font-weight: 500; + white-space: pre-wrap; + word-wrap: break-word; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + &__error_message { + background: var(--color-warning-background); + color: var(--color-warning-color); + + padding: 0.5rem 0.75rem; + border-radius: var(--border-radius-md); + margin-top: 1rem; + font-size: 0.875rem; + } + + &__error-link { + display: flex; + align-items: center; + background: none; + border: none; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + padding: 0; + margin-top: 1rem; + text-decoration: underline; + font-family: inherit; + color: var(--link-color); + + &:hover { + color: var(--link-color-hover); + } + &:active { + color: var(--link-color-active); + } + + &:disabled { + cursor: not-allowed; + text-decoration: none; + } + } + } + + @keyframes typing { + 0%, + 80%, + 100% { + transform: scale(0.8); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } + } + + @keyframes blink { + 0%, + 50% { + opacity: 1; + } + 51%, + 100% { + opacity: 0; + } + } +} diff --git a/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx b/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx new file mode 100644 index 0000000000..87c1132500 --- /dev/null +++ b/packages/excalidraw/components/TTDDialog/Chat/ChatHistoryMenu.tsx @@ -0,0 +1,88 @@ +import clsx from "clsx"; + +import { t } from "../../../i18n"; +import { historyIcon, TrashIcon } from "../../icons"; +import DropdownMenu from "../../dropdownMenu/DropdownMenu"; + +import { FilledButton } from "../../FilledButton"; + +import type { SavedChat } from "../types"; + +interface ChatHistoryMenuProps { + isOpen: boolean; + onToggle: () => void; + onClose: () => void; + onNewChat: () => void; + onRestoreChat: (chat: SavedChat) => void; + onDeleteChat: (chatId: string, event: React.MouseEvent) => void; + savedChats: SavedChat[]; + activeSessionId: string; + disabled?: boolean; + isNewChatBtnVisible?: boolean; +} + +export const ChatHistoryMenu = ({ + isOpen, + onToggle, + onClose, + onNewChat, + onRestoreChat, + onDeleteChat, + isNewChatBtnVisible, + savedChats, + activeSessionId, + disabled, +}: ChatHistoryMenuProps) => { + return ( +
    + {isNewChatBtnVisible && ( + + {t("chat.newChat")} + + )} + {savedChats.length > 0 && ( +
    + + + {historyIcon} + + + <> + {savedChats.map((chat) => ( + { + onRestoreChat(chat); + }} + > + + {chat.title} + + + + ))} + + + +
    + )} +
    + ); +}; diff --git a/packages/excalidraw/components/TTDDialog/Chat/ChatInterface.tsx b/packages/excalidraw/components/TTDDialog/Chat/ChatInterface.tsx new file mode 100644 index 0000000000..1f0a302351 --- /dev/null +++ b/packages/excalidraw/components/TTDDialog/Chat/ChatInterface.tsx @@ -0,0 +1,191 @@ +import React, { useRef, useEffect, useLayoutEffect } from "react"; +import { KEYS } from "@excalidraw/common"; + +import { ArrowRightIcon, stop as StopIcon } from "../../icons"; +import { InlineIcon } from "../../InlineIcon"; + +import { t } from "../../../i18n"; + +import { TTDWelcomeMessage } from "../TTDWelcomeMessage"; + +import { ChatMessage } from "./ChatMessage"; + +import type { TChat, TTTDDialog } from "../types"; + +import type { FormEventHandler } from "react"; + +export const ChatInterface = ({ + chatId, + messages, + currentPrompt, + onPromptChange, + onGenerate, + isGenerating, + rateLimits, + onAbort, + onMermaidTabClick, + onAiRepairClick, + onDeleteMessage, + onInsertMessage, + onRetry, + renderWelcomeScreen, + renderWarning, +}: { + chatId: string; + messages: TChat.ChatMessage[]; + currentPrompt: string; + onPromptChange: (prompt: string) => void; + onGenerate: TTTDDialog.OnGenerate; + isGenerating: boolean; + rateLimits?: { + rateLimit: number; + rateLimitRemaining: number; + } | null; + onViewAsMermaid?: () => void; + generatedResponse?: string | null; + onAbort?: () => void; + onMermaidTabClick?: (message: TChat.ChatMessage) => void; + onAiRepairClick?: (message: TChat.ChatMessage) => void; + onDeleteMessage?: (messageId: string) => void; + onInsertMessage?: (message: TChat.ChatMessage) => void; + onRetry?: (message: TChat.ChatMessage) => void; + renderWelcomeScreen?: TTTDDialog.renderWelcomeScreen; + renderWarning?: TTTDDialog.renderWarning; +}) => { + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + + useLayoutEffect(() => { + messagesEndRef.current?.scrollIntoView(); + }, [messages]); + + useEffect(() => { + if (textareaRef.current) { + textareaRef.current.focus(); + } + }, [chatId]); + + const handleInputChange = (event: React.ChangeEvent) => { + const value = event.target.value; + onPromptChange(value); + }; + + const handleSubmit = () => { + if (isGenerating && onAbort) { + onAbort(); + return; + } + + const trimmedPrompt = currentPrompt.trim(); + if (!trimmedPrompt) { + return; + } + + onGenerate({ prompt: trimmedPrompt }); + onPromptChange(""); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === KEYS.ENTER && !event.shiftKey) { + event.preventDefault(); + if (!isGenerating) { + handleSubmit(); + } + } + }; + + const canSend = + currentPrompt.trim().length > 3 && + !isGenerating && + (rateLimits?.rateLimitRemaining ?? 1) > 0; + + const canStop = isGenerating && !!onAbort; + + const onInput: FormEventHandler = (ev) => { + const target = ev.target as HTMLTextAreaElement; + target.style.height = "auto"; + target.style.height = `${Math.min(target.scrollHeight, 120)}px`; + }; + + return ( +
    +
    + {messages.length === 0 ? ( +
    + {renderWelcomeScreen ? ( + renderWelcomeScreen({ rateLimits: rateLimits ?? null }) + ) : ( + + )} +
    + ) : ( + messages.map((message, index) => ( + + )) + )} +
    +
    + +
    +
    +
    +
    Ctrl
    Enter
    " -`; +exports[`Test > should open mermaid popup when active tool is mermaid 1`] = `""`; exports[`Test > should show error in preview when mermaid library throws error 1`] = ` "flowchart TD diff --git a/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap index 868e27e842..5fa9fb7e35 100644 --- a/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/charts.test.tsx.snap @@ -2,19 +2,24 @@ exports[`tryParseSpreadsheet > works for numbers with comma in them 1`] = ` { - "spreadsheet": { + "data": { "labels": [ "Week 1", "Week 2", "Week 3", ], - "title": "Users", - "values": [ - 814, - 10301, - 4264, + "series": [ + { + "title": "Users", + "values": [ + 814, + 10301, + 4264, + ], + }, ], + "title": "Users", }, - "type": "VALID_SPREADSHEET", + "ok": true, } `; diff --git a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap index e7c3c68d32..cb548f5b62 100644 --- a/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/contextmenu.test.tsx.snap @@ -11,6 +11,9 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -888,7 +891,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "top": 40, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -932,6 +934,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -950,12 +953,12 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -981,7 +984,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1082,10 +1085,12 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1129,6 +1134,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1144,12 +1150,12 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1172,7 +1178,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Added to library", @@ -1213,7 +1219,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 2019559783, + "versionNonce": 401146281, "width": 10, "x": -20, "y": -10, @@ -1294,10 +1300,12 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1341,6 +1349,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1356,12 +1365,12 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1384,7 +1393,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1416,14 +1425,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -1448,14 +1457,14 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings "opacity": 100, "roughness": 1, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 915032327, + "versionNonce": 493213705, "width": 20, "x": -10, "y": 0, @@ -1623,10 +1632,12 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1670,6 +1681,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -1685,12 +1697,12 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -1713,7 +1725,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -1745,14 +1757,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1505387817, + "versionNonce": 1604849351, "width": 20, "x": 20, "y": 30, @@ -1777,14 +1789,14 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings "opacity": 100, "roughness": 1, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 915032327, + "versionNonce": 493213705, "width": 20, "x": -10, "y": 0, @@ -1952,10 +1964,12 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -1999,6 +2013,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2014,12 +2029,12 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2042,7 +2057,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Copied styles.", @@ -2083,7 +2098,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 2019559783, + "versionNonce": 401146281, "width": 10, "x": -20, "y": -10, @@ -2164,10 +2179,12 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2211,6 +2228,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2226,12 +2244,12 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2252,7 +2270,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2291,7 +2309,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1014066025, + "versionNonce": 1116226695, "width": 10, "x": -20, "y": -10, @@ -2403,10 +2421,12 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2450,6 +2470,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2465,12 +2486,12 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -2493,7 +2514,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2532,7 +2553,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 2019559783, + "versionNonce": 401146281, "width": 10, "x": -20, "y": -10, @@ -2557,14 +2578,14 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 5, - "versionNonce": 1604849351, + "versionNonce": 400692809, "width": 10, "x": -10, "y": 0, @@ -2699,10 +2720,12 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -2746,6 +2769,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -2761,12 +2785,12 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -2794,7 +2818,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -2828,14 +2852,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "opacity": 100, "roughness": 1, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 81784553, + "versionNonce": 915032327, "width": 20, "x": -10, "y": 0, @@ -2862,14 +2886,14 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 747212839, + "versionNonce": 81784553, "width": 20, "x": 20, "y": 30, @@ -3069,10 +3093,12 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "#a5d8ff", @@ -3116,6 +3142,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3131,12 +3158,12 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3159,7 +3186,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": { "message": "Copied styles.", @@ -3193,14 +3220,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "opacity": 60, "roughness": 2, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 1359939303, + "versionNonce": 1402203177, "width": 20, "x": -10, "y": 0, @@ -3225,14 +3252,14 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s "opacity": 60, "roughness": 2, "roundness": null, - "seed": 640725609, + "seed": 1898319239, "strokeColor": "#e03131", "strokeStyle": "dotted", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 9, - "versionNonce": 908564423, + "versionNonce": 941653321, "width": 20, "x": 20, "y": 30, @@ -3560,10 +3587,12 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3607,6 +3636,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3622,12 +3652,12 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3650,7 +3680,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -3682,14 +3712,14 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "opacity": 100, "roughness": 1, "roundness": null, - "seed": 1116226695, + "seed": 238820263, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 23633383, + "versionNonce": 915032327, "width": 20, "x": 20, "y": 30, @@ -3721,7 +3751,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 401146281, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -3881,10 +3911,12 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -3928,6 +3960,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -3943,12 +3976,12 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -3971,7 +4004,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4003,14 +4036,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "opacity": 100, "roughness": 1, "roundness": null, - "seed": 238820263, + "seed": 1014066025, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 4, - "versionNonce": 915032327, + "versionNonce": 493213705, "width": 20, "x": 20, "y": 30, @@ -4035,14 +4068,14 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el "opacity": 100, "roughness": 1, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1150084233, + "versionNonce": 2019559783, "width": 20, "x": -10, "y": 0, @@ -4202,10 +4235,12 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": null, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -4249,6 +4284,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -4264,12 +4300,12 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id3": true, }, @@ -4295,7 +4331,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -4327,7 +4363,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung "opacity": 100, "roughness": 1, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -4611,6 +4647,9 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -5488,7 +5527,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -5532,6 +5570,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -5547,12 +5586,12 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -5578,7 +5617,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -5610,14 +5649,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "opacity": 100, "roughness": 1, "roundness": null, - "seed": 453191, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 1014066025, + "versionNonce": 1150084233, "width": 10, "x": -10, "y": 0, @@ -5642,14 +5681,14 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi "opacity": 100, "roughness": 1, "roundness": null, - "seed": 1505387817, + "seed": 400692809, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 915032327, + "versionNonce": 23633383, "width": 10, "x": 12, "y": 0, @@ -5826,6 +5865,9 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -6703,7 +6745,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -6747,6 +6788,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -6762,12 +6804,12 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": { "id0": true, }, @@ -6795,7 +6837,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -6829,7 +6871,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro "opacity": 100, "roughness": 1, "roundness": null, - "seed": 449462985, + "seed": 1278240551, "strokeColor": "#1e1e1e", "strokeStyle": "solid", "strokeWidth": 2, @@ -7092,6 +7134,9 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -7467,6 +7512,28 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app }, "viewMode": false, }, + { + "checked": [Function], + "label": "labels.arrowBinding", + "name": "arrowBinding", + "perform": [Function], + "trackEvent": { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": false, + }, + { + "checked": [Function], + "label": "labels.midpointSnapping", + "name": "midpointSnapping", + "perform": [Function], + "trackEvent": { + "category": "canvas", + "predicate": [Function], + }, + "viewMode": false, + }, { "checked": [Function], "icon": shows context menu for canvas > [end of test] app "top": -9, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -7680,6 +7746,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -7698,12 +7765,12 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -7724,7 +7791,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -7757,6 +7824,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -8634,7 +8704,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "top": -7, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -8678,6 +8747,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -8693,12 +8763,12 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "openPopup": null, "openSidebar": null, "originSnapOffset": null, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -8721,7 +8791,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -8746,6 +8816,9 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "locked": false, "type": "selection", }, + "bindMode": "orbit", + "bindingPreference": "enabled", + "boxSelectionMode": "contain", "collaborators": Map {}, "contextMenu": { "items": [ @@ -9623,7 +9696,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "top": 90, }, "croppingElementId": null, - "currentChartType": "bar", "currentHoveredFontFamily": null, "currentItemArrowType": "round", "currentItemBackgroundColor": "transparent", @@ -9667,6 +9739,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "isBindingEnabled": true, "isCropping": false, "isLoading": false, + "isMidpointSnappingEnabled": true, "isResizing": false, "isRotating": false, "lastPointerDownWith": "mouse", @@ -9685,12 +9758,12 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "x": 0, "y": 0, }, - "pasteDialog": { - "data": null, - "shown": false, - }, "penDetected": false, "penMode": false, + "preferredSelectionTool": { + "initialized": true, + "type": "selection", + }, "previousSelectedElementIds": {}, "resizingElement": null, "scrollX": 0, @@ -9713,7 +9786,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap "open": false, "panels": 3, }, - "suggestedBindings": [], + "suggestedBinding": null, "theme": "light", "toast": null, "userToFollow": null, @@ -9752,7 +9825,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] el "type": "rectangle", "updated": 1, "version": 3, - "versionNonce": 2019559783, + "versionNonce": 401146281, "width": 10, "x": -20, "y": -10, diff --git a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap index c25b269f4b..a538500c25 100644 --- a/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/dragCreate.test.tsx.snap @@ -18,7 +18,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, @@ -135,7 +134,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e "id": "id0", "index": "a0", "isDeleted": false, - "lastCommittedPoint": null, "link": null, "locked": false, "opacity": 100, diff --git a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap index ae4728e0c7..2ed8bc1c53 100644 --- a/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/excalidraw.test.tsx.snap @@ -2,15 +2,31 @@ exports[` > > should render main menu with host menu items if passed from host 1`] = `