mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
2 Commits
v0.14.2
...
zsviczian-
Author | SHA1 | Date | |
---|---|---|---|
4c39abf07f | |||
02c1236962 |
@ -1,6 +1,5 @@
|
||||
*
|
||||
!.env.development
|
||||
!.env.production
|
||||
!.env
|
||||
!.eslintrc.json
|
||||
!.npmrc
|
||||
!.prettierrc
|
||||
|
@ -20,5 +20,3 @@ REACT_APP_DEV_ENABLE_SW=
|
||||
# whether to disable live reload / HMR. Usuaully what you want to do when
|
||||
# debugging Service Workers.
|
||||
REACT_APP_DEV_DISABLE_LIVE_RELOAD=
|
||||
|
||||
FAST_REFRESH=false
|
||||
|
2
.github/workflows/autorelease-excalidraw.yml
vendored
2
.github/workflows/autorelease-excalidraw.yml
vendored
@ -2,7 +2,7 @@ name: Auto release excalidraw next
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
- master
|
||||
|
||||
jobs:
|
||||
Auto-release-excalidraw-next:
|
||||
|
2
.github/workflows/build-docker.yml
vendored
2
.github/workflows/build-docker.yml
vendored
@ -3,7 +3,7 @@ name: Build Docker image
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build-docker:
|
||||
|
2
.github/workflows/cancel.yml
vendored
2
.github/workflows/cancel.yml
vendored
@ -3,7 +3,7 @@ name: Cancel previous runs
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
2
.github/workflows/publish-docker.yml
vendored
2
.github/workflows/publish-docker.yml
vendored
@ -3,7 +3,7 @@ name: Publish Docker
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
- master
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
|
2
.github/workflows/sentry-production.yml
vendored
2
.github/workflows/sentry-production.yml
vendored
@ -3,7 +3,7 @@ name: New Sentry production release
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
- master
|
||||
|
||||
jobs:
|
||||
sentry:
|
||||
|
@ -1,2 +1,2 @@
|
||||
#!/bin/sh
|
||||
# yarn lint-staged
|
||||
yarn lint-staged
|
||||
|
@ -4692,9 +4692,9 @@ json-schema-traverse@^1.0.0:
|
||||
integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
|
||||
|
||||
json5@^2.1.2, json5@^2.2.1:
|
||||
version "2.2.3"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283"
|
||||
integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
|
||||
integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==
|
||||
|
||||
jsonfile@^6.0.1:
|
||||
version "6.1.0"
|
||||
@ -4755,9 +4755,9 @@ loader-runner@^4.2.0:
|
||||
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
|
||||
|
||||
loader-utils@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
|
||||
integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.2.tgz#d6e3b4fb81870721ae4e0868ab11dd638368c129"
|
||||
integrity sha512-TM57VeHptv569d/GKh6TAYdzKblwDNiumOdkFnejjD0XwTH87K90w3O7AiJRqdQoXygvi1VQTJTLGhJl7WqA7A==
|
||||
dependencies:
|
||||
big.js "^5.2.2"
|
||||
emojis-list "^3.0.0"
|
||||
|
28
package.json
28
package.json
@ -19,7 +19,6 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@dwelle/tunnel-rat": "0.1.1",
|
||||
"@sentry/browser": "6.2.5",
|
||||
"@sentry/integrations": "6.2.5",
|
||||
"@testing-library/jest-dom": "5.16.2",
|
||||
@ -32,7 +31,6 @@
|
||||
"@types/socket.io-client": "1.4.36",
|
||||
"browser-fs-access": "0.29.1",
|
||||
"clsx": "1.1.1",
|
||||
"cross-env": "7.0.3",
|
||||
"fake-indexeddb": "3.1.7",
|
||||
"firebase": "8.3.3",
|
||||
"i18next-browser-languagedetector": "6.1.4",
|
||||
@ -52,24 +50,11 @@
|
||||
"pwacompat": "2.0.17",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-scripts": "4.0.3",
|
||||
"roughjs": "4.5.2",
|
||||
"sass": "1.51.0",
|
||||
"socket.io-client": "2.3.1",
|
||||
"tunnel-rat": "0.1.0",
|
||||
"typescript": "4.9.4",
|
||||
"workbox-background-sync": "^6.5.4",
|
||||
"workbox-broadcast-update": "^6.5.4",
|
||||
"workbox-cacheable-response": "^6.5.4",
|
||||
"workbox-core": "^6.5.4",
|
||||
"workbox-expiration": "^6.5.4",
|
||||
"workbox-google-analytics": "^6.5.4",
|
||||
"workbox-navigation-preload": "^6.5.4",
|
||||
"workbox-precaching": "^6.5.4",
|
||||
"workbox-range-requests": "^6.5.4",
|
||||
"workbox-routing": "^6.5.4",
|
||||
"workbox-strategies": "^6.5.4",
|
||||
"workbox-streams": "^6.5.4"
|
||||
"typescript": "4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@excalidraw/eslint-config": "1.0.0",
|
||||
@ -82,7 +67,6 @@
|
||||
"dotenv": "16.0.1",
|
||||
"eslint-config-prettier": "8.5.0",
|
||||
"eslint-plugin-prettier": "3.3.1",
|
||||
"http-server": "14.1.1",
|
||||
"husky": "7.0.4",
|
||||
"jest-canvas-mock": "2.4.0",
|
||||
"lint-staged": "12.3.7",
|
||||
@ -105,10 +89,11 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-node": "node ./scripts/build-node.js",
|
||||
"build:app:docker": "cross-env REACT_APP_DISABLE_SENTRY=true REACT_APP_DISABLE_TRACKING=true react-scripts build",
|
||||
"build:app": "cross-env REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:app:docker": "REACT_APP_DISABLE_SENTRY=true react-scripts build",
|
||||
"build:app": "REACT_APP_GIT_SHA=$VERCEL_GIT_COMMIT_SHA react-scripts build",
|
||||
"build:version": "node ./scripts/build-version.js",
|
||||
"build": "yarn build:app && yarn build:version",
|
||||
"build:prebuild": "node ./scripts/prebuild.js",
|
||||
"build": "yarn build:prebuild && yarn build:app && yarn build:version",
|
||||
"eject": "react-scripts eject",
|
||||
"fix:code": "yarn test:code --fix",
|
||||
"fix:other": "yarn prettier --write",
|
||||
@ -118,7 +103,6 @@
|
||||
"prepare": "husky install",
|
||||
"prettier": "prettier \"**/*.{css,scss,json,md,html,yml}\" --ignore-path=.eslintignore",
|
||||
"start": "react-scripts start",
|
||||
"start:production": "npm run build && npx http-server build -a localhost -p 5001 -o",
|
||||
"test:all": "yarn test:typecheck && yarn test:code && yarn test:other && yarn test:app --watchAll=false",
|
||||
"test:app": "react-scripts test --passWithNoTests",
|
||||
"test:code": "eslint --max-warnings=0 --ext .js,.ts,.tsx .",
|
||||
|
@ -146,8 +146,7 @@
|
||||
// setting this so that libraries installation reuses this window tab.
|
||||
window.name = "_excalidraw";
|
||||
</script>
|
||||
<% if (process.env.REACT_APP_DISABLE_TRACKING !== 'true' &&
|
||||
process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<% if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID) { %>
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=%REACT_APP_GOOGLE_ANALYTICS_ID%"
|
||||
@ -167,6 +166,9 @@
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system,
|
||||
Segoe UI, Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
|
||||
width: 100%;
|
||||
|
81
public/service-worker.js
Normal file
81
public/service-worker.js
Normal file
@ -0,0 +1,81 @@
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
// eslint-disable-next-line no-unused-expressions
|
||||
|
||||
/* eslint-disable no-restricted-globals */
|
||||
/* global importScripts, workbox */
|
||||
|
||||
/**
|
||||
* Welcome to your Workbox-powered service worker!
|
||||
*
|
||||
* You'll need to register this file in your web app and you should
|
||||
* disable HTTP caching for this file too.
|
||||
* See https://goo.gl/nhQhGp
|
||||
*
|
||||
* The rest of the code is auto-generated. Please don't update this file
|
||||
* directly; instead, make changes to your Workbox build configuration
|
||||
* and re-run your build process.
|
||||
* See https://goo.gl/2aRDsh
|
||||
*/
|
||||
|
||||
// in dev, `process` is undefined because this file is not compiled until build
|
||||
const IS_DEVELOPMENT =
|
||||
typeof process === "undefined" || process.env.NODE_ENV !== "production";
|
||||
|
||||
if (IS_DEVELOPMENT) {
|
||||
importScripts(
|
||||
"https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js",
|
||||
);
|
||||
workbox.setConfig({
|
||||
debug: true,
|
||||
});
|
||||
} else {
|
||||
importScripts("/workbox/workbox-sw.js");
|
||||
workbox.setConfig({
|
||||
modulePathPrefix: "/workbox/",
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data && event.data.type === "SKIP_WAITING") {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
|
||||
workbox.core.clientsClaim();
|
||||
|
||||
if (!IS_DEVELOPMENT) {
|
||||
workbox.precaching.precacheAndRoute(self.__WB_MANIFEST);
|
||||
|
||||
workbox.routing.registerNavigationRoute(
|
||||
workbox.precaching.getCacheKeyForURL("./index.html"),
|
||||
{
|
||||
blacklist: [/^\/_/, /\/[^/?]+\.[^/]+$/],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Cache relevant font files
|
||||
workbox.routing.registerRoute(
|
||||
new RegExp("/(fonts.css|.+.(ttf|woff2|otf))"),
|
||||
new workbox.strategies.StaleWhileRevalidate({
|
||||
cacheName: "fonts",
|
||||
plugins: [new workbox.expiration.Plugin({ maxEntries: 10 })],
|
||||
}),
|
||||
);
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (
|
||||
event.request.method === "POST" &&
|
||||
event.request.url.endsWith("/web-share-target")
|
||||
) {
|
||||
return event.respondWith(
|
||||
(async () => {
|
||||
const formData = await event.request.formData();
|
||||
const file = formData.get("file");
|
||||
const webShareTargetCache = await caches.open("web-share-target");
|
||||
await webShareTargetCache.put("shared-file", new Response(file));
|
||||
return Response.redirect("/?web-share-target", 303);
|
||||
})(),
|
||||
);
|
||||
}
|
||||
});
|
@ -50,8 +50,8 @@ const crowdinMap = {
|
||||
"lv-LV": "en-lv",
|
||||
"cs-CZ": "en-cs",
|
||||
"kk-KZ": "en-kk",
|
||||
"vi-VN": "en-vi",
|
||||
"mr-IN": "en-mr",
|
||||
"vi-vn": "en-vi",
|
||||
"mr-in": "en-mr",
|
||||
};
|
||||
|
||||
const flags = {
|
||||
@ -120,7 +120,6 @@ const languages = {
|
||||
"fa-IR": "فارسی",
|
||||
"fi-FI": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"gl-ES": "Galego",
|
||||
"he-IL": "עברית",
|
||||
"hi-IN": "हिन्दी",
|
||||
"hu-HU": "Magyar",
|
||||
@ -130,7 +129,6 @@ const languages = {
|
||||
"kab-KAB": "Taqbaylit",
|
||||
"kk-KZ": "Қазақ тілі",
|
||||
"ko-KR": "한국어",
|
||||
"ku-TR": "Kurdî",
|
||||
"lt-LT": "Lietuvių",
|
||||
"lv-LV": "Latviešu",
|
||||
"my-MM": "Burmese",
|
||||
|
21
scripts/prebuild.js
Normal file
21
scripts/prebuild.js
Normal file
@ -0,0 +1,21 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// for development purposes we want to have the service-worker.js file
|
||||
// accessible from the public folder. On build though, we need to compile it
|
||||
// and CRA expects that file to be in src/ folder.
|
||||
const moveServiceWorkerScript = () => {
|
||||
const oldPath = path.resolve(__dirname, "../public/service-worker.js");
|
||||
const newPath = path.resolve(__dirname, "../src/service-worker.js");
|
||||
|
||||
fs.rename(oldPath, newPath, (error) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
console.info("public/service-worker.js moved to src/");
|
||||
});
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
moveServiceWorkerScript();
|
@ -6,10 +6,6 @@ import {
|
||||
measureText,
|
||||
redrawTextBoundingBox,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
resetOriginalContainerCache,
|
||||
} from "../element/textWysiwyg";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isTextBindableContainer,
|
||||
@ -26,7 +22,7 @@ export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
@ -42,11 +38,6 @@ export const actionUnbindText = register({
|
||||
boundTextElement.originalText,
|
||||
getFontString(boundTextElement),
|
||||
);
|
||||
const originalContainerHeight = getOriginalContainerHeightFromCache(
|
||||
element.id,
|
||||
);
|
||||
resetOriginalContainerCache(element.id);
|
||||
|
||||
mutateElement(boundTextElement as ExcalidrawTextElement, {
|
||||
containerId: null,
|
||||
width,
|
||||
@ -58,9 +49,6 @@ export const actionUnbindText = register({
|
||||
boundElements: element.boundElements?.filter(
|
||||
(ele) => ele.id !== boundTextElement.id,
|
||||
),
|
||||
height: originalContainerHeight
|
||||
? originalContainerHeight
|
||||
: element.height,
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -76,7 +64,7 @@ export const actionBindText = register({
|
||||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
|
@ -1,7 +1,13 @@
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
import { ZoomInIcon, ZoomOutIcon } from "../components/icons";
|
||||
import {
|
||||
eraser,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { CURSOR_TYPE, MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { MIN_ZOOM, THEME, ZOOM_STEP } from "../constants";
|
||||
import { getCommonBounds, getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
@ -10,25 +16,19 @@ import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
import { getShortcutKey, setCursor, updateActiveTool } from "../utils";
|
||||
import { getShortcutKey, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getDefaultAppState,
|
||||
isEraserActive,
|
||||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { getDefaultAppState, isEraserActive } from "../appState";
|
||||
import ClearCanvas from "../components/ClearCanvas";
|
||||
import clsx from "clsx";
|
||||
import MenuItem from "../components/MenuItem";
|
||||
import { getShortcutFromShortcutName } from "./shortcuts";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
trackEvent: false,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.changeViewBackgroundColor &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
appState: { ...appState, ...value },
|
||||
@ -36,7 +36,6 @@ export const actionChangeViewBackgroundColor = register({
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ColorPicker
|
||||
@ -60,12 +59,6 @@ export const actionChangeViewBackgroundColor = register({
|
||||
export const actionClearCanvas = register({
|
||||
name: "clearCanvas",
|
||||
trackEvent: { category: "canvas" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.clearCanvas &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
app.imageCache.clear();
|
||||
return {
|
||||
@ -91,11 +84,12 @@ export const actionClearCanvas = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
||||
PanelComponent: ({ updateData }) => <ClearCanvas onConfirm={updateData} />,
|
||||
});
|
||||
|
||||
export const actionZoomIn = register({
|
||||
name: "zoomIn",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
@ -132,7 +126,6 @@ export const actionZoomIn = register({
|
||||
|
||||
export const actionZoomOut = register({
|
||||
name: "zoomOut",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
@ -169,7 +162,6 @@ export const actionZoomOut = register({
|
||||
|
||||
export const actionResetZoom = register({
|
||||
name: "resetZoom",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_elements, appState, _, app) => {
|
||||
return {
|
||||
@ -279,7 +271,6 @@ export const actionZoomToSelected = register({
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
keyTest: (event) =>
|
||||
@ -291,7 +282,6 @@ export const actionZoomToFit = register({
|
||||
|
||||
export const actionToggleTheme = register({
|
||||
name: "toggleTheme",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (_, appState, value) => {
|
||||
return {
|
||||
@ -303,21 +293,33 @@ export const actionToggleTheme = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<MenuItem
|
||||
label={
|
||||
appState.theme === "dark"
|
||||
? t("buttons.lightMode")
|
||||
: t("buttons.darkMode")
|
||||
}
|
||||
onClick={() => {
|
||||
updateData(appState.theme === THEME.LIGHT ? THEME.DARK : THEME.LIGHT);
|
||||
}}
|
||||
icon={appState.theme === "dark" ? SunIcon : MoonIcon}
|
||||
dataTestId="toggle-dark-mode"
|
||||
shortcut={getShortcutFromShortcutName("toggleTheme")}
|
||||
/>
|
||||
),
|
||||
keyTest: (event) => event.altKey && event.shiftKey && event.code === CODES.D,
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return !!app.props.UIOptions.canvasActions.toggleTheme;
|
||||
},
|
||||
});
|
||||
|
||||
export const actionToggleEraserTool = register({
|
||||
name: "toggleEraserTool",
|
||||
export const actionErase = register({
|
||||
name: "eraser",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
@ -340,38 +342,17 @@ export const actionToggleEraserTool = register({
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.E,
|
||||
});
|
||||
|
||||
export const actionToggleHandTool = register({
|
||||
name: "toggleHandTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
if (isHandToolActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: "hand",
|
||||
lastActiveToolBeforeEraser: appState.activeTool,
|
||||
});
|
||||
setCursor(app.canvas, CURSOR_TYPE.GRAB);
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.H,
|
||||
PanelComponent: ({ elements, appState, updateData, data }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={eraser}
|
||||
className={clsx("eraser", { active: isEraserActive(appState) })}
|
||||
title={`${t("toolBar.eraser")}-${getShortcutKey("E")}`}
|
||||
aria-label={t("toolBar.eraser")}
|
||||
onClick={() => {
|
||||
updateData(null);
|
||||
}}
|
||||
size={data?.size || "medium"}
|
||||
></ToolButton>
|
||||
),
|
||||
});
|
||||
|
@ -3,7 +3,6 @@ import { register } from "./register";
|
||||
import {
|
||||
copyTextToSystemClipboard,
|
||||
copyToClipboard,
|
||||
probablySupportsClipboardBlob,
|
||||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
@ -24,31 +23,11 @@ export const actionCopy = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.copy",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionPaste = register({
|
||||
name: "paste",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements: any, appStates: any, data, app) => {
|
||||
app.pasteFromClipboard(null);
|
||||
return {
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.paste",
|
||||
// don't supply a shortcut since we handle this conditionally via onCopy event
|
||||
keyTest: undefined,
|
||||
});
|
||||
|
||||
export const actionCut = register({
|
||||
name: "cut",
|
||||
trackEvent: { category: "element" },
|
||||
@ -56,9 +35,6 @@ export const actionCut = register({
|
||||
actionCopy.perform(elements, appState, data, app);
|
||||
return actionDeleteSelected.perform(elements, appState);
|
||||
},
|
||||
predicate: (elements, appState, appProps, app) => {
|
||||
return app.device.isMobile && !!navigator.clipboard;
|
||||
},
|
||||
contextItemLabel: "labels.cut",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
|
||||
});
|
||||
@ -101,9 +77,6 @@ export const actionCopyAsSvg = register({
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardWriteText && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsSvg",
|
||||
});
|
||||
|
||||
@ -158,9 +131,6 @@ export const actionCopyAsPng = register({
|
||||
};
|
||||
}
|
||||
},
|
||||
predicate: (elements) => {
|
||||
return probablySupportsClipboardBlob && elements.length > 0;
|
||||
},
|
||||
contextItemLabel: "labels.copyAsPng",
|
||||
keyTest: (event) => event.code === CODES.C && event.altKey && event.shiftKey,
|
||||
});
|
||||
@ -188,7 +158,7 @@ export const copyText = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState) => {
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { questionCircle, saveAs } from "../components/icons";
|
||||
import { LoadIcon, questionCircle, saveAs } from "../components/icons";
|
||||
import { ProjectName } from "../components/ProjectName";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import "../components/ToolIcon.scss";
|
||||
import { Tooltip } from "../components/Tooltip";
|
||||
import { DarkModeToggle } from "../components/DarkModeToggle";
|
||||
import { loadFromJSON, saveAsJSON } from "../data";
|
||||
@ -14,11 +15,12 @@ import { getExportSize } from "../scene/export";
|
||||
import { DEFAULT_EXPORT_PADDING, EXPORT_SCALES, THEME } from "../constants";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ActiveFile } from "../components/ActiveFile";
|
||||
import { isImageFileHandle } from "../data/blob";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { Theme } from "../element/types";
|
||||
|
||||
import "../components/ToolIcon.scss";
|
||||
import MenuItem from "../components/MenuItem";
|
||||
import { getShortcutFromShortcutName } from "./shortcuts";
|
||||
|
||||
export const actionChangeProjectName = register({
|
||||
name: "changeProjectName",
|
||||
@ -131,13 +133,6 @@ export const actionChangeExportEmbedScene = register({
|
||||
export const actionSaveToActiveFile = register({
|
||||
name: "saveToActiveFile",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.saveToActiveFile &&
|
||||
!!appState.fileHandle &&
|
||||
!appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, value, app) => {
|
||||
const fileHandleExists = !!appState.fileHandle;
|
||||
|
||||
@ -174,11 +169,16 @@ export const actionSaveToActiveFile = register({
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.key === KEYS.S && event[KEYS.CTRL_OR_CMD] && !event.shiftKey,
|
||||
PanelComponent: ({ updateData, appState }) => (
|
||||
<ActiveFile
|
||||
onSave={() => updateData(null)}
|
||||
fileName={appState.fileHandle?.name}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionSaveFileToDisk = register({
|
||||
name: "saveFileToDisk",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "export" },
|
||||
perform: async (elements, appState, value, app) => {
|
||||
try {
|
||||
@ -219,11 +219,6 @@ export const actionSaveFileToDisk = register({
|
||||
export const actionLoadScene = register({
|
||||
name: "loadScene",
|
||||
trackEvent: { category: "export" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return (
|
||||
!!app.props.UIOptions.canvasActions.loadScene && !appState.viewModeEnabled
|
||||
);
|
||||
},
|
||||
perform: async (elements, appState, _, app) => {
|
||||
try {
|
||||
const {
|
||||
@ -251,6 +246,15 @@ export const actionLoadScene = register({
|
||||
}
|
||||
},
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O,
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<MenuItem
|
||||
label={t("buttons.load")}
|
||||
icon={LoadIcon}
|
||||
onClick={updateData}
|
||||
dataTestId="load-button"
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionExportWithDarkMode = register({
|
||||
|
@ -145,7 +145,7 @@ export const actionFinalize = register({
|
||||
let activeTool: AppState["activeTool"];
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
...(appState.activeTool.lastActiveToolBeforeEraser || {
|
||||
type: "selection",
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
} from "../element/bounds";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
const enableActionFlipHorizontal = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -50,7 +49,7 @@ export const actionFlipHorizontal = register({
|
||||
},
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyH",
|
||||
contextItemLabel: "labels.flipHorizontal",
|
||||
predicate: (elements, appState) =>
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionFlipHorizontal(elements, appState),
|
||||
});
|
||||
|
||||
@ -64,10 +63,9 @@ export const actionFlipVertical = register({
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
event.shiftKey && event.code === "KeyV" && !event[KEYS.CTRL_OR_CMD],
|
||||
keyTest: (event) => event.shiftKey && event.code === "KeyV",
|
||||
contextItemLabel: "labels.flipVertical",
|
||||
predicate: (elements, appState) =>
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionFlipVertical(elements, appState),
|
||||
});
|
||||
|
||||
@ -153,7 +151,11 @@ const flipElement = (
|
||||
|
||||
let initialPointsCoords;
|
||||
if (isLinearElement(element)) {
|
||||
initialPointsCoords = getElementPointsCoords(element, element.points);
|
||||
initialPointsCoords = getElementPointsCoords(
|
||||
element,
|
||||
element.points,
|
||||
element.strokeSharpness,
|
||||
);
|
||||
}
|
||||
const initialElementAbsoluteCoords = getElementAbsoluteCoords(element);
|
||||
|
||||
@ -211,7 +213,11 @@ const flipElement = (
|
||||
// Adjusting origin because when a beizer curve path exceeds min/max points it offsets the origin.
|
||||
// There's still room for improvement since when the line roughness is > 1
|
||||
// we still have a small offset of the origin when fliipping the element.
|
||||
const finalPointsCoords = getElementPointsCoords(element, element.points);
|
||||
const finalPointsCoords = getElementPointsCoords(
|
||||
element,
|
||||
element.points,
|
||||
element.strokeSharpness,
|
||||
);
|
||||
|
||||
const topLeftCoordsDiff = initialPointsCoords[0] - finalPointsCoords[0];
|
||||
const topRightCoordDiff = initialPointsCoords[2] - finalPointsCoords[2];
|
||||
|
@ -129,7 +129,8 @@ export const actionGroup = register({
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
predicate: (elements, appState) => enableActionGroup(elements, appState),
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
enableActionGroup(elements, appState),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
@ -192,7 +193,8 @@ export const actionUngroup = register({
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.key === KEYS.G.toUpperCase(),
|
||||
contextItemLabel: "labels.ungroup",
|
||||
predicate: (elements, appState) => getSelectedGroupIds(appState).length > 0,
|
||||
contextItemPredicate: (elements, appState) =>
|
||||
getSelectedGroupIds(appState).length > 0,
|
||||
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
|
@ -5,11 +5,10 @@ import { t } from "../i18n";
|
||||
import History, { HistoryEntry } from "../history";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { KEYS } from "../keys";
|
||||
import { isWindows, KEYS } from "../keys";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { fixBindingsAfterDeletion } from "../element/binding";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { isWindows } from "../constants";
|
||||
|
||||
const writeData = (
|
||||
prevElements: readonly ExcalidrawElement[],
|
||||
|
@ -10,7 +10,7 @@ export const actionToggleLinearEditor = register({
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState) => {
|
||||
contextItemPredicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { HamburgerMenuIcon, palette } from "../components/icons";
|
||||
import { HamburgerMenuIcon, HelpIcon, palette } from "../components/icons";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { showSelectedShapeActions, getNonDeletedElements } from "../element";
|
||||
import { register } from "./register";
|
||||
import { allowFullScreen, exitFullScreen, isFullScreen } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { HelpButton } from "../components/HelpButton";
|
||||
import MenuItem from "../components/MenuItem";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
@ -54,7 +56,6 @@ export const actionToggleEditMenu = register({
|
||||
|
||||
export const actionFullScreen = register({
|
||||
name: "toggleFullScreen",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas", predicate: (appState) => !isFullScreen() },
|
||||
perform: () => {
|
||||
if (!isFullScreen()) {
|
||||
@ -72,7 +73,6 @@ export const actionFullScreen = register({
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu", action: "toggleHelpDialog" },
|
||||
perform: (_elements, appState, _, { focusContainer }) => {
|
||||
if (appState.openDialog === "help") {
|
||||
@ -86,5 +86,17 @@ export const actionShortcuts = register({
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData, isInHamburgerMenu }) =>
|
||||
isInHamburgerMenu ? (
|
||||
<MenuItem
|
||||
label={t("helpDialog.title")}
|
||||
dataTestId="help-menu-item"
|
||||
icon={HelpIcon}
|
||||
onClick={updateData}
|
||||
shortcut="?"
|
||||
/>
|
||||
) : (
|
||||
<HelpButton title={t("helpDialog.title")} onClick={updateData} />
|
||||
),
|
||||
keyTest: (event) => event.key === KEYS.QUESTION_MARK,
|
||||
});
|
||||
|
@ -6,7 +6,6 @@ import { register } from "./register";
|
||||
|
||||
export const actionGoToCollaborator = register({
|
||||
name: "goToCollaborator",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "collab" },
|
||||
perform: (_elements, appState, value) => {
|
||||
const point = value as Collaborator["pointer"];
|
||||
|
@ -42,7 +42,6 @@ import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
@ -58,7 +57,7 @@ import {
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
isUsingAdaptiveRadius,
|
||||
isLinearElementType,
|
||||
} from "../element/typeChecks";
|
||||
import {
|
||||
Arrowhead,
|
||||
@ -73,7 +72,7 @@ import { getLanguage, t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { randomInteger } from "../random";
|
||||
import {
|
||||
canChangeRoundness,
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
getCommonAttributeOfSelectedElements,
|
||||
getSelectedElements,
|
||||
@ -817,19 +816,16 @@ export const actionChangeVerticalAlign = register({
|
||||
value: VERTICAL_ALIGN.TOP,
|
||||
text: t("labels.alignTop"),
|
||||
icon: <TextAlignTopIcon theme={appState.theme} />,
|
||||
testId: "align-top",
|
||||
},
|
||||
{
|
||||
value: VERTICAL_ALIGN.MIDDLE,
|
||||
text: t("labels.centerVertically"),
|
||||
icon: <TextAlignMiddleIcon theme={appState.theme} />,
|
||||
testId: "align-middle",
|
||||
},
|
||||
{
|
||||
value: VERTICAL_ALIGN.BOTTOM,
|
||||
text: t("labels.alignBottom"),
|
||||
icon: <TextAlignBottomIcon theme={appState.theme} />,
|
||||
testId: "align-bottom",
|
||||
},
|
||||
]}
|
||||
value={getFormValue(elements, appState, (element) => {
|
||||
@ -849,71 +845,69 @@ export const actionChangeVerticalAlign = register({
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeRoundness = register({
|
||||
name: "changeRoundness",
|
||||
export const actionChangeSharpness = register({
|
||||
name: "changeSharpness",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
roundness:
|
||||
value === "round"
|
||||
? {
|
||||
type: isUsingAdaptiveRadius(el.type)
|
||||
? ROUNDNESS.ADAPTIVE_RADIUS
|
||||
: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemRoundness: value,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
const hasLegacyRoundness = targetElements.some(
|
||||
(el) => el.roundness?.type === ROUNDNESS.LEGACY,
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.edges")}</legend>
|
||||
<ButtonIconSelect
|
||||
group="edges"
|
||||
options={[
|
||||
{
|
||||
value: "sharp",
|
||||
text: t("labels.sharp"),
|
||||
icon: EdgeSharpIcon,
|
||||
},
|
||||
{
|
||||
value: "round",
|
||||
text: t("labels.round"),
|
||||
icon: EdgeRoundIcon,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) =>
|
||||
hasLegacyRoundness ? null : element.roundness ? "round" : "sharp",
|
||||
(canChangeRoundness(appState.activeTool.type) &&
|
||||
appState.currentItemRoundness) ||
|
||||
null,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
const shouldUpdateForNonLinearElements = targetElements.length
|
||||
? targetElements.every((el) => !isLinearElement(el))
|
||||
: !isLinearElementType(appState.activeTool.type);
|
||||
const shouldUpdateForLinearElements = targetElements.length
|
||||
? targetElements.every(isLinearElement)
|
||||
: isLinearElementType(appState.activeTool.type);
|
||||
return {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
strokeSharpness: value,
|
||||
}),
|
||||
),
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
|
||||
? value
|
||||
: appState.currentItemStrokeSharpness,
|
||||
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
|
||||
? value
|
||||
: appState.currentItemLinearStrokeSharpness,
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.edges")}</legend>
|
||||
<ButtonIconSelect
|
||||
group="edges"
|
||||
options={[
|
||||
{
|
||||
value: "sharp",
|
||||
text: t("labels.sharp"),
|
||||
icon: EdgeSharpIcon,
|
||||
},
|
||||
{
|
||||
value: "round",
|
||||
text: t("labels.round"),
|
||||
icon: EdgeRoundIcon,
|
||||
},
|
||||
]}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => element.strokeSharpness,
|
||||
(canChangeSharpness(appState.activeTool.type) &&
|
||||
(isLinearElementType(appState.activeTool.type)
|
||||
? appState.currentItemLinearStrokeSharpness
|
||||
: appState.currentItemStrokeSharpness)) ||
|
||||
null,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
</fieldset>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionChangeArrowhead = register({
|
||||
|
@ -13,11 +13,7 @@ import {
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
} from "../element/typeChecks";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
@ -81,14 +77,6 @@ export const actionPasteStyles = register({
|
||||
fillStyle: elementStylesToCopyFrom?.fillStyle,
|
||||
opacity: elementStylesToCopyFrom?.opacity,
|
||||
roughness: elementStylesToCopyFrom?.roughness,
|
||||
roundness: elementStylesToCopyFrom.roundness
|
||||
? canApplyRoundnessTypeToElement(
|
||||
elementStylesToCopyFrom.roundness.type,
|
||||
element,
|
||||
)
|
||||
? elementStylesToCopyFrom.roundness
|
||||
: getDefaultRoundnessTypeForElement(element)
|
||||
: null,
|
||||
});
|
||||
|
||||
if (isTextElement(newElement)) {
|
||||
|
@ -5,7 +5,6 @@ import { AppState } from "../types";
|
||||
|
||||
export const actionToggleGridMode = register({
|
||||
name: "gridMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.gridSize,
|
||||
@ -20,9 +19,6 @@ export const actionToggleGridMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState: AppState) => appState.gridSize !== null,
|
||||
predicate: (element, appState, props) => {
|
||||
return typeof props.gridModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.showGrid",
|
||||
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.code === CODES.QUOTE,
|
||||
});
|
||||
|
@ -41,9 +41,15 @@ export const actionToggleLock = register({
|
||||
: "labels.elementLock.lock";
|
||||
}
|
||||
|
||||
return getOperation(selected) === "lock"
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
if (selected.length > 1) {
|
||||
return getOperation(selected) === "lock"
|
||||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unexpected zero elements to lock/unlock. This should never happen.",
|
||||
);
|
||||
},
|
||||
keyTest: (event, appState, elements) => {
|
||||
return (
|
||||
|
@ -3,7 +3,6 @@ import { CODES, KEYS } from "../keys";
|
||||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
|
@ -3,7 +3,6 @@ import { register } from "./register";
|
||||
|
||||
export const actionToggleViewMode = register({
|
||||
name: "viewMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.viewModeEnabled,
|
||||
@ -18,9 +17,6 @@ export const actionToggleViewMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.viewModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.viewModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "labels.viewMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.R,
|
||||
|
@ -3,7 +3,6 @@ import { register } from "./register";
|
||||
|
||||
export const actionToggleZenMode = register({
|
||||
name: "zenMode",
|
||||
viewMode: true,
|
||||
trackEvent: {
|
||||
category: "canvas",
|
||||
predicate: (appState) => !appState.zenModeEnabled,
|
||||
@ -18,9 +17,6 @@ export const actionToggleZenMode = register({
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.zenModeEnabled,
|
||||
predicate: (elements, appState, appProps) => {
|
||||
return typeof appProps.zenModeEnabled === "undefined";
|
||||
},
|
||||
contextItemLabel: "buttons.zenMode",
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.Z,
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
moveAllLeft,
|
||||
moveAllRight,
|
||||
} from "../zindex";
|
||||
import { KEYS, CODES } from "../keys";
|
||||
import { KEYS, isDarwin, CODES } from "../keys";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
@ -15,7 +15,6 @@ import {
|
||||
SendBackwardIcon,
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { isDarwin } from "../constants";
|
||||
|
||||
export const actionSendBackward = register({
|
||||
name: "sendBackward",
|
||||
|
@ -9,6 +9,7 @@ import {
|
||||
} from "./types";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { MODES } from "../constants";
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
const trackAction = (
|
||||
@ -102,8 +103,11 @@ export class ActionManager {
|
||||
|
||||
const action = data[0];
|
||||
|
||||
if (this.getAppState().viewModeEnabled && action.viewMode !== true) {
|
||||
return false;
|
||||
const { viewModeEnabled } = this.getAppState();
|
||||
if (viewModeEnabled) {
|
||||
if (!Object.values(MODES).includes(data[0].name)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
@ -131,7 +135,11 @@ export class ActionManager {
|
||||
/**
|
||||
* @param data additional data sent to the PanelComponent
|
||||
*/
|
||||
renderAction = (name: ActionName, data?: PanelComponentProps["data"]) => {
|
||||
renderAction = (
|
||||
name: ActionName,
|
||||
data?: PanelComponentProps["data"],
|
||||
isInHamburgerMenu = false,
|
||||
) => {
|
||||
const canvasActions = this.app.props.UIOptions.canvasActions;
|
||||
|
||||
if (
|
||||
@ -166,20 +174,11 @@ export class ActionManager {
|
||||
updateData={updateData}
|
||||
appProps={this.app.props}
|
||||
data={data}
|
||||
isInHamburgerMenu={isInHamburgerMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
isActionEnabled = (action: Action) => {
|
||||
const elements = this.getElementsIncludingDeleted();
|
||||
const appState = this.getAppState();
|
||||
|
||||
return (
|
||||
!action.predicate ||
|
||||
action.predicate(elements, appState, this.app.props, this.app)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { isDarwin } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { isDarwin } from "../keys";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import { ActionName } from "./types";
|
||||
|
||||
@ -48,7 +48,7 @@ const shortcutMap: Record<ShortcutName, string[]> = {
|
||||
copyStyles: [getShortcutKey("CtrlOrCmd+Alt+C")],
|
||||
pasteStyles: [getShortcutKey("CtrlOrCmd+Alt+V")],
|
||||
selectAll: [getShortcutKey("CtrlOrCmd+A")],
|
||||
deleteSelectedElements: [getShortcutKey("Delete")],
|
||||
deleteSelectedElements: [getShortcutKey("Del")],
|
||||
duplicateSelection: [
|
||||
getShortcutKey("CtrlOrCmd+D"),
|
||||
getShortcutKey(`Alt+${t("helpDialog.drag")}`),
|
||||
|
@ -91,7 +91,7 @@ export type ActionName =
|
||||
| "ungroup"
|
||||
| "goToCollaborator"
|
||||
| "addToLibrary"
|
||||
| "changeRoundness"
|
||||
| "changeSharpness"
|
||||
| "alignTop"
|
||||
| "alignBottom"
|
||||
| "alignLeft"
|
||||
@ -109,11 +109,10 @@ export type ActionName =
|
||||
| "decreaseFontSize"
|
||||
| "unbindText"
|
||||
| "hyperlink"
|
||||
| "eraser"
|
||||
| "bindText"
|
||||
| "toggleLock"
|
||||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool";
|
||||
| "toggleLinearEditor";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@ -125,7 +124,9 @@ export type PanelComponentProps = {
|
||||
|
||||
export interface Action {
|
||||
name: ActionName;
|
||||
PanelComponent?: React.FC<PanelComponentProps>;
|
||||
PanelComponent?: React.FC<
|
||||
PanelComponentProps & { isInHamburgerMenu: boolean }
|
||||
>;
|
||||
perform: ActionFn;
|
||||
keyPriority?: number;
|
||||
keyTest?: (
|
||||
@ -139,11 +140,9 @@ export interface Action {
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
) => string);
|
||||
predicate?: (
|
||||
contextItemPredicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
appProps: ExcalidrawProps,
|
||||
app: AppClassProperties,
|
||||
) => boolean;
|
||||
checked?: (appState: Readonly<AppState>) => boolean;
|
||||
trackEvent:
|
||||
@ -165,7 +164,4 @@ export interface Action {
|
||||
value: any,
|
||||
) => boolean;
|
||||
};
|
||||
/** if set to `true`, allow action to be performed in viewMode.
|
||||
* Defaults to `false` */
|
||||
viewMode?: boolean;
|
||||
}
|
||||
|
@ -28,11 +28,12 @@ export const getDefaultAppState = (): Omit<
|
||||
currentItemFillStyle: "hachure",
|
||||
currentItemFontFamily: DEFAULT_FONT_FAMILY,
|
||||
currentItemFontSize: DEFAULT_FONT_SIZE,
|
||||
currentItemLinearStrokeSharpness: "round",
|
||||
currentItemOpacity: 100,
|
||||
currentItemRoughness: 1,
|
||||
currentItemStartArrowhead: null,
|
||||
currentItemStrokeColor: oc.black,
|
||||
currentItemRoundness: "round",
|
||||
currentItemStrokeSharpness: "sharp",
|
||||
currentItemStrokeStyle: "solid",
|
||||
currentItemStrokeWidth: 1,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
@ -45,7 +46,7 @@ export const getDefaultAppState = (): Omit<
|
||||
type: "selection",
|
||||
customType: null,
|
||||
locked: false,
|
||||
lastActiveTool: null,
|
||||
lastActiveToolBeforeEraser: null,
|
||||
},
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
@ -64,7 +65,6 @@ export const getDefaultAppState = (): Omit<
|
||||
lastPointerDownWith: "mouse",
|
||||
multiElement: null,
|
||||
name: `${t("labels.untitled")}-${getDateTime()}`,
|
||||
contextMenu: null,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
openSidebar: null,
|
||||
@ -120,7 +120,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemFillStyle: { browser: true, export: false, server: false },
|
||||
currentItemFontFamily: { browser: true, export: false, server: false },
|
||||
currentItemFontSize: { browser: true, export: false, server: false },
|
||||
currentItemRoundness: {
|
||||
currentItemLinearStrokeSharpness: {
|
||||
browser: true,
|
||||
export: false,
|
||||
server: false,
|
||||
@ -129,6 +129,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
currentItemRoughness: { browser: true, export: false, server: false },
|
||||
currentItemStartArrowhead: { browser: true, export: false, server: false },
|
||||
currentItemStrokeColor: { browser: true, export: false, server: false },
|
||||
currentItemStrokeSharpness: { browser: true, export: false, server: false },
|
||||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
@ -158,7 +159,6 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
name: { browser: true, export: false, server: false },
|
||||
offsetLeft: { browser: false, export: false, server: false },
|
||||
offsetTop: { browser: false, export: false, server: false },
|
||||
contextMenu: { browser: false, export: false, server: false },
|
||||
openMenu: { browser: true, export: false, server: false },
|
||||
openPopup: { browser: false, export: false, server: false },
|
||||
openSidebar: { browser: true, export: false, server: false },
|
||||
@ -228,11 +228,3 @@ export const isEraserActive = ({
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => activeTool.type === "eraser";
|
||||
|
||||
export const isHandToolActive = ({
|
||||
activeTool,
|
||||
}: {
|
||||
activeTool: AppState["activeTool"];
|
||||
}) => {
|
||||
return activeTool.type === "hand";
|
||||
};
|
||||
|
@ -172,7 +172,7 @@ const commonProps = {
|
||||
opacity: 100,
|
||||
roughness: 1,
|
||||
strokeColor: colors.elementStroke[0],
|
||||
roundness: null,
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
strokeWidth: 1,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
@ -322,7 +322,7 @@ const chartBaseElements = (
|
||||
text: spreadsheet.title,
|
||||
x: x + chartWidth / 2,
|
||||
y: y - BAR_HEIGHT - BAR_GAP * 2 - DEFAULT_FONT_SIZE,
|
||||
roundness: null,
|
||||
strokeSharpness: "sharp",
|
||||
strokeStyle: "solid",
|
||||
textAlign: "center",
|
||||
})
|
||||
|
@ -1,27 +0,0 @@
|
||||
import { parseClipboard } from "./clipboard";
|
||||
|
||||
describe("Test parseClipboard", () => {
|
||||
it("should parse valid json correctly", async () => {
|
||||
let text = "123";
|
||||
|
||||
let clipboardData = await parseClipboard({
|
||||
//@ts-ignore
|
||||
clipboardData: {
|
||||
getData: () => text,
|
||||
},
|
||||
});
|
||||
|
||||
expect(clipboardData.text).toBe(text);
|
||||
|
||||
text = "[123]";
|
||||
|
||||
clipboardData = await parseClipboard({
|
||||
//@ts-ignore
|
||||
clipboardData: {
|
||||
getData: () => text,
|
||||
},
|
||||
});
|
||||
|
||||
expect(clipboardData.text).toBe(text);
|
||||
});
|
||||
});
|
@ -109,16 +109,16 @@ const parsePotentialSpreadsheet = (
|
||||
* Retrieves content from system clipboard (either from ClipboardEvent or
|
||||
* via async clipboard API if supported)
|
||||
*/
|
||||
export const getSystemClipboard = async (
|
||||
const getSystemClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
): Promise<string> => {
|
||||
try {
|
||||
const text = event
|
||||
? event.clipboardData?.getData("text/plain")
|
||||
? event.clipboardData?.getData("text/plain").trim()
|
||||
: probablySupportsClipboardReadText &&
|
||||
(await navigator.clipboard.readText());
|
||||
|
||||
return (text || "").trim();
|
||||
return text || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
@ -129,25 +129,19 @@ export const getSystemClipboard = async (
|
||||
*/
|
||||
export const parseClipboard = async (
|
||||
event: ClipboardEvent | null,
|
||||
isPlainPaste = false,
|
||||
): Promise<ClipboardData> => {
|
||||
const systemClipboard = await getSystemClipboard(event);
|
||||
|
||||
// if system clipboard empty, couldn't be resolved, or contains previously
|
||||
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
|
||||
// elements
|
||||
if (
|
||||
!systemClipboard ||
|
||||
(!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
|
||||
) {
|
||||
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
|
||||
return getAppClipboard();
|
||||
}
|
||||
|
||||
// if system clipboard contains spreadsheet, use it even though it's
|
||||
// technically possible it's staler than in-app clipboard
|
||||
const spreadsheetResult =
|
||||
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
|
||||
|
||||
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
|
||||
if (spreadsheetResult) {
|
||||
return spreadsheetResult;
|
||||
}
|
||||
@ -160,36 +154,30 @@ export const parseClipboard = async (
|
||||
return {
|
||||
elements: systemClipboardData.elements,
|
||||
files: systemClipboardData.files,
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(systemClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
||||
// support storing to system clipboard on copy
|
||||
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
||||
? {
|
||||
...appClipboardData,
|
||||
text: isPlainPaste
|
||||
? JSON.stringify(appClipboardData.elements, null, 2)
|
||||
: undefined,
|
||||
}
|
||||
: { text: systemClipboard };
|
||||
return appClipboardData;
|
||||
} catch {
|
||||
// system clipboard doesn't contain excalidraw elements → return plaintext
|
||||
// unless we set a flag to prefer in-app clipboard because browser didn't
|
||||
// support storing to system clipboard on copy
|
||||
return PREFER_APP_CLIPBOARD && appClipboardData.elements
|
||||
? appClipboardData
|
||||
: { text: systemClipboard };
|
||||
}
|
||||
};
|
||||
|
||||
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
let promise;
|
||||
try {
|
||||
// in Safari so far we need to construct the ClipboardItem synchronously
|
||||
// (i.e. in the same tick) otherwise browser will complain for lack of
|
||||
// user intent. Using a Promise ClipboardItem constructor solves this.
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=222262
|
||||
//
|
||||
// Note that Firefox (and potentially others) seems to support Promise
|
||||
// ClipboardItem constructor, but throws on an unrelated MIME type error.
|
||||
// So we need to await this and fallback to awaiting the blob if applicable.
|
||||
await navigator.clipboard.write([
|
||||
// not await so that we can detect whether the thrown error likely relates
|
||||
// to a lack of support for the Promise ClipboardItem constructor
|
||||
promise = navigator.clipboard.write([
|
||||
new window.ClipboardItem({
|
||||
[MIME_TYPES.png]: blob,
|
||||
}),
|
||||
@ -207,6 +195,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await promise;
|
||||
};
|
||||
|
||||
export const copyTextToSystemClipboard = async (text: string | null) => {
|
||||
|
@ -5,7 +5,7 @@ import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { useDevice } from "../components/App";
|
||||
import {
|
||||
canChangeRoundness,
|
||||
canChangeSharpness,
|
||||
canHaveArrowheads,
|
||||
getTargetElements,
|
||||
hasBackground,
|
||||
@ -25,12 +25,11 @@ import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { hasBoundTextElement } from "../element/typeChecks";
|
||||
import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks";
|
||||
import clsx from "clsx";
|
||||
import { actionToggleZenMode } from "../actions";
|
||||
import "./Actions.scss";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { shouldAllowVerticalAlign } from "../element/textElement";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
@ -110,9 +109,9 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(canChangeRoundness(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canChangeRoundness(element.type))) && (
|
||||
<>{renderAction("changeRoundness")}</>
|
||||
{(canChangeSharpness(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canChangeSharpness(element.type))) && (
|
||||
<>{renderAction("changeSharpness")}</>
|
||||
)}
|
||||
|
||||
{(hasText(appState.activeTool.type) ||
|
||||
@ -126,8 +125,10 @@ export const SelectedShapeActions = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldAllowVerticalAlign(targetElements) &&
|
||||
renderAction("changeVerticalAlign")}
|
||||
{targetElements.some(
|
||||
(element) =>
|
||||
hasBoundTextElement(element) || isBoundToContainer(element),
|
||||
) && renderAction("changeVerticalAlign")}
|
||||
{(canHaveArrowheads(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canHaveArrowheads(element.type))) && (
|
||||
<>{renderAction("changeArrowhead")}</>
|
||||
@ -217,13 +218,13 @@ export const ShapesSwitcher = ({
|
||||
appState: AppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
{SHAPES.map(({ value, icon, key, fillable }, index) => {
|
||||
const numberKey = value === "eraser" ? 0 : index + 1;
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const letter = key && (typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numberKey}`
|
||||
: `${numberKey}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
@ -233,7 +234,7 @@ export const ShapesSwitcher = ({
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
keyBindingLabel={`${numberKey}`}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
|
@ -1,35 +0,0 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { actionClearCanvas } from "../actions";
|
||||
import { t } from "../i18n";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
|
||||
export const activeConfirmDialogAtom = atom<"clearCanvas" | null>(null);
|
||||
|
||||
export const ActiveConfirmDialog = () => {
|
||||
const [activeConfirmDialog, setActiveConfirmDialog] = useAtom(
|
||||
activeConfirmDialogAtom,
|
||||
);
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
if (!activeConfirmDialog) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeConfirmDialog === "clearCanvas") {
|
||||
return (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
actionManager.executeAction(actionClearCanvas);
|
||||
setActiveConfirmDialog(null);
|
||||
}}
|
||||
onCancel={() => setActiveConfirmDialog(null)}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
</ConfirmDialog>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
23
src/components/ActiveFile.tsx
Normal file
23
src/components/ActiveFile.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// this icon is not great
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { save } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import "./ActiveFile.scss";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
type ActiveFileProps = {
|
||||
fileName?: string;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => (
|
||||
<MenuItem
|
||||
label={`${t("buttons.save")}`}
|
||||
shortcut={getShortcutFromShortcutName("saveScene")}
|
||||
dataTestId="save-button"
|
||||
onClick={onSave}
|
||||
icon={save}
|
||||
/>
|
||||
);
|
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,8 @@
|
||||
.Avatar {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
position: relative;
|
||||
border-radius: 100%;
|
||||
outline: 2px solid var(--avatar-border-color);
|
||||
outline-offset: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@ -21,16 +21,5 @@
|
||||
height: 100%;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
bottom: -3px;
|
||||
left: -3px;
|
||||
border: 1px solid var(--avatar-border-color);
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
@import "../css/theme";
|
||||
|
||||
.excalidraw {
|
||||
.excalidraw-button {
|
||||
@include outlineButtonStyles;
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import "./Button.scss";
|
||||
|
||||
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
type?: "button" | "submit" | "reset";
|
||||
onSelect: () => any;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A generic button component that follows Excalidraw's design system.
|
||||
* Style can be customised using `className` or `style` prop.
|
||||
* Accepts all props that a regular `button` element accepts.
|
||||
*/
|
||||
export const Button = ({
|
||||
type = "button",
|
||||
onSelect,
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
onClick={(event) => {
|
||||
onSelect();
|
||||
rest.onClick?.(event);
|
||||
}}
|
||||
type={type}
|
||||
className={`excalidraw-button ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
39
src/components/ClearCanvas.tsx
Normal file
39
src/components/ClearCanvas.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { TrashIcon } from "./icons";
|
||||
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const toggleDialog = () => {
|
||||
setShowDialog(!showDialog);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem
|
||||
label={t("buttons.clearReset")}
|
||||
icon={TrashIcon}
|
||||
onClick={toggleDialog}
|
||||
dataTestId="clear-canvas-button"
|
||||
/>
|
||||
|
||||
{showDialog && (
|
||||
<ConfirmDialog
|
||||
onConfirm={() => {
|
||||
onConfirm();
|
||||
toggleDialog();
|
||||
}}
|
||||
onCancel={toggleDialog}
|
||||
title={t("clearCanvasDialog.title")}
|
||||
>
|
||||
<p className="clear-canvas__content"> {t("alerts.clearReset")}</p>
|
||||
</ConfirmDialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearCanvas;
|
@ -1,23 +1,30 @@
|
||||
@import "../../css/variables.module";
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.collab-button {
|
||||
--button-bg: var(--color-primary);
|
||||
--button-color: white;
|
||||
--button-border: var(--color-primary);
|
||||
|
||||
--button-width: var(--lg-button-size);
|
||||
--button-height: var(--lg-button-size);
|
||||
|
||||
--button-hover-bg: var(--color-primary-darker);
|
||||
--button-hover-border: var(--color-primary-darker);
|
||||
|
||||
--button-active-bg: var(--color-primary-darker);
|
||||
@include outlineButtonStyles;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
|
||||
// double .active to force specificity
|
||||
&.active.active {
|
||||
&:hover {
|
||||
background-color: var(--color-primary-darker);
|
||||
border-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background-color: #0fb884;
|
||||
border-color: #0fb884;
|
||||
|
49
src/components/CollabButton.tsx
Normal file
49
src/components/CollabButton.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { t } from "../i18n";
|
||||
import { UsersIcon } from "./icons";
|
||||
|
||||
import "./CollabButton.scss";
|
||||
import MenuItem from "./MenuItem";
|
||||
import clsx from "clsx";
|
||||
|
||||
const CollabButton = ({
|
||||
isCollaborating,
|
||||
collaboratorCount,
|
||||
onClick,
|
||||
isInHamburgerMenu = true,
|
||||
}: {
|
||||
isCollaborating: boolean;
|
||||
collaboratorCount: number;
|
||||
onClick: () => void;
|
||||
isInHamburgerMenu?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{isInHamburgerMenu ? (
|
||||
<MenuItem
|
||||
label={t("labels.liveCollaboration")}
|
||||
dataTestId="collab-button"
|
||||
icon={UsersIcon}
|
||||
onClick={onClick}
|
||||
isCollaborating={isCollaborating}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
className={clsx("collab-button", { active: isCollaborating })}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={{ position: "relative" }}
|
||||
title={t("labels.liveCollaboration")}
|
||||
>
|
||||
{UsersIcon}
|
||||
{collaboratorCount > 0 && (
|
||||
<div className="CollabButton-collaborators">
|
||||
{collaboratorCount}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollabButton;
|
@ -66,13 +66,10 @@ const getColor = (color: string): string | null => {
|
||||
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)
|
||||
return isValidColor(color)
|
||||
? color
|
||||
: isValidColor(`#${color}`)
|
||||
? `#${color}`
|
||||
: null;
|
||||
};
|
||||
|
||||
|
@ -3,9 +3,9 @@ import { Dialog, DialogProps } from "./Dialog";
|
||||
|
||||
import "./ConfirmDialog.scss";
|
||||
import DialogActionButton from "./DialogActionButton";
|
||||
import { isMenuOpenAtom } from "./App";
|
||||
import { isDropdownOpenAtom } from "./App";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
import { useExcalidrawSetAppState } from "./App";
|
||||
|
||||
interface Props extends Omit<DialogProps, "onCloseRequest"> {
|
||||
onConfirm: () => void;
|
||||
@ -23,8 +23,9 @@ const ConfirmDialog = (props: Props) => {
|
||||
className = "",
|
||||
...rest
|
||||
} = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
|
||||
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
|
||||
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@ -38,16 +39,16 @@ const ConfirmDialog = (props: Props) => {
|
||||
<DialogActionButton
|
||||
label={cancelText}
|
||||
onClick={() => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
setIsMenuOpen(false);
|
||||
setIsDropdownOpen(false);
|
||||
onCancel();
|
||||
}}
|
||||
/>
|
||||
<DialogActionButton
|
||||
label={confirmText}
|
||||
onClick={() => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
setIsMenuOpen(false);
|
||||
setIsDropdownOpen(false);
|
||||
onConfirm();
|
||||
}}
|
||||
actionType="danger"
|
||||
|
@ -19,7 +19,7 @@
|
||||
color: var(--popup-text-color);
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
.context-menu-option {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 9.5rem;
|
||||
@ -43,16 +43,16 @@
|
||||
}
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-item__label {
|
||||
.context-menu-option__label {
|
||||
color: $oc-red-7;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item__label {
|
||||
.context-menu-option__label {
|
||||
justify-self: start;
|
||||
margin-inline-end: 20px;
|
||||
}
|
||||
.context-menu-item__shortcut {
|
||||
.context-menu-option__shortcut {
|
||||
justify-self: end;
|
||||
opacity: 0.6;
|
||||
font-family: inherit;
|
||||
@ -60,37 +60,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
.context-menu-option:hover {
|
||||
color: var(--popup-bg-color);
|
||||
background-color: var(--select-highlight-color);
|
||||
|
||||
&.dangerous {
|
||||
.context-menu-item__label {
|
||||
.context-menu-option__label {
|
||||
color: var(--popup-bg-color);
|
||||
}
|
||||
background-color: $oc-red-6;
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item:focus {
|
||||
.context-menu-option:focus {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@include isMobile {
|
||||
.context-menu-item {
|
||||
.context-menu-option {
|
||||
display: block;
|
||||
|
||||
.context-menu-item__label {
|
||||
.context-menu-option__label {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.context-menu-item__shortcut {
|
||||
.context-menu-option__shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.context-menu-item-separator {
|
||||
.context-menu-option-separator {
|
||||
border: none;
|
||||
border-top: 1px solid $oc-gray-5;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t } from "../i18n";
|
||||
@ -9,116 +10,140 @@ import {
|
||||
} from "../actions/shortcuts";
|
||||
import { Action } from "../actions/types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import {
|
||||
useExcalidrawAppState,
|
||||
useExcalidrawElements,
|
||||
useExcalidrawSetAppState,
|
||||
} from "./App";
|
||||
import React from "react";
|
||||
import { AppState } from "../types";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
|
||||
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
|
||||
|
||||
export type ContextMenuItems = (ContextMenuItem | false | null | undefined)[];
|
||||
export type ContextMenuOption = "separator" | Action;
|
||||
|
||||
type ContextMenuProps = {
|
||||
actionManager: ActionManager;
|
||||
items: ContextMenuItems;
|
||||
options: ContextMenuOption[];
|
||||
onCloseRequest?(): void;
|
||||
top: number;
|
||||
left: number;
|
||||
actionManager: ActionManager;
|
||||
appState: Readonly<AppState>;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
};
|
||||
|
||||
export const CONTEXT_MENU_SEPARATOR = "separator";
|
||||
|
||||
export const ContextMenu = React.memo(
|
||||
({ actionManager, items, top, left }: ContextMenuProps) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const elements = useExcalidrawElements();
|
||||
|
||||
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
|
||||
if (
|
||||
item &&
|
||||
(item === CONTEXT_MENU_SEPARATOR ||
|
||||
!item.predicate ||
|
||||
item.predicate(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app.props,
|
||||
actionManager.app,
|
||||
))
|
||||
) {
|
||||
acc.push(item);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={() => setAppState({ contextMenu: null })}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
offsetLeft={appState.offsetLeft}
|
||||
offsetTop={appState.offsetTop}
|
||||
viewportWidth={appState.width}
|
||||
viewportHeight={appState.height}
|
||||
const ContextMenu = ({
|
||||
options,
|
||||
onCloseRequest,
|
||||
top,
|
||||
left,
|
||||
actionManager,
|
||||
appState,
|
||||
elements,
|
||||
}: ContextMenuProps) => {
|
||||
return (
|
||||
<Popover
|
||||
onCloseRequest={onCloseRequest}
|
||||
top={top}
|
||||
left={left}
|
||||
fitInViewport={true}
|
||||
offsetLeft={appState.offsetLeft}
|
||||
offsetTop={appState.offsetTop}
|
||||
viewportWidth={appState.width}
|
||||
viewportHeight={appState.height}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
>
|
||||
{filteredItems.map((item, idx) => {
|
||||
if (item === CONTEXT_MENU_SEPARATOR) {
|
||||
if (
|
||||
!filteredItems[idx - 1] ||
|
||||
filteredItems[idx - 1] === CONTEXT_MENU_SEPARATOR
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <hr key={idx} className="context-menu-item-separator" />;
|
||||
}
|
||||
{options.map((option, idx) => {
|
||||
if (option === "separator") {
|
||||
return <hr key={idx} className="context-menu-option-separator" />;
|
||||
}
|
||||
|
||||
const actionName = item.name;
|
||||
let label = "";
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
label = t(item.contextItemLabel(elements, appState));
|
||||
} else {
|
||||
label = t(item.contextItemLabel);
|
||||
}
|
||||
const actionName = option.name;
|
||||
let label = "";
|
||||
if (option.contextItemLabel) {
|
||||
if (typeof option.contextItemLabel === "function") {
|
||||
label = t(option.contextItemLabel(elements, appState));
|
||||
} else {
|
||||
label = t(option.contextItemLabel);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
key={idx}
|
||||
data-testid={actionName}
|
||||
onClick={() => {
|
||||
// we need update state before executing the action in case
|
||||
// the action uses the appState it's being passed (that still
|
||||
// contains a defined contextMenu) to return the next state.
|
||||
setAppState({ contextMenu: null }, () => {
|
||||
actionManager.executeAction(item, "contextMenu");
|
||||
});
|
||||
}}
|
||||
}
|
||||
return (
|
||||
<li key={idx} data-testid={actionName} onClick={onCloseRequest}>
|
||||
<button
|
||||
className={clsx("context-menu-option", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: option.checked?.(appState),
|
||||
})}
|
||||
onClick={() =>
|
||||
actionManager.executeAction(option, "contextMenu")
|
||||
}
|
||||
>
|
||||
<button
|
||||
className={clsx("context-menu-item", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: item.checked?.(appState),
|
||||
})}
|
||||
>
|
||||
<div className="context-menu-item__label">{label}</div>
|
||||
<kbd className="context-menu-item__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
<div className="context-menu-option__label">{label}</div>
|
||||
<kbd className="context-menu-option__shortcut">
|
||||
{actionName
|
||||
? getShortcutFromShortcutName(actionName as ShortcutName)
|
||||
: ""}
|
||||
</kbd>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const contextMenuNodeByContainer = new WeakMap<HTMLElement, HTMLDivElement>();
|
||||
|
||||
const getContextMenuNode = (container: HTMLElement): HTMLDivElement => {
|
||||
let contextMenuNode = contextMenuNodeByContainer.get(container);
|
||||
if (contextMenuNode) {
|
||||
return contextMenuNode;
|
||||
}
|
||||
contextMenuNode = document.createElement("div");
|
||||
container
|
||||
.querySelector(".excalidraw-contextMenuContainer")!
|
||||
.appendChild(contextMenuNode);
|
||||
contextMenuNodeByContainer.set(container, contextMenuNode);
|
||||
return contextMenuNode;
|
||||
};
|
||||
|
||||
type ContextMenuParams = {
|
||||
options: (ContextMenuOption | false | null | undefined)[];
|
||||
top: ContextMenuProps["top"];
|
||||
left: ContextMenuProps["left"];
|
||||
actionManager: ContextMenuProps["actionManager"];
|
||||
appState: Readonly<AppState>;
|
||||
container: HTMLElement;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
};
|
||||
|
||||
const handleClose = (container: HTMLElement) => {
|
||||
const contextMenuNode = contextMenuNodeByContainer.get(container);
|
||||
if (contextMenuNode) {
|
||||
unmountComponentAtNode(contextMenuNode);
|
||||
contextMenuNode.remove();
|
||||
contextMenuNodeByContainer.delete(container);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
push(params: ContextMenuParams) {
|
||||
const options = Array.of<ContextMenuOption>();
|
||||
params.options.forEach((option) => {
|
||||
if (option) {
|
||||
options.push(option);
|
||||
}
|
||||
});
|
||||
if (options.length) {
|
||||
render(
|
||||
<ContextMenu
|
||||
top={params.top}
|
||||
left={params.left}
|
||||
options={options}
|
||||
onCloseRequest={() => handleClose(params.container)}
|
||||
actionManager={params.actionManager}
|
||||
appState={params.appState}
|
||||
elements={params.elements}
|
||||
/>,
|
||||
getContextMenuNode(params.container),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
@ -2,11 +2,7 @@ import clsx from "clsx";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useCallbackRefState } from "../hooks/useCallbackRefState";
|
||||
import { t } from "../i18n";
|
||||
import {
|
||||
useExcalidrawContainer,
|
||||
useDevice,
|
||||
useExcalidrawSetAppState,
|
||||
} from "../components/App";
|
||||
import { useExcalidrawContainer, useDevice } from "../components/App";
|
||||
import { KEYS } from "../keys";
|
||||
import "./Dialog.scss";
|
||||
import { back, CloseIcon } from "./icons";
|
||||
@ -14,8 +10,8 @@ import { Island } from "./Island";
|
||||
import { Modal } from "./Modal";
|
||||
import { AppState } from "../types";
|
||||
import { queryFocusableElements } from "../utils";
|
||||
import { isMenuOpenAtom, isDropdownOpenAtom } from "./App";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isLibraryMenuOpenAtom } from "./LibraryMenuHeaderContent";
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
@ -71,12 +67,12 @@ export const Dialog = (props: DialogProps) => {
|
||||
return () => islandNode.removeEventListener("keydown", handleKeyDown);
|
||||
}, [islandNode, props.autofocus]);
|
||||
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const setIsLibraryMenuOpen = useSetAtom(isLibraryMenuOpenAtom);
|
||||
const setIsMenuOpen = useSetAtom(isMenuOpenAtom);
|
||||
const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom);
|
||||
|
||||
const onClose = () => {
|
||||
setAppState({ openMenu: null });
|
||||
setIsLibraryMenuOpen(false);
|
||||
setIsMenuOpen(false);
|
||||
setIsDropdownOpen(false);
|
||||
(lastActiveElement as HTMLElement).focus();
|
||||
props.onCloseRequest();
|
||||
};
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { shield } from "../../components/icons";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
import { t } from "../../i18n";
|
||||
import { t } from "../i18n";
|
||||
import { shield } from "./icons";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
export const EncryptedIcon = () => (
|
||||
const EncryptedIcon = () => (
|
||||
<a
|
||||
className="encrypted-icon tooltip"
|
||||
href="https://blog.excalidraw.com/end-to-end-encryption/"
|
||||
@ -15,3 +15,5 @@ export const EncryptedIcon = () => (
|
||||
</Tooltip>
|
||||
</a>
|
||||
);
|
||||
|
||||
export default EncryptedIcon;
|
@ -96,10 +96,6 @@
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
margin: 0 0.2em;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
border-radius: 1rem;
|
||||
background-color: var(--button-color);
|
||||
|
@ -1,5 +1,3 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.FixedSideContainer {
|
||||
position: absolute;
|
||||
@ -11,10 +9,10 @@
|
||||
}
|
||||
|
||||
.FixedSideContainer_side_top {
|
||||
left: var(--editor-container-padding);
|
||||
top: var(--editor-container-padding);
|
||||
right: var(--editor-container-padding);
|
||||
bottom: var(--editor-container-padding);
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
@ -1,36 +1,35 @@
|
||||
import clsx from "clsx";
|
||||
import { actionShortcuts } from "../../actions";
|
||||
import { ActionManager } from "../../actions/manager";
|
||||
import { AppState } from "../../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import { AppState, ExcalidrawProps } from "../types";
|
||||
import {
|
||||
ExitZenModeAction,
|
||||
FinalizeAction,
|
||||
UndoRedoActions,
|
||||
ZoomActions,
|
||||
} from "../Actions";
|
||||
import { useDevice } from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { HelpButton } from "../HelpButton";
|
||||
import { Section } from "../Section";
|
||||
import Stack from "../Stack";
|
||||
} from "./Actions";
|
||||
import { useDevice } from "./App";
|
||||
import { WelcomeScreenHelpArrow } from "./icons";
|
||||
import { Section } from "./Section";
|
||||
import Stack from "./Stack";
|
||||
import WelcomeScreenDecor from "./WelcomeScreenDecor";
|
||||
|
||||
const Footer = ({
|
||||
appState,
|
||||
actionManager,
|
||||
renderCustomFooter,
|
||||
showExitZenModeBtn,
|
||||
renderWelcomeScreen,
|
||||
}: {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
showExitZenModeBtn: boolean;
|
||||
renderWelcomeScreen: boolean;
|
||||
}) => {
|
||||
const { footerCenterTunnel, welcomeScreenHelpHintTunnel } = useTunnels();
|
||||
|
||||
const device = useDevice();
|
||||
const showFinalize =
|
||||
!appState.viewModeEnabled && appState.multiElement && device.isTouchScreen;
|
||||
|
||||
return (
|
||||
<footer
|
||||
role="contentinfo"
|
||||
@ -70,17 +69,33 @@ const Footer = ({
|
||||
</Section>
|
||||
</Stack.Col>
|
||||
</div>
|
||||
<footerCenterTunnel.Out />
|
||||
<div
|
||||
className={clsx(
|
||||
"layer-ui__wrapper__footer-center zen-mode-transition",
|
||||
{
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{renderCustomFooter?.(false, appState)}
|
||||
</div>
|
||||
<div
|
||||
className={clsx("layer-ui__wrapper__footer-right zen-mode-transition", {
|
||||
"transition-right disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && <welcomeScreenHelpHintTunnel.Out />}
|
||||
<HelpButton
|
||||
onClick={() => actionManager.executeAction(actionShortcuts)}
|
||||
/>
|
||||
<WelcomeScreenDecor
|
||||
shouldRender={renderWelcomeScreen && !appState.isLoading}
|
||||
>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--help-pointer">
|
||||
<div>{t("welcomeScreen.helpHints")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
</WelcomeScreenDecor>
|
||||
|
||||
{actionManager.renderAction("toggleShortcuts")}
|
||||
</div>
|
||||
</div>
|
||||
<ExitZenModeAction
|
||||
@ -92,4 +107,3 @@ const Footer = ({
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
Footer.displayName = "Footer";
|
@ -1,32 +0,0 @@
|
||||
import "./ToolIcon.scss";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { handIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
type LockIconProps = {
|
||||
title?: string;
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
export const HandButton = (props: LockIconProps) => {
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={handIcon}
|
||||
name="editor-current-shape"
|
||||
checked={props.checked}
|
||||
title={`${props.title} — H`}
|
||||
keyBindingLabel={!props.isMobile ? KEYS.H.toLocaleUpperCase() : undefined}
|
||||
aria-label={`${props.title} — H`}
|
||||
aria-keyshortcuts={KEYS.H}
|
||||
data-testid={`toolbar-hand`}
|
||||
onChange={() => props.onChange?.()}
|
||||
/>
|
||||
);
|
||||
};
|
@ -1,12 +1,10 @@
|
||||
import React from "react";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { isDarwin, isWindows } from "../keys";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { getShortcutKey } from "../utils";
|
||||
import "./HelpDialog.scss";
|
||||
import { ExternalLinkIcon } from "./icons";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { isDarwin, isFirefox, isWindows } from "../constants";
|
||||
|
||||
const Header = () => (
|
||||
<div className="HelpDialog__header">
|
||||
@ -69,10 +67,6 @@ function* intersperse(as: JSX.Element[][], delim: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
const upperCaseSingleChars = (str: string) => {
|
||||
return str.replace(/\b[a-z]\b/, (c) => c.toUpperCase());
|
||||
};
|
||||
|
||||
const Shortcut = ({
|
||||
label,
|
||||
shortcuts,
|
||||
@ -87,9 +81,7 @@ const Shortcut = ({
|
||||
? [...shortcut.slice(0, -2).split("+"), "+"]
|
||||
: shortcut.split("+");
|
||||
|
||||
return keys.map((key) => (
|
||||
<ShortcutKey key={key}>{upperCaseSingleChars(key)}</ShortcutKey>
|
||||
));
|
||||
return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>);
|
||||
});
|
||||
|
||||
return (
|
||||
@ -126,50 +118,26 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
className="HelpDialog__island--tools"
|
||||
caption={t("helpDialog.tools")}
|
||||
>
|
||||
<Shortcut label={t("toolBar.hand")} shortcuts={[KEYS.H]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.selection")}
|
||||
shortcuts={[KEYS.V, KEYS["1"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.rectangle")}
|
||||
shortcuts={[KEYS.R, KEYS["2"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.diamond")}
|
||||
shortcuts={[KEYS.D, KEYS["3"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.ellipse")}
|
||||
shortcuts={[KEYS.O, KEYS["4"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.arrow")}
|
||||
shortcuts={[KEYS.A, KEYS["5"]]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.line")}
|
||||
shortcuts={[KEYS.L, KEYS["6"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.selection")} shortcuts={["V", "1"]} />
|
||||
<Shortcut label={t("toolBar.rectangle")} shortcuts={["R", "2"]} />
|
||||
<Shortcut label={t("toolBar.diamond")} shortcuts={["D", "3"]} />
|
||||
<Shortcut label={t("toolBar.ellipse")} shortcuts={["O", "4"]} />
|
||||
<Shortcut label={t("toolBar.arrow")} shortcuts={["A", "5"]} />
|
||||
<Shortcut label={t("toolBar.line")} shortcuts={["P", "6"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.freedraw")}
|
||||
shortcuts={[KEYS.P, KEYS["7"]]}
|
||||
shortcuts={["Shift + P", "X", "7"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("toolBar.text")}
|
||||
shortcuts={[KEYS.T, KEYS["8"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} />
|
||||
<Shortcut label={t("toolBar.text")} shortcuts={["T", "8"]} />
|
||||
<Shortcut label={t("toolBar.image")} shortcuts={["9"]} />
|
||||
<Shortcut label={t("toolBar.library")} shortcuts={["0"]} />
|
||||
<Shortcut
|
||||
label={t("toolBar.eraser")}
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
shortcuts={[getShortcutKey("E")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.editSelectedShape")}
|
||||
shortcuts={[
|
||||
getShortcutKey("CtrlOrCmd+Enter"),
|
||||
getShortcutKey(`CtrlOrCmd + ${t("helpDialog.doubleClick")}`),
|
||||
]}
|
||||
shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.textNewLine")}
|
||||
@ -205,7 +173,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
]}
|
||||
isOr={false}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} />
|
||||
<Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} />
|
||||
<Shortcut
|
||||
label={t("helpDialog.preventBinding")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd")]}
|
||||
@ -239,14 +207,6 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
label={t("helpDialog.zoomToSelection")}
|
||||
shortcuts={["Shift+2"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.movePageUpDown")}
|
||||
shortcuts={["PgUp/PgDn"]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("helpDialog.movePageLeftRight")}
|
||||
shortcuts={["Shift+PgUp/PgDn"]}
|
||||
/>
|
||||
<Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} />
|
||||
<Shortcut
|
||||
label={t("buttons.zenMode")}
|
||||
@ -310,17 +270,9 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+V")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.pasteAsPlaintext")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+V")]}
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
{/* firefox supports clipboard API under a flag, so we'll
|
||||
show users what they can do in the error message */}
|
||||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
<Shortcut
|
||||
label={t("labels.copyAsPng")}
|
||||
shortcuts={[getShortcutKey("Shift+Alt+C")]}
|
||||
/>
|
||||
)}
|
||||
<Shortcut
|
||||
label={t("labels.copyStyles")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]}
|
||||
@ -331,7 +283,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.delete")}
|
||||
shortcuts={[getShortcutKey("Delete")]}
|
||||
shortcuts={[getShortcutKey("Del")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.sendToBack")}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { canvasToBlob } from "../data/blob";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { CanvasError } from "../errors";
|
||||
import { t } from "../i18n";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { exportToCanvas } from "../scene/export";
|
||||
@ -12,7 +14,7 @@ import Stack from "./Stack";
|
||||
import "./ExportDialog.scss";
|
||||
import OpenColor from "open-color";
|
||||
import { CheckboxItem } from "./CheckboxItem";
|
||||
import { DEFAULT_EXPORT_PADDING, isFirefox } from "../constants";
|
||||
import { DEFAULT_EXPORT_PADDING } from "../constants";
|
||||
import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
@ -31,6 +33,19 @@ export const ErrorCanvasPreview = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderPreview = (
|
||||
content: HTMLCanvasElement | Error,
|
||||
previewNode: HTMLDivElement,
|
||||
) => {
|
||||
unmountComponentAtNode(previewNode);
|
||||
previewNode.innerHTML = "";
|
||||
if (content instanceof HTMLCanvasElement) {
|
||||
previewNode.appendChild(content);
|
||||
} else {
|
||||
render(<ErrorCanvasPreview />, previewNode);
|
||||
}
|
||||
};
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
scale?: number,
|
||||
@ -84,7 +99,6 @@ const ImageExportModal = ({
|
||||
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
|
||||
const previewRef = useRef<HTMLDivElement>(null);
|
||||
const { exportBackground, viewBackgroundColor } = appState;
|
||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState, true)
|
||||
@ -105,16 +119,15 @@ const ImageExportModal = ({
|
||||
exportPadding,
|
||||
})
|
||||
.then((canvas) => {
|
||||
setRenderError(null);
|
||||
// if converting to blob fails, there's some problem that will
|
||||
// likely prevent preview and export (e.g. canvas too big)
|
||||
return canvasToBlob(canvas).then(() => {
|
||||
previewNode.replaceChildren(canvas);
|
||||
renderPreview(canvas, previewNode);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
setRenderError(error);
|
||||
renderPreview(new CanvasError(), previewNode);
|
||||
});
|
||||
}, [
|
||||
appState,
|
||||
@ -127,9 +140,7 @@ const ImageExportModal = ({
|
||||
|
||||
return (
|
||||
<div className="ExportDialog">
|
||||
<div className="ExportDialog__preview" ref={previewRef}>
|
||||
{renderError && <ErrorCanvasPreview />}
|
||||
</div>
|
||||
<div className="ExportDialog__preview" ref={previewRef} />
|
||||
{supportsContextFilters &&
|
||||
actionManager.renderAction("exportWithDarkMode")}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr" }}>
|
||||
@ -190,9 +201,7 @@ const ImageExportModal = ({
|
||||
>
|
||||
SVG
|
||||
</ExportButton>
|
||||
{/* firefox supports clipboard API under a flag,
|
||||
so let's throw and tell people what they can do */}
|
||||
{(probablySupportsClipboardBlob || isFirefox) && (
|
||||
{probablySupportsClipboardBlob && (
|
||||
<ExportButton
|
||||
title={t("buttons.copyPngToClipboard")}
|
||||
onClick={() => onExportToClipboard(exportedElements)}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { AppState, ExportOpts, BinaryFiles } from "../types";
|
||||
import { Dialog } from "./Dialog";
|
||||
import { exportToFileIcon, LinkIcon } from "./icons";
|
||||
import { ExportIcon, exportToFileIcon, LinkIcon } from "./icons";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { actionSaveFileToDisk } from "../actions/actionExport";
|
||||
import { Card } from "./Card";
|
||||
@ -14,6 +14,7 @@ import { nativeFileSystemSupported } from "../data/filesystem";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getFrame } from "../utils";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
export type ExportCB = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
@ -93,7 +94,6 @@ export const JSONExportDialog = ({
|
||||
actionManager,
|
||||
exportOpts,
|
||||
canvas,
|
||||
setAppState,
|
||||
}: {
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
@ -101,15 +101,24 @@ export const JSONExportDialog = ({
|
||||
actionManager: ActionManager;
|
||||
exportOpts: ExportOpts;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => {
|
||||
const [modalIsShown, setModalIsShown] = useState(false);
|
||||
|
||||
const handleClose = React.useCallback(() => {
|
||||
setAppState({ openDialog: null });
|
||||
}, [setAppState]);
|
||||
setModalIsShown(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{appState.openDialog === "jsonExport" && (
|
||||
<MenuItem
|
||||
icon={ExportIcon}
|
||||
label={t("buttons.export")}
|
||||
onClick={() => {
|
||||
setModalIsShown(true);
|
||||
}}
|
||||
dataTestId="json-export-button"
|
||||
/>
|
||||
{modalIsShown && (
|
||||
<Dialog onCloseRequest={handleClose} title={t("buttons.export")}>
|
||||
<JSONExportModal
|
||||
elements={elements}
|
||||
|
@ -80,6 +80,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__wrapper__footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
.layer-ui__wrapper__footer-left,
|
||||
.layer-ui__wrapper__footer-right,
|
||||
.disable-zen-mode--visible {
|
||||
|
@ -9,8 +9,9 @@ import { Language, t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { ExportType } from "../scene/types";
|
||||
import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types";
|
||||
import { isShallowEqual, muteFSAbortError } from "../utils";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { ErrorDialog } from "./ErrorDialog";
|
||||
import { ExportCB, ImageExportDialog } from "./ImageExportDialog";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
@ -34,18 +35,26 @@ import "./LayerUI.scss";
|
||||
import "./Toolbar.scss";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "../components/App";
|
||||
import { isMenuOpenAtom, useDevice } from "../components/App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./footer/Footer";
|
||||
import Footer from "./Footer";
|
||||
import {
|
||||
ExportImageIcon,
|
||||
HamburgerMenuIcon,
|
||||
WelcomeScreenMenuArrow,
|
||||
WelcomeScreenTopToolbarArrow,
|
||||
} from "./icons";
|
||||
import { MenuLinks, Separator } from "./MenuUtils";
|
||||
import { useOutsideClickHook } from "../hooks/useOutsideClick";
|
||||
import WelcomeScreen from "./WelcomeScreen";
|
||||
import { hostSidebarCountersAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import { Provider, useAtom } from "jotai";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { TunnelsContext, useInitializeTunnels } from "./context/tunnels";
|
||||
import { useAtom } from "jotai";
|
||||
import { LanguageList } from "../excalidraw-app/components/LanguageList";
|
||||
import WelcomeScreenDecor from "./WelcomeScreenDecor";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import MenuItem from "./MenuItem";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
@ -54,13 +63,16 @@ interface LayerUIProps {
|
||||
canvas: HTMLCanvasElement | null;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
onInsertElements: (elements: readonly NonDeletedExcalidrawElement[]) => void;
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
isCollaborating: boolean;
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderMenuLinks?: ExcalidrawProps["renderMenuLinks"];
|
||||
renderCustomFooter?: ExcalidrawProps["renderFooter"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderCustomSidebar?: ExcalidrawProps["renderSidebar"];
|
||||
libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"];
|
||||
@ -70,35 +82,7 @@ interface LayerUIProps {
|
||||
id: string;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
}> = ({ UIOptions }) => {
|
||||
return (
|
||||
<MainMenu __fallback>
|
||||
<MainMenu.DefaultItems.LoadScene />
|
||||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
{/* FIXME we should to test for this inside the item itself */}
|
||||
{UIOptions.canvasActions.export && <MainMenu.DefaultItems.Export />}
|
||||
{/* FIXME we should to test for this inside the item itself */}
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
)}
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.Group title="Excalidraw links">
|
||||
<MainMenu.DefaultItems.Socials />
|
||||
</MainMenu.Group>
|
||||
<MainMenu.Separator />
|
||||
<MainMenu.DefaultItems.ToggleTheme />
|
||||
<MainMenu.DefaultItems.ChangeCanvasBackground />
|
||||
</MainMenu>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
@ -106,12 +90,15 @@ const LayerUI = ({
|
||||
setAppState,
|
||||
elements,
|
||||
canvas,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
onInsertElements,
|
||||
showExitZenModeBtn,
|
||||
isCollaborating,
|
||||
renderTopRightUI,
|
||||
renderMenuLinks,
|
||||
renderCustomFooter,
|
||||
renderCustomStats,
|
||||
renderCustomSidebar,
|
||||
libraryReturnUrl,
|
||||
@ -121,12 +108,9 @@ const LayerUI = ({
|
||||
id,
|
||||
onImageAction,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
|
||||
const tunnels = useInitializeTunnels();
|
||||
|
||||
const renderJSONExportDialog = () => {
|
||||
if (!UIOptions.canvasActions.export) {
|
||||
return null;
|
||||
@ -140,7 +124,6 @@ const LayerUI = ({
|
||||
actionManager={actionManager}
|
||||
exportOpts={UIOptions.canvasActions.export}
|
||||
canvas={canvas}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -194,12 +177,111 @@ const LayerUI = ({
|
||||
);
|
||||
};
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom);
|
||||
const menuRef = useOutsideClickHook(() => setIsMenuOpen(false));
|
||||
|
||||
const renderCanvasActions = () => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{/* wrapping to Fragment stops React from occasionally complaining
|
||||
about identical Keys */}
|
||||
<tunnels.mainMenuTunnel.Out />
|
||||
{renderWelcomeScreen && <tunnels.welcomeScreenMenuHintTunnel.Out />}
|
||||
<WelcomeScreenDecor
|
||||
shouldRender={renderWelcomeScreen && !appState.isLoading}
|
||||
>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--menu-pointer">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div>{t("welcomeScreen.menuHints")}</div>
|
||||
</div>
|
||||
</WelcomeScreenDecor>
|
||||
|
||||
<button
|
||||
data-prevent-outside-click
|
||||
className={clsx("menu-button", "zen-mode-transition", {
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
type="button"
|
||||
data-testid="menu-button"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</button>
|
||||
|
||||
{isMenuOpen && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{ position: "absolute", top: "100%", marginTop: ".25rem" }}
|
||||
>
|
||||
<Section heading="canvasActions">
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
<Island
|
||||
className="menu-container"
|
||||
padding={2}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{!appState.viewModeEnabled &&
|
||||
actionManager.renderAction("loadScene")}
|
||||
{/* // TODO barnabasmolnar/editor-redesign */}
|
||||
{/* is this fine here? */}
|
||||
{UIOptions.canvasActions.saveToActiveFile &&
|
||||
appState.fileHandle &&
|
||||
actionManager.renderAction("saveToActiveFile")}
|
||||
{renderJSONExportDialog()}
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MenuItem
|
||||
label={t("buttons.exportImage")}
|
||||
icon={ExportImageIcon}
|
||||
dataTestId="image-export-button"
|
||||
onClick={() => setAppState({ openDialog: "imageExport" })}
|
||||
shortcut={getShortcutFromShortcutName("imageExport")}
|
||||
/>
|
||||
)}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{actionManager.renderAction("toggleShortcuts", undefined, true)}
|
||||
{!appState.viewModeEnabled &&
|
||||
actionManager.renderAction("clearCanvas")}
|
||||
{typeof renderMenuLinks === "undefined" ? ( //zsviczian
|
||||
<Separator />
|
||||
) : (
|
||||
renderMenuLinks && <Separator />
|
||||
)}
|
||||
{typeof renderMenuLinks === "undefined" ? ( //zsviczian
|
||||
<MenuLinks />
|
||||
) : (
|
||||
renderMenuLinks && renderMenuLinks(device.isMobile, appState)
|
||||
)}
|
||||
<Separator />
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
rowGap: ".5rem",
|
||||
}}
|
||||
>
|
||||
<div>{actionManager.renderAction("toggleTheme")}</div>
|
||||
{UIOptions.showLanguageList !== false && (
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
<LanguageList style={{ width: "100%" }} />
|
||||
</div>
|
||||
)}
|
||||
{!appState.viewModeEnabled && (
|
||||
<div>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Island>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -236,6 +318,9 @@ const LayerUI = ({
|
||||
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
{renderWelcomeScreen && !appState.isLoading && (
|
||||
<WelcomeScreen appState={appState} actionManager={actionManager} />
|
||||
)}
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={6}
|
||||
@ -250,9 +335,17 @@ const LayerUI = ({
|
||||
<Section heading="shapes" className="shapes-section">
|
||||
{(heading: React.ReactNode) => (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.welcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<WelcomeScreenDecor
|
||||
shouldRender={renderWelcomeScreen && !appState.isLoading}
|
||||
>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--top-toolbar-pointer">
|
||||
<div className="WelcomeScreen-decor--top-toolbar-pointer__label">
|
||||
{t("welcomeScreen.toolbarHints")}
|
||||
</div>
|
||||
{WelcomeScreenTopToolbarArrow}
|
||||
</div>
|
||||
</WelcomeScreenDecor>
|
||||
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
@ -282,20 +375,13 @@ const LayerUI = ({
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
onChange={() => onLockToggle()}
|
||||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider"></div>
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
@ -307,6 +393,9 @@ const LayerUI = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{/* {actionManager.renderAction("eraser", {
|
||||
// size: "small",
|
||||
})} */}
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
</Stack.Row>
|
||||
@ -323,7 +412,18 @@ const LayerUI = ({
|
||||
},
|
||||
)}
|
||||
>
|
||||
<UserList collaborators={appState.collaborators} />
|
||||
<UserList
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isInHamburgerMenu={false}
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{renderTopRightUI?.(device.isMobile, appState)}
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton appState={appState} setAppState={setAppState} />
|
||||
@ -351,18 +451,8 @@ const LayerUI = ({
|
||||
|
||||
const [hostSidebarCounters] = useAtom(hostSidebarCountersAtom, jotaiScope);
|
||||
|
||||
const layerUIJSX = (
|
||||
return (
|
||||
<>
|
||||
{/* ------------------------- tunneled UI ---------------------------- */}
|
||||
{/* make sure we render host app components first so that we can detect
|
||||
them first on initial render to optimize layout shift */}
|
||||
{children}
|
||||
{/* render component fallbacks. Can be rendered anywhere as they'll be
|
||||
tunneled away. We only render tunneled components that actually
|
||||
have defaults when host do not render anything. */}
|
||||
<DefaultMainMenu UIOptions={UIOptions} />
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
{appState.errorMessage && (
|
||||
<ErrorDialog
|
||||
@ -377,9 +467,7 @@ const LayerUI = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{appState.pasteDialog.shown && (
|
||||
<PasteChartDialog
|
||||
setAppState={setAppState}
|
||||
@ -394,21 +482,26 @@ const LayerUI = ({
|
||||
)}
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onCollabButtonClick={onCollabButtonClick}
|
||||
onLockToggle={() => onLockToggle()}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
canvas={canvas}
|
||||
isCollaborating={isCollaborating}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
onImageAction={onImageAction}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderMenuLinks={renderMenuLinks}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
device={device}
|
||||
UIOptions={UIOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -431,13 +524,13 @@ const LayerUI = ({
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{renderWelcomeScreen && <tunnels.welcomeScreenCenterTunnel.Out />}
|
||||
{renderFixedSideContainer()}
|
||||
<Footer
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
appState={appState}
|
||||
actionManager={actionManager}
|
||||
renderCustomFooter={renderCustomFooter}
|
||||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
{appState.showStats && (
|
||||
<Stats
|
||||
@ -468,49 +561,31 @@ const LayerUI = ({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Provider scope={tunnels.jotaiScope}>
|
||||
<TunnelsContext.Provider value={tunnels}>
|
||||
{layerUIJSX}
|
||||
</TunnelsContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const stripIrrelevantAppStateProps = (
|
||||
appState: AppState,
|
||||
): Partial<AppState> => {
|
||||
const { suggestedBindings, startBoundElement, cursorButton, ...ret } =
|
||||
appState;
|
||||
return ret;
|
||||
};
|
||||
const areEqual = (prev: LayerUIProps, next: LayerUIProps) => {
|
||||
const getNecessaryObj = (appState: AppState): Partial<AppState> => {
|
||||
const {
|
||||
suggestedBindings,
|
||||
startBoundElement: boundElement,
|
||||
...ret
|
||||
} = appState;
|
||||
return ret;
|
||||
};
|
||||
const prevAppState = getNecessaryObj(prev.appState);
|
||||
const nextAppState = getNecessaryObj(next.appState);
|
||||
|
||||
const areEqual = (prevProps: LayerUIProps, nextProps: LayerUIProps) => {
|
||||
// short-circuit early
|
||||
if (prevProps.children !== nextProps.children) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
canvas: _prevCanvas,
|
||||
// not stable, but shouldn't matter in our case
|
||||
onInsertElements: _prevOnInsertElements,
|
||||
appState: prevAppState,
|
||||
...prev
|
||||
} = prevProps;
|
||||
const {
|
||||
canvas: _nextCanvas,
|
||||
onInsertElements: _nextOnInsertElements,
|
||||
appState: nextAppState,
|
||||
...next
|
||||
} = nextProps;
|
||||
const keys = Object.keys(prevAppState) as (keyof Partial<AppState>)[];
|
||||
|
||||
return (
|
||||
isShallowEqual(
|
||||
stripIrrelevantAppStateProps(prevAppState),
|
||||
stripIrrelevantAppStateProps(nextAppState),
|
||||
) && isShallowEqual(prev, next)
|
||||
prev.renderCustomFooter === next.renderCustomFooter &&
|
||||
prev.renderTopRightUI === next.renderTopRightUI &&
|
||||
prev.renderCustomStats === next.renderCustomStats &&
|
||||
prev.renderCustomSidebar === next.renderCustomSidebar &&
|
||||
prev.langCode === next.langCode &&
|
||||
prev.elements === next.elements &&
|
||||
prev.files === next.files &&
|
||||
keys.every((key) => prevAppState[key] === nextAppState[key])
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -22,7 +22,7 @@ export const LibraryButton: React.FC<{
|
||||
}
|
||||
|
||||
return (
|
||||
<label title={`${capitalizeString(t("toolBar.library"))}`}>
|
||||
<label title={`${capitalizeString(t("toolBar.library"))} — 0`}>
|
||||
<input
|
||||
className="ToolIcon_type_checkbox"
|
||||
type="checkbox"
|
||||
|
@ -129,27 +129,4 @@
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.layer-ui__sidebar__header .dropdown-menu {
|
||||
&.dropdown-menu--mobile {
|
||||
top: 100%;
|
||||
}
|
||||
.dropdown-menu-container {
|
||||
--gap: 0;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
width: 196px;
|
||||
box-shadow: var(--library-dropdown-shadow);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -13,15 +13,14 @@ import {
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { fileOpen } from "../data/filesystem";
|
||||
import { muteFSAbortError } from "../utils";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { jotaiScope } from "../jotai";
|
||||
import ConfirmDialog from "./ConfirmDialog";
|
||||
import PublishLibrary from "./PublishLibrary";
|
||||
import { Dialog } from "./Dialog";
|
||||
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
import { useOutsideClickHook } from "../hooks/useOutsideClick";
|
||||
import MenuItem from "./MenuItem";
|
||||
import { isDropdownOpenAtom } from "./App";
|
||||
|
||||
const getSelectedItems = (
|
||||
libraryItems: LibraryItems,
|
||||
@ -46,9 +45,7 @@ export const LibraryMenuHeader: React.FC<{
|
||||
appState,
|
||||
}) => {
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope);
|
||||
const [isLibraryMenuOpen, setIsLibraryMenuOpen] = useAtom(
|
||||
isLibraryMenuOpenAtom,
|
||||
);
|
||||
|
||||
const renderRemoveLibAlert = useCallback(() => {
|
||||
const content = selectedItems.length
|
||||
? t("alerts.removeItemsFromsLibrary", { count: selectedItems.length })
|
||||
@ -176,87 +173,85 @@ export const LibraryMenuHeader: React.FC<{
|
||||
});
|
||||
};
|
||||
|
||||
const renderLibraryMenu = () => {
|
||||
return (
|
||||
<DropdownMenu open={isLibraryMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="Sidebar__dropdown-btn"
|
||||
onToggle={() => setIsLibraryMenuOpen(!isLibraryMenuOpen)}
|
||||
>
|
||||
{DotsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsLibraryMenuOpen(false)}
|
||||
onSelect={() => setIsLibraryMenuOpen(false)}
|
||||
className="library-menu"
|
||||
>
|
||||
{!itemsSelected && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={onLibraryImport}
|
||||
icon={LoadIcon}
|
||||
data-testid="lib-dropdown--load"
|
||||
>
|
||||
{t("buttons.load")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={onLibraryExport}
|
||||
icon={ExportIcon}
|
||||
data-testid="lib-dropdown--export"
|
||||
>
|
||||
{t("buttons.export")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => setShowRemoveLibAlert(true)}
|
||||
icon={TrashIcon}
|
||||
>
|
||||
{resetLabel}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<DropdownMenu.Item
|
||||
icon={publishIcon}
|
||||
onSelect={() => setShowPublishLibraryDialog(true)}
|
||||
data-testid="lib-dropdown--remove"
|
||||
>
|
||||
{t("buttons.publishLibrary")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom);
|
||||
const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false));
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
{renderLibraryMenu()}
|
||||
<button
|
||||
type="button"
|
||||
className="Sidebar__dropdown-btn"
|
||||
data-prevent-outside-click
|
||||
onClick={() => setIsDropdownOpen(!isDropdownOpen)}
|
||||
>
|
||||
{DotsIcon}
|
||||
</button>
|
||||
|
||||
{selectedItems.length > 0 && (
|
||||
<div className="library-actions-counter">{selectedItems.length}</div>
|
||||
)}
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
className="Sidebar__dropdown-content menu-container"
|
||||
ref={dropdownRef}
|
||||
>
|
||||
{!itemsSelected && (
|
||||
<MenuItem
|
||||
label={t("buttons.load")}
|
||||
icon={LoadIcon}
|
||||
dataTestId="lib-dropdown--load"
|
||||
onClick={onLibraryImport}
|
||||
/>
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
{showRemoveLibAlert && renderRemoveLibAlert()}
|
||||
{showPublishLibraryDialog && (
|
||||
<PublishLibrary
|
||||
onClose={() => setShowPublishLibraryDialog(false)}
|
||||
libraryItems={getSelectedItems(
|
||||
libraryItemsData.libraryItems,
|
||||
selectedItems,
|
||||
)}
|
||||
appState={appState}
|
||||
onSuccess={(data) =>
|
||||
onPublishLibSuccess(data, libraryItemsData.libraryItems)
|
||||
}
|
||||
onError={(error) => window.alert(error)}
|
||||
updateItemsInStorage={() =>
|
||||
library.setLibrary(libraryItemsData.libraryItems)
|
||||
}
|
||||
onRemove={(id: string) =>
|
||||
onSelectItems(selectedItems.filter((_id) => _id !== id))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
{!!items.length && (
|
||||
<>
|
||||
<MenuItem
|
||||
label={t("buttons.export")}
|
||||
icon={ExportIcon}
|
||||
onClick={onLibraryExport}
|
||||
dataTestId="lib-dropdown--export"
|
||||
/>
|
||||
<MenuItem
|
||||
label={resetLabel}
|
||||
icon={TrashIcon}
|
||||
onClick={() => setShowRemoveLibAlert(true)}
|
||||
dataTestId="lib-dropdown--remove"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<MenuItem
|
||||
label={t("buttons.publishLibrary")}
|
||||
icon={publishIcon}
|
||||
dataTestId="lib-dropdown--publish"
|
||||
onClick={() => setShowPublishLibraryDialog(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{publishLibSuccess && renderPublishSuccess()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -44,7 +44,6 @@ export const LibraryUnit = ({
|
||||
},
|
||||
null,
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
node.innerHTML = svg.outerHTML;
|
||||
})();
|
||||
|
||||
|
@ -9,6 +9,7 @@ type LockIconProps = {
|
||||
name?: string;
|
||||
checked: boolean;
|
||||
onChange?(): void;
|
||||
zenModeEnabled?: boolean;
|
||||
isMobile?: boolean;
|
||||
};
|
||||
|
||||
|
85
src/components/Menu.scss
Normal file
85
src/components/Menu.scss
Normal file
@ -0,0 +1,85 @@
|
||||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.menu-container {
|
||||
background-color: #fff !important;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
padding: 0 0.625rem;
|
||||
height: 2rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-100);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.active-collab {
|
||||
background-color: #ecfdf5;
|
||||
color: #064e3c;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.menu-item {
|
||||
color: var(--color-gray-40);
|
||||
|
||||
&.active-collab {
|
||||
background-color: #064e3c;
|
||||
color: #ecfdf5;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
background-color: var(--color-gray-90) !important;
|
||||
}
|
||||
}
|
||||
}
|
37
src/components/MenuItem.tsx
Normal file
37
src/components/MenuItem.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import clsx from "clsx";
|
||||
import "./Menu.scss";
|
||||
|
||||
interface MenuProps {
|
||||
icon: JSX.Element;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
dataTestId: string;
|
||||
shortcut?: string;
|
||||
isCollaborating?: boolean;
|
||||
}
|
||||
|
||||
const MenuItem = ({
|
||||
icon,
|
||||
onClick,
|
||||
label,
|
||||
dataTestId,
|
||||
shortcut,
|
||||
isCollaborating,
|
||||
}: MenuProps) => {
|
||||
return (
|
||||
<button
|
||||
className={clsx("menu-item", { "active-collab": isCollaborating })}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
data-testid={dataTestId}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<div className="menu-item__icon">{icon}</div>
|
||||
<div className="menu-item__text">{label}</div>
|
||||
{shortcut && <div className="menu-item__shortcut">{shortcut}</div>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
53
src/components/MenuUtils.tsx
Normal file
53
src/components/MenuUtils.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { GithubIcon, DiscordIcon, PlusPromoIcon, TwitterIcon } from "./icons";
|
||||
|
||||
export const MenuLinks = () => (
|
||||
<>
|
||||
<a
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=hamburger"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="menu-item"
|
||||
style={{ color: "var(--color-promo)" }}
|
||||
>
|
||||
<div className="menu-item__icon">{PlusPromoIcon}</div>
|
||||
<div className="menu-item__text">Excalidraw+</div>
|
||||
</a>
|
||||
<a
|
||||
className="menu-item"
|
||||
href="https://github.com/excalidraw/excalidraw"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="menu-item__icon">{GithubIcon}</div>
|
||||
<div className="menu-item__text">GitHub</div>
|
||||
</a>
|
||||
<a
|
||||
className="menu-item"
|
||||
target="_blank"
|
||||
href="https://discord.gg/UexuTaE"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="menu-item__icon">{DiscordIcon}</div>
|
||||
<div className="menu-item__text">Discord</div>
|
||||
</a>
|
||||
<a
|
||||
className="menu-item"
|
||||
target="_blank"
|
||||
href="https://twitter.com/excalidraw"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="menu-item__icon">{TwitterIcon}</div>
|
||||
<div className="menu-item__text">Twitter</div>
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Separator = () => (
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: ".5rem 0",
|
||||
}}
|
||||
/>
|
||||
);
|
@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps } from "../types";
|
||||
import { AppProps, AppState, Device, ExcalidrawProps } from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
@ -11,15 +11,18 @@ import { HintViewer } from "./HintViewer";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { Section } from "./Section";
|
||||
import CollabButton from "./CollabButton";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { UserList } from "./UserList";
|
||||
import { LibraryButton } from "./LibraryButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { useTunnels } from "./context/tunnels";
|
||||
import { MenuLinks, Separator } from "./MenuUtils";
|
||||
import WelcomeScreen from "./WelcomeScreen";
|
||||
import MenuItem from "./MenuItem";
|
||||
import { ExportImageIcon } from "./icons";
|
||||
|
||||
type MobileMenuProps = {
|
||||
appState: AppState;
|
||||
@ -28,41 +31,56 @@ type MobileMenuProps = {
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onCollabButtonClick?: () => void;
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: () => void;
|
||||
canvas: HTMLCanvasElement | null;
|
||||
|
||||
isCollaborating: boolean;
|
||||
renderCustomFooter?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void;
|
||||
renderTopRightUI?: (
|
||||
isMobile: boolean,
|
||||
appState: AppState,
|
||||
) => JSX.Element | null;
|
||||
renderMenuLinks?: ExcalidrawProps["renderMenuLinks"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderWelcomeScreen?: boolean;
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
appState,
|
||||
elements,
|
||||
actionManager,
|
||||
renderJSONExportDialog,
|
||||
renderImageExportDialog,
|
||||
setAppState,
|
||||
onCollabButtonClick,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
canvas,
|
||||
isCollaborating,
|
||||
renderCustomFooter,
|
||||
onImageAction,
|
||||
renderTopRightUI,
|
||||
renderMenuLinks,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
renderWelcomeScreen,
|
||||
UIOptions,
|
||||
}: MobileMenuProps) => {
|
||||
const { welcomeScreenCenterTunnel, mainMenuTunnel } = useTunnels();
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
<welcomeScreenCenterTunnel.Out />
|
||||
{renderWelcomeScreen && !appState.isLoading && (
|
||||
<WelcomeScreen appState={appState} actionManager={actionManager} />
|
||||
)}
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
@ -70,6 +88,20 @@ export const MobileMenu = ({
|
||||
<Island padding={1} className="App-toolbar App-toolbar--mobile">
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
{/* <PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
/>
|
||||
<div className="App-toolbar__divider"></div> */}
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
canvas={canvas}
|
||||
@ -83,21 +115,15 @@ export const MobileMenu = ({
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<div className="mobile-misc-tools-container">
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
isMobile
|
||||
/>
|
||||
)}
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={onPenModeToggle}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
// penDetected={true}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
@ -105,12 +131,13 @@ export const MobileMenu = ({
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
/>
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
{!appState.viewModeEnabled && (
|
||||
<LibraryButton
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
isMobile
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
@ -130,14 +157,14 @@ export const MobileMenu = ({
|
||||
if (appState.viewModeEnabled) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<mainMenuTunnel.Out />
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<mainMenuTunnel.Out />
|
||||
{actionManager.renderAction("toggleCanvasMenu")}
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
@ -149,6 +176,68 @@ export const MobileMenu = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderCanvasActions = () => {
|
||||
if (appState.viewModeEnabled) {
|
||||
return (
|
||||
<>
|
||||
{renderJSONExportDialog()}
|
||||
<MenuItem
|
||||
label={t("buttons.exportImage")}
|
||||
icon={ExportImageIcon}
|
||||
dataTestId="image-export-button"
|
||||
onClick={() => setAppState({ openDialog: "imageExport" })}
|
||||
/>
|
||||
{renderImageExportDialog()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{!appState.viewModeEnabled && actionManager.renderAction("loadScene")}
|
||||
{renderJSONExportDialog()}
|
||||
{renderImageExportDialog()}
|
||||
{UIOptions.canvasActions.saveAsImage && (
|
||||
<MenuItem
|
||||
label={t("buttons.exportImage")}
|
||||
icon={ExportImageIcon}
|
||||
dataTestId="image-export-button"
|
||||
onClick={() => setAppState({ openDialog: "imageExport" })}
|
||||
/>
|
||||
)}
|
||||
{onCollabButtonClick && (
|
||||
<CollabButton
|
||||
isCollaborating={isCollaborating}
|
||||
collaboratorCount={appState.collaborators.size}
|
||||
onClick={onCollabButtonClick}
|
||||
/>
|
||||
)}
|
||||
{actionManager.renderAction("toggleShortcuts", undefined, true)}
|
||||
{!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")}
|
||||
{typeof renderMenuLinks === "undefined" ? ( //zsviczian
|
||||
<Separator />
|
||||
) : (
|
||||
renderMenuLinks && <Separator />
|
||||
)}
|
||||
{typeof renderMenuLinks === "undefined" ? ( //zsviczian
|
||||
<MenuLinks />
|
||||
) : (
|
||||
renderMenuLinks && renderMenuLinks(device.isMobile, appState)
|
||||
)}
|
||||
<Separator />
|
||||
{!appState.viewModeEnabled && (
|
||||
<div style={{ marginBottom: ".5rem" }}>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
{actionManager.renderAction("changeViewBackgroundColor")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{actionManager.renderAction("toggleTheme")}
|
||||
</>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
@ -173,9 +262,28 @@ export const MobileMenu = ({
|
||||
}}
|
||||
>
|
||||
<Island padding={0}>
|
||||
{appState.openMenu === "shape" &&
|
||||
!appState.viewModeEnabled &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
{appState.openMenu === "canvas" ? (
|
||||
<Section className="App-mobile-menu" heading="canvasActions">
|
||||
<div className="panelColumn">
|
||||
<Stack.Col gap={2}>
|
||||
{renderCanvasActions()}
|
||||
{renderCustomFooter?.(true, appState)}
|
||||
{appState.collaborators.size > 0 && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.collaborators")}</legend>
|
||||
<UserList
|
||||
mobile
|
||||
collaborators={appState.collaborators}
|
||||
actionManager={actionManager}
|
||||
/>
|
||||
</fieldset>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
</Section>
|
||||
) : appState.openMenu === "shape" &&
|
||||
!appState.viewModeEnabled &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
|
@ -46,7 +46,6 @@ const ChartPreviewBtn = (props: {
|
||||
},
|
||||
null, // files
|
||||
);
|
||||
svg.querySelector(".style-fonts")?.remove();
|
||||
previewNode.replaceChildren();
|
||||
previewNode.appendChild(svg);
|
||||
|
||||
|
@ -3,6 +3,24 @@
|
||||
|
||||
.excalidraw {
|
||||
.Sidebar {
|
||||
&__dropdown-content {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
margin-top: 0.25rem;
|
||||
width: 180px;
|
||||
box-shadow: var(--library-dropdown-shadow);
|
||||
border-radius: var(--border-radius-lg);
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
&__close-btn,
|
||||
&__pin-btn,
|
||||
&__dropdown-btn {
|
||||
|
@ -19,7 +19,7 @@ type ToolButtonBaseProps = {
|
||||
name?: string;
|
||||
id?: string;
|
||||
size?: ToolButtonSize;
|
||||
keyBindingLabel?: string | null;
|
||||
keyBindingLabel?: string;
|
||||
showAriaLabel?: boolean;
|
||||
hidden?: boolean;
|
||||
visible?: boolean;
|
||||
|
@ -7,7 +7,6 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
|
||||
&:empty {
|
||||
|
@ -4,16 +4,16 @@ import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { AppState, Collaborator } from "../types";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { useExcalidrawActionManager } from "./App";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
|
||||
export const UserList: React.FC<{
|
||||
className?: string;
|
||||
mobile?: boolean;
|
||||
collaborators: AppState["collaborators"];
|
||||
}> = ({ className, mobile, collaborators }) => {
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
actionManager: ActionManager;
|
||||
}> = ({ className, mobile, collaborators, actionManager }) => {
|
||||
const uniqueCollaborators = new Map<string, Collaborator>();
|
||||
|
||||
collaborators.forEach((collaborator, socketId) => {
|
||||
uniqueCollaborators.set(
|
||||
// filter on user id, else fall back on unique socketId
|
||||
@ -44,6 +44,26 @@ export const UserList: React.FC<{
|
||||
);
|
||||
});
|
||||
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// probably remove before shipping :)
|
||||
// 20 fake collaborators; for easy, convenient debug purposes ˇˇ
|
||||
// const avatars = Array.from({ length: 20 }).map((_, index) => {
|
||||
// const avatarJSX = actionManager.renderAction("goToCollaborator", [
|
||||
// index.toString(),
|
||||
// {
|
||||
// username: `User ${index}`,
|
||||
// },
|
||||
// ]);
|
||||
|
||||
// return mobile ? (
|
||||
// <Tooltip label={`User ${index}`} key={index}>
|
||||
// {avatarJSX}
|
||||
// </Tooltip>
|
||||
// ) : (
|
||||
// <React.Fragment key={index}>{avatarJSX}</React.Fragment>
|
||||
// );
|
||||
// });
|
||||
|
||||
return (
|
||||
<div className={clsx("UserList", className, { UserList_mobile: mobile })}>
|
||||
{avatars}
|
||||
|
@ -3,39 +3,29 @@
|
||||
font-family: "Virgil";
|
||||
}
|
||||
|
||||
// WelcomeSreen common
|
||||
// ---------------------------------------------------------------------------
|
||||
.WelcomeScreen-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
font-size: 2.25rem;
|
||||
|
||||
.welcome-screen-decor {
|
||||
svg {
|
||||
width: 1.625rem;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.WelcomeScreen-decor {
|
||||
pointer-events: none;
|
||||
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.welcome-screen-decor {
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
}
|
||||
|
||||
// WelcomeScreen.Hints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.welcome-screen-decor-hint {
|
||||
@media (max-height: 599px) {
|
||||
display: none !important;
|
||||
&--subheading {
|
||||
font-size: 1.125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px), (max-width: 800px) {
|
||||
.welcome-screen-decor {
|
||||
&--help,
|
||||
&--menu {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--help {
|
||||
&--help-pointer {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@ -59,7 +49,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--toolbar {
|
||||
&--top-toolbar-pointer {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
@ -68,7 +58,7 @@
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
.welcome-screen-decor-hint__label {
|
||||
&__label {
|
||||
width: 120px;
|
||||
position: relative;
|
||||
top: -0.5rem;
|
||||
@ -84,7 +74,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--menu {
|
||||
&--menu-pointer {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
font-size: 1rem;
|
||||
@ -105,19 +95,10 @@
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.welcome-screen-decor-hint__label {
|
||||
max-width: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WelcomeSreen.Center
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
.welcome-screen-center {
|
||||
.WelcomeScreen-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
@ -131,24 +112,7 @@
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
.welcome-screen-center__logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 0.75rem;
|
||||
font-size: 2.25rem;
|
||||
|
||||
svg {
|
||||
width: 1.625rem;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-screen-center__heading {
|
||||
font-size: 1.125rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-screen-menu {
|
||||
.WelcomeScreen-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
@ -156,7 +120,7 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item {
|
||||
.WelcomeScreen-item {
|
||||
box-sizing: border-box;
|
||||
|
||||
pointer-events: all;
|
||||
@ -164,10 +128,8 @@
|
||||
color: var(--color-gray-50);
|
||||
font-size: 0.875rem;
|
||||
|
||||
width: 100%;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
display: grid;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@ -178,49 +140,44 @@
|
||||
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
grid-template-columns: calc(var(--default-icon-size) + 0.5rem) 1fr 3rem;
|
||||
|
||||
&__text {
|
||||
&__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
column-gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
svg {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
margin-left: auto;
|
||||
color: var(--color-gray-40);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active) .welcome-screen-menu-item:hover {
|
||||
&:not(:active) .WelcomeScreen-item:hover {
|
||||
text-decoration: none;
|
||||
background: var(--color-gray-10);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
.WelcomeScreen-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item__text {
|
||||
.WelcomeScreen-item__label {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:active {
|
||||
.WelcomeScreen-item:active {
|
||||
background: var(--color-gray-20);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
.WelcomeScreen-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item__text {
|
||||
.WelcomeScreen-item__label {
|
||||
color: var(--color-gray-100);
|
||||
}
|
||||
|
||||
@ -228,7 +185,7 @@
|
||||
color: var(--color-promo) !important;
|
||||
|
||||
&:hover {
|
||||
.welcome-screen-menu-item__text {
|
||||
.WelcomeScreen-item__label {
|
||||
color: var(--color-promo) !important;
|
||||
}
|
||||
}
|
||||
@ -236,7 +193,11 @@
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.welcome-screen-menu-item {
|
||||
.WelcomeScreen-decor {
|
||||
color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
.WelcomeScreen-item {
|
||||
color: var(--color-gray-60);
|
||||
|
||||
&__shortcut {
|
||||
@ -244,41 +205,69 @@
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:active) .welcome-screen-menu-item:hover {
|
||||
&:not(:active) .WelcomeScreen-item:hover {
|
||||
background: var(--color-gray-85);
|
||||
|
||||
.welcome-screen-menu-item__shortcut {
|
||||
.WelcomeScreen-item__shortcut {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item__text {
|
||||
.WelcomeScreen-item__label {
|
||||
color: var(--color-gray-10);
|
||||
}
|
||||
}
|
||||
|
||||
.welcome-screen-menu-item:active {
|
||||
.WelcomeScreen-item:active {
|
||||
background-color: var(--color-gray-90);
|
||||
.welcome-screen-menu-item__text {
|
||||
.WelcomeScreen-item__label {
|
||||
color: var(--color-gray-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Can tweak these values but for an initial effort, it looks OK to me
|
||||
@media (max-width: 1024px) {
|
||||
.WelcomeScreen-decor {
|
||||
&--help-pointer,
|
||||
&--menu-pointer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @media (max-height: 400px) {
|
||||
// .WelcomeScreen-container {
|
||||
// margin-top: 0;
|
||||
// }
|
||||
// }
|
||||
@media (max-height: 599px) {
|
||||
.welcome-screen-center {
|
||||
.WelcomeScreen-container {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
}
|
||||
@media (min-height: 600px) and (max-height: 900px) {
|
||||
.welcome-screen-center {
|
||||
.WelcomeScreen-container {
|
||||
margin-top: 8rem;
|
||||
}
|
||||
}
|
||||
@media (max-height: 500px), (max-width: 320px) {
|
||||
.welcome-screen-center {
|
||||
@media (max-height: 630px) {
|
||||
.WelcomeScreen-decor--top-toolbar-pointer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (max-height: 500px) {
|
||||
.WelcomeScreen-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// @media (max-height: 740px) {
|
||||
// .WelcomeScreen-decor {
|
||||
// &--help-pointer,
|
||||
// &--top-toolbar-pointer,
|
||||
// &--menu-pointer {
|
||||
// display: none;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
141
src/components/WelcomeScreen.tsx
Normal file
141
src/components/WelcomeScreen.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { actionLoadScene, actionShortcuts } from "../actions";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { COOKIES } from "../constants";
|
||||
import { collabDialogShownAtom } from "../excalidraw-app/collab/Collab";
|
||||
import { t } from "../i18n";
|
||||
import { AppState } from "../types";
|
||||
import {
|
||||
ExcalLogo,
|
||||
HelpIcon,
|
||||
LoadIcon,
|
||||
PlusPromoIcon,
|
||||
UsersIcon,
|
||||
} from "./icons";
|
||||
import "./WelcomeScreen.scss";
|
||||
|
||||
const isExcalidrawPlusSignedUser = document.cookie.includes(
|
||||
COOKIES.AUTH_STATE_COOKIE,
|
||||
);
|
||||
|
||||
const WelcomeScreenItem = ({
|
||||
label,
|
||||
shortcut,
|
||||
onClick,
|
||||
icon,
|
||||
link,
|
||||
}: {
|
||||
label: string;
|
||||
shortcut: string | null;
|
||||
onClick?: () => void;
|
||||
icon: JSX.Element;
|
||||
link?: string;
|
||||
}) => {
|
||||
if (link) {
|
||||
return (
|
||||
<a
|
||||
className="WelcomeScreen-item"
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="WelcomeScreen-item__label">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="WelcomeScreen-item" type="button" onClick={onClick}>
|
||||
<div className="WelcomeScreen-item__label">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
{shortcut && (
|
||||
<div className="WelcomeScreen-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const WelcomeScreen = ({
|
||||
appState,
|
||||
actionManager,
|
||||
}: {
|
||||
appState: AppState;
|
||||
actionManager: ActionManager;
|
||||
}) => {
|
||||
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
||||
|
||||
let subheadingJSX;
|
||||
|
||||
if (isExcalidrawPlusSignedUser) {
|
||||
subheadingJSX = t("welcomeScreen.switchToPlusApp")
|
||||
.split(/(Excalidraw\+)/)
|
||||
.map((bit, idx) => {
|
||||
if (bit === "Excalidraw+") {
|
||||
return (
|
||||
<a
|
||||
style={{ pointerEvents: "all" }}
|
||||
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
key={idx}
|
||||
>
|
||||
Excalidraw+
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return bit;
|
||||
});
|
||||
} else {
|
||||
subheadingJSX = t("welcomeScreen.data");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="WelcomeScreen-container">
|
||||
<div className="WelcomeScreen-logo virgil WelcomeScreen-decor">
|
||||
{ExcalLogo} Excalidraw
|
||||
</div>
|
||||
<div className="virgil WelcomeScreen-decor WelcomeScreen-decor--subheading">
|
||||
{subheadingJSX}
|
||||
</div>
|
||||
<div className="WelcomeScreen-items">
|
||||
{!appState.viewModeEnabled && (
|
||||
<WelcomeScreenItem
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// do we want the internationalized labels here that are currently
|
||||
// in use elsewhere or new ones?
|
||||
label={t("buttons.load")}
|
||||
onClick={() => actionManager.executeAction(actionLoadScene)}
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
icon={LoadIcon}
|
||||
/>
|
||||
)}
|
||||
<WelcomeScreenItem
|
||||
label={t("labels.liveCollaboration")}
|
||||
shortcut={null}
|
||||
onClick={() => setCollabDialogShown(true)}
|
||||
icon={UsersIcon}
|
||||
/>
|
||||
<WelcomeScreenItem
|
||||
onClick={() => actionManager.executeAction(actionShortcuts)}
|
||||
label={t("helpDialog.title")}
|
||||
shortcut="?"
|
||||
icon={HelpIcon}
|
||||
/>
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
<WelcomeScreenItem
|
||||
link="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
label="Try Excalidraw Plus!"
|
||||
shortcut={null}
|
||||
icon={PlusPromoIcon}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WelcomeScreen;
|
11
src/components/WelcomeScreenDecor.tsx
Normal file
11
src/components/WelcomeScreenDecor.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const WelcomeScreenDecor = ({
|
||||
children,
|
||||
shouldRender,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
shouldRender: boolean;
|
||||
}) => (shouldRender ? <>{children}</> : null);
|
||||
|
||||
export default WelcomeScreenDecor;
|
@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import tunnel from "@dwelle/tunnel-rat";
|
||||
|
||||
type Tunnel = ReturnType<typeof tunnel>;
|
||||
|
||||
type TunnelsContextValue = {
|
||||
mainMenuTunnel: Tunnel;
|
||||
welcomeScreenMenuHintTunnel: Tunnel;
|
||||
welcomeScreenToolbarHintTunnel: Tunnel;
|
||||
welcomeScreenHelpHintTunnel: Tunnel;
|
||||
welcomeScreenCenterTunnel: Tunnel;
|
||||
footerCenterTunnel: Tunnel;
|
||||
jotaiScope: symbol;
|
||||
};
|
||||
|
||||
export const TunnelsContext = React.createContext<TunnelsContextValue>(null!);
|
||||
|
||||
export const useTunnels = () => React.useContext(TunnelsContext);
|
||||
|
||||
export const useInitializeTunnels = () => {
|
||||
return React.useMemo((): TunnelsContextValue => {
|
||||
return {
|
||||
mainMenuTunnel: tunnel(),
|
||||
welcomeScreenMenuHintTunnel: tunnel(),
|
||||
welcomeScreenToolbarHintTunnel: tunnel(),
|
||||
welcomeScreenHelpHintTunnel: tunnel(),
|
||||
welcomeScreenCenterTunnel: tunnel(),
|
||||
footerCenterTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
};
|
||||
}, []);
|
||||
};
|
@ -1,127 +0,0 @@
|
||||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 0.25rem;
|
||||
|
||||
&--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 0.75rem;
|
||||
|
||||
.dropdown-menu-container {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: #fff !important;
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
--gap: 2;
|
||||
}
|
||||
|
||||
.dropdown-menu-item-base {
|
||||
display: flex;
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-gray-100);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dropdown-menu-item {
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&__text {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__shortcut {
|
||||
margin-inline-start: auto;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-item-custom {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-menu-group-title {
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
margin: 10px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
&.theme--dark {
|
||||
.dropdown-menu-item {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--color-gray-90) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-button {
|
||||
@include outlineButtonStyles;
|
||||
background-color: var(--island-bg-color);
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size);
|
||||
height: var(--lg-icon-size);
|
||||
}
|
||||
|
||||
&--mobile {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: var(--default-button-size);
|
||||
height: var(--default-button-size);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
import React from "react";
|
||||
import DropdownMenuTrigger from "./DropdownMenuTrigger";
|
||||
import DropdownMenuItem from "./DropdownMenuItem";
|
||||
import MenuSeparator from "./DropdownMenuSeparator";
|
||||
import DropdownMenuGroup from "./DropdownMenuGroup";
|
||||
import DropdownMenuContent from "./DropdownMenuContent";
|
||||
import DropdownMenuItemLink from "./DropdownMenuItemLink";
|
||||
import DropdownMenuItemCustom from "./DropdownMenuItemCustom";
|
||||
import {
|
||||
getMenuContentComponent,
|
||||
getMenuTriggerComponent,
|
||||
} from "./dropdownMenuUtils";
|
||||
|
||||
import "./DropdownMenu.scss";
|
||||
|
||||
const DropdownMenu = ({
|
||||
children,
|
||||
open,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
}) => {
|
||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||
const MenuContentComp = getMenuContentComponent(children);
|
||||
return (
|
||||
<>
|
||||
{MenuTriggerComp}
|
||||
{open && MenuContentComp}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DropdownMenu.Trigger = DropdownMenuTrigger;
|
||||
DropdownMenu.Content = DropdownMenuContent;
|
||||
DropdownMenu.Item = DropdownMenuItem;
|
||||
DropdownMenu.ItemLink = DropdownMenuItemLink;
|
||||
DropdownMenu.ItemCustom = DropdownMenuItemCustom;
|
||||
DropdownMenu.Group = DropdownMenuGroup;
|
||||
DropdownMenu.Separator = MenuSeparator;
|
||||
|
||||
export default DropdownMenu;
|
||||
|
||||
DropdownMenu.displayName = "DropdownMenu";
|
@ -1,62 +0,0 @@
|
||||
import { useOutsideClickHook } from "../../hooks/useOutsideClick";
|
||||
import { Island } from "../Island";
|
||||
|
||||
import { useDevice } from "../App";
|
||||
import clsx from "clsx";
|
||||
import Stack from "../Stack";
|
||||
import React from "react";
|
||||
import { DropdownMenuContentPropsContext } from "./common";
|
||||
|
||||
const MenuContent = ({
|
||||
children,
|
||||
onClickOutside,
|
||||
className = "",
|
||||
onSelect,
|
||||
style,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClickOutside?: () => void;
|
||||
className?: string;
|
||||
/**
|
||||
* Called when any menu item is selected (clicked on).
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
style?: React.CSSProperties;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const menuRef = useOutsideClickHook(() => {
|
||||
onClickOutside?.();
|
||||
});
|
||||
|
||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||
"dropdown-menu--mobile": device.isMobile,
|
||||
}).trim();
|
||||
|
||||
return (
|
||||
<DropdownMenuContentPropsContext.Provider value={{ onSelect }}>
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={classNames}
|
||||
style={style}
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
{device.isMobile ? (
|
||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||
) : (
|
||||
<Island
|
||||
className="dropdown-menu-container"
|
||||
padding={2}
|
||||
style={{ zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</Island>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContentPropsContext.Provider>
|
||||
);
|
||||
};
|
||||
MenuContent.displayName = "DropdownMenuContent";
|
||||
|
||||
export default MenuContent;
|
@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const MenuGroup = ({
|
||||
children,
|
||||
className = "",
|
||||
style,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
title?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div className={`dropdown-menu-group ${className}`} style={style}>
|
||||
{title && <p className="dropdown-menu-group-title">{title}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuGroup;
|
||||
MenuGroup.displayName = "DropdownMenuGroup";
|
@ -1,40 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
getDropdownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
icon,
|
||||
onSelect,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
onSelect: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItem;
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
@ -1,23 +0,0 @@
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuItemContent = ({
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||
<div className="dropdown-menu-item__text">{children}</div>
|
||||
{shortcut && !device.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default MenuItemContent;
|
@ -1,21 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const DropdownMenuItemCustom = ({
|
||||
children,
|
||||
className = "",
|
||||
...rest
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
className={`dropdown-menu-item-base dropdown-menu-item-custom ${className}`.trim()}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItemCustom;
|
@ -1,44 +0,0 @@
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
import React from "react";
|
||||
import {
|
||||
getDropdownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
|
||||
const DropdownMenuItemLink = ({
|
||||
icon,
|
||||
shortcut,
|
||||
href,
|
||||
children,
|
||||
onSelect,
|
||||
className = "",
|
||||
...rest
|
||||
}: {
|
||||
href: string;
|
||||
icon?: JSX.Element;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
className?: string;
|
||||
onSelect?: (event: Event) => void;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
|
||||
return (
|
||||
<a
|
||||
{...rest}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={getDropdownMenuItemClassName(className)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default DropdownMenuItemLink;
|
||||
DropdownMenuItemLink.displayName = "DropdownMenuItemLink";
|
@ -1,14 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const MenuSeparator = () => (
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: ".5rem 0",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export default MenuSeparator;
|
||||
MenuSeparator.displayName = "DropdownMenuSeparator";
|
@ -1,37 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useDevice, useExcalidrawAppState } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
className = "",
|
||||
children,
|
||||
onToggle,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const device = useDevice();
|
||||
const classNames = clsx(
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
},
|
||||
).trim();
|
||||
return (
|
||||
<button
|
||||
data-prevent-outside-click
|
||||
className={classNames}
|
||||
onClick={onToggle}
|
||||
type="button"
|
||||
data-testid="dropdown-menu-button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuTrigger;
|
||||
MenuTrigger.displayName = "DropdownMenuTrigger";
|
@ -1,31 +0,0 @@
|
||||
import React, { useContext } from "react";
|
||||
import { EVENT } from "../../constants";
|
||||
import { composeEventHandlers } from "../../utils";
|
||||
|
||||
export const DropdownMenuContentPropsContext = React.createContext<{
|
||||
onSelect?: (event: Event) => void;
|
||||
}>({});
|
||||
|
||||
export const getDropdownMenuItemClassName = (className = "") => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}`.trim();
|
||||
};
|
||||
|
||||
export const useHandleDropdownMenuItemClick = (
|
||||
origOnClick:
|
||||
| React.MouseEventHandler<HTMLAnchorElement | HTMLButtonElement>
|
||||
| undefined,
|
||||
onSelect: ((event: Event) => void) | undefined,
|
||||
) => {
|
||||
const DropdownMenuContentProps = useContext(DropdownMenuContentPropsContext);
|
||||
|
||||
return composeEventHandlers(origOnClick, (event) => {
|
||||
const itemSelectEvent = new CustomEvent(EVENT.MENU_ITEM_SELECT, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
onSelect?.(itemSelectEvent);
|
||||
if (!itemSelectEvent.defaultPrevented) {
|
||||
DropdownMenuContentProps.onSelect?.(itemSelectEvent);
|
||||
}
|
||||
});
|
||||
};
|
@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const getMenuTriggerComponent = (children: React.ReactNode) => {
|
||||
const comp = React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
//@ts-ignore
|
||||
child?.type.displayName &&
|
||||
//@ts-ignore
|
||||
child.type.displayName === "DropdownMenuTrigger",
|
||||
);
|
||||
if (!comp) {
|
||||
return null;
|
||||
}
|
||||
//@ts-ignore
|
||||
return comp;
|
||||
};
|
||||
|
||||
export const getMenuContentComponent = (children: React.ReactNode) => {
|
||||
const comp = React.Children.toArray(children).find(
|
||||
(child) =>
|
||||
React.isValidElement(child) &&
|
||||
typeof child.type !== "string" &&
|
||||
//@ts-ignore
|
||||
child?.type.displayName &&
|
||||
//@ts-ignore
|
||||
child.type.displayName === "DropdownMenuContent",
|
||||
);
|
||||
if (!comp) {
|
||||
return null;
|
||||
}
|
||||
//@ts-ignore
|
||||
return comp;
|
||||
};
|
@ -1,10 +0,0 @@
|
||||
.footer-center {
|
||||
pointer-events: none;
|
||||
& > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import "./FooterCenter.scss";
|
||||
|
||||
const FooterCenter = ({ children }: { children?: React.ReactNode }) => {
|
||||
const { footerCenterTunnel } = useTunnels();
|
||||
const appState = useExcalidrawAppState();
|
||||
return (
|
||||
<footerCenterTunnel.In>
|
||||
<div
|
||||
className={clsx("footer-center zen-mode-transition", {
|
||||
"layer-ui__wrapper__footer-left--transition-bottom":
|
||||
appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</footerCenterTunnel.In>
|
||||
);
|
||||
};
|
||||
|
||||
export default FooterCenter;
|
||||
FooterCenter.displayName = "FooterCenter";
|
@ -1,52 +0,0 @@
|
||||
import { atom, useAtom } from "jotai";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
export const withInternalFallback = <P,>(
|
||||
componentName: string,
|
||||
Component: React.FC<P>,
|
||||
) => {
|
||||
const counterAtom = atom(0);
|
||||
// flag set on initial render to tell the fallback component to skip the
|
||||
// render until mount counter are initialized. This is because the counter
|
||||
// is initialized in an effect, and thus we could end rendering both
|
||||
// components at the same time until counter is initialized.
|
||||
let preferHost = false;
|
||||
|
||||
const WrapperComponent: React.FC<
|
||||
P & {
|
||||
__fallback?: boolean;
|
||||
}
|
||||
> = (props) => {
|
||||
const { jotaiScope } = useTunnels();
|
||||
const [counter, setCounter] = useAtom(counterAtom, jotaiScope);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setCounter((counter) => counter + 1);
|
||||
return () => {
|
||||
setCounter((counter) => counter - 1);
|
||||
};
|
||||
}, [setCounter]);
|
||||
|
||||
if (!props.__fallback) {
|
||||
preferHost = true;
|
||||
}
|
||||
|
||||
// ensure we don't render fallback and host components at the same time
|
||||
if (
|
||||
// either before the counters are initialized
|
||||
(!counter && props.__fallback && preferHost) ||
|
||||
// or after the counters are initialized, and both are rendered
|
||||
// (this is the default when host renders as well)
|
||||
(counter > 1 && props.__fallback)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
WrapperComponent.displayName = componentName;
|
||||
|
||||
return WrapperComponent;
|
||||
};
|
@ -883,7 +883,7 @@ export const CenterHorizontallyIcon = createIcon(
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const usersIcon = createIcon(
|
||||
export const UsersIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<circle cx="9" cy="7" r="4"></circle>
|
||||
@ -1470,11 +1470,11 @@ export const TextAlignRightIcon = createIcon(
|
||||
export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<g
|
||||
strokeWidth="1.5"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<line x1="4" y1="4" x2="20" y2="4" />
|
||||
@ -1488,11 +1488,11 @@ export const TextAlignTopIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<g
|
||||
strokeWidth="2"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<line x1="4" y1="20" x2="20" y2="20" />
|
||||
@ -1506,11 +1506,11 @@ export const TextAlignBottomIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
|
||||
createIcon(
|
||||
<g
|
||||
strokeWidth="1.5"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<line x1="4" y1="12" x2="9" y2="12" />
|
||||
@ -1532,14 +1532,3 @@ export const publishIcon = createIcon(
|
||||
export const eraser = createIcon(
|
||||
<path d="M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z" />,
|
||||
);
|
||||
|
||||
export const handIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M8 13v-7.5a1.5 1.5 0 0 1 3 0v6.5"></path>
|
||||
<path d="M11 5.5v-2a1.5 1.5 0 1 1 3 0v8.5"></path>
|
||||
<path d="M14 5.5a1.5 1.5 0 0 1 3 0v6.5"></path>
|
||||
<path d="M17 7.5a1.5 1.5 0 0 1 3 0v8.5a6 6 0 0 1 -6 6h-2h.208a6 6 0 0 1 -5.012 -2.7a69.74 69.74 0 0 1 -.196 -.3c-.312 -.479 -1.407 -2.388 -3.286 -5.728a1.5 1.5 0 0 1 .536 -2.022a1.867 1.867 0 0 1 2.28 .28l1.47 1.47"></path>
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user