mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-31 19:04:35 +01:00 
			
		
		
		
	Compare commits
	
		
			24 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 555bf6338f | ||
|   | 0bef3945f6 | ||
|   | dc97dc30bf | ||
|   | a0ecfed4cd | ||
|   | e201e79cd0 | ||
|   | e1c5c706c6 | ||
|   | bdc56090d7 | ||
|   | 58accc9310 | ||
|   | b91158198e | ||
|   | 938ce241ff | ||
|   | 0228646507 | ||
|   | 25ea97d0f9 | ||
|   | 8d5d68e589 | ||
|   | 6c15d9948b | ||
|   | e8fba43cf6 | ||
|   | 2e5c798c71 | ||
|   | 8c298336fc | ||
|   | 7f91cdc0c9 | ||
|   | 6334bd832f | ||
|   | 4d26993c8f | ||
|   | 1e69609ce4 | ||
|   | f5379d1563 | ||
|   | c8f6e3faa8 | ||
|   | 36bf17cf59 | 
| @@ -1,5 +1,6 @@ | ||||
| * | ||||
| !.env | ||||
| !.env.development | ||||
| !.env.production | ||||
| !.eslintrc.json | ||||
| !.npmrc | ||||
| !.prettierrc | ||||
|   | ||||
| @@ -20,3 +20,5 @@ 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 | ||||
|   | ||||
| @@ -74,9 +74,6 @@ | ||||
|     "prettier": "2.6.2", | ||||
|     "rewire": "6.0.0" | ||||
|   }, | ||||
|   "resolutions": { | ||||
|     "@typescript-eslint/typescript-estree": "5.10.2" | ||||
|   }, | ||||
|   "engines": { | ||||
|     "node": ">=14.0.0" | ||||
|   }, | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-Bold.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-Bold.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-Medium.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-Medium.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-Regular.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								public/Assistant-SemiBold.woff2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/Assistant-SemiBold.woff2
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| @@ -11,3 +11,28 @@ | ||||
|   src: url("Cascadia.woff2"); | ||||
|   font-display: swap; | ||||
| } | ||||
|  | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-Regular.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 400; | ||||
| } | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-Medium.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 500; | ||||
| } | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-SemiBold.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 600; | ||||
| } | ||||
| @font-face { | ||||
|   font-family: "Assistant"; | ||||
|   src: url("Assistant-Bold.woff2"); | ||||
|   font-display: swap; | ||||
|   font-weight: 700; | ||||
| } | ||||
|   | ||||
| @@ -8,49 +8,57 @@ | ||||
|       content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, shrink-to-fit=no" | ||||
|     /> | ||||
|     <meta name="referrer" content="origin" /> | ||||
|  | ||||
|     <meta name="mobile-web-app-capable" content="yes" /> | ||||
|     <meta name="theme-color" content="#121212" /> | ||||
|  | ||||
|     <meta name="theme-color" content="#000" /> | ||||
|     <!-- Primary Meta Tags --> | ||||
|     <meta | ||||
|       name="title" | ||||
|       content="Excalidraw — Collaborative whiteboarding made easy" | ||||
|     /> | ||||
|     <meta | ||||
|       name="description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta name="image" content="https://excalidraw.com/og-general-v1.png" /> | ||||
|  | ||||
|     <!-- Open Graph / Facebook --> | ||||
|     <meta property="og:site_name" content="Excalidraw" /> | ||||
|     <meta property="og:type" content="website" /> | ||||
|     <meta property="og:url" content="https://excalidraw.com" /> | ||||
|     <meta | ||||
|       property="og:title" | ||||
|       content="Excalidraw — Collaborative whiteboarding made easy" | ||||
|     /> | ||||
|     <meta property="og:image:alt" content="Excalidraw logo" /> | ||||
|     <meta | ||||
|       property="og:description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta property="og:image" content="https://excalidraw.com/og-fb-v1.png" /> | ||||
|  | ||||
|     <!-- Twitter --> | ||||
|     <meta property="twitter:card" content="summary_large_image" /> | ||||
|     <meta property="twitter:site" content="@excalidraw" /> | ||||
|     <meta property="twitter:url" content="https://excalidraw.com" /> | ||||
|     <meta | ||||
|       property="twitter:title" | ||||
|       content="Excalidraw — Collaborative whiteboarding made easy" | ||||
|     /> | ||||
|     <meta | ||||
|       property="twitter:description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta | ||||
|       property="twitter:image" | ||||
|       content="https://excalidraw.com/og-twitter-v1.png" | ||||
|     /> | ||||
|  | ||||
|     <!-- General tags --> | ||||
|     <meta | ||||
|       name="description" | ||||
|       content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <meta name="image" content="og-image.png" /> | ||||
|  | ||||
|     <!-- OpenGraph tags --> | ||||
|     <meta property="og:url" content="https://excalidraw.com" /> | ||||
|     <meta property="og:site_name" content="Excalidraw" /> | ||||
|     <meta property="og:type" content="website" /> | ||||
|     <meta property="og:title" content="Excalidraw" /> | ||||
|     <meta | ||||
|       property="og:description" | ||||
|       content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|     <!-- OG tags require an absolute url for images --> | ||||
|     <meta | ||||
|       property="og:image" | ||||
|       name="twitter:image" | ||||
|       content="https://excalidraw.com/og-image.png" | ||||
|     /> | ||||
|     <meta | ||||
|       property="og:image:secure_url" | ||||
|       name="twitter:image" | ||||
|       content="https://excalidraw.com/og-image.png" | ||||
|     /> | ||||
|     <meta property="og:image:width" content="1280" /> | ||||
|     <meta property="og:image:height" content="669" /> | ||||
|     <meta property="og:image:alt" content="Excalidraw logo with byline." /> | ||||
|  | ||||
|     <!-- Twitter Card tags --> | ||||
|     <meta name="twitter:card" content="summary_large_image" /> | ||||
|     <meta name="twitter:title" content="Excalidraw" /> | ||||
|     <meta | ||||
|       name="twitter:description" | ||||
|       content="Excalidraw is a whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them." | ||||
|     /> | ||||
|  | ||||
|     <!-------------------------------------------------------------------------> | ||||
|     <!--   to minimize white flash on load when user has dark mode enabled   --> | ||||
| @@ -158,8 +166,8 @@ | ||||
|       body, | ||||
|       html { | ||||
|         margin: 0; | ||||
|         --ui-font: system-ui, BlinkMacSystemFont, -apple-system, Segoe UI, | ||||
|           Roboto, Helvetica, Arial, sans-serif; | ||||
|         --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%; | ||||
|  | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								public/og-fb-v1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/og-fb-v1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 26 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/og-general-v1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/og-general-v1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 26 KiB | 
							
								
								
									
										
											BIN
										
									
								
								public/og-twitter-v1.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								public/og-twitter-v1.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 27 KiB | 
| @@ -1,3 +1,9 @@ | ||||
| User-agent: Twitterbot | ||||
| Disallow: | ||||
|  | ||||
| User-agent: facebookexternalhit | ||||
| Disallow: | ||||
|  | ||||
| user-agent: * | ||||
| Allow: /$ | ||||
| Disallow: / | ||||
|   | ||||
| @@ -60,7 +60,7 @@ export const actionAlignTop = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignTopIcon theme={appState.theme} />} | ||||
|       icon={AlignTopIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignTop")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Up", | ||||
| @@ -90,7 +90,7 @@ export const actionAlignBottom = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignBottomIcon theme={appState.theme} />} | ||||
|       icon={AlignBottomIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignBottom")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Down", | ||||
| @@ -120,7 +120,7 @@ export const actionAlignLeft = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignLeftIcon theme={appState.theme} />} | ||||
|       icon={AlignLeftIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignLeft")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Left", | ||||
| @@ -151,7 +151,7 @@ export const actionAlignRight = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<AlignRightIcon theme={appState.theme} />} | ||||
|       icon={AlignRightIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.alignRight")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+Shift+Right", | ||||
| @@ -180,7 +180,7 @@ export const actionAlignVerticallyCentered = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<CenterVerticallyIcon theme={appState.theme} />} | ||||
|       icon={CenterVerticallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={t("labels.centerVertically")} | ||||
|       aria-label={t("labels.centerVertically")} | ||||
| @@ -206,7 +206,7 @@ export const actionAlignHorizontallyCentered = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<CenterHorizontallyIcon theme={appState.theme} />} | ||||
|       icon={CenterHorizontallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={t("labels.centerHorizontally")} | ||||
|       aria-label={t("labels.centerHorizontally")} | ||||
|   | ||||
| @@ -1,8 +1,13 @@ | ||||
| import { ColorPicker } from "../components/ColorPicker"; | ||||
| import { eraser, zoomIn, zoomOut } from "../components/icons"; | ||||
| import { | ||||
|   eraser, | ||||
|   MoonIcon, | ||||
|   SunIcon, | ||||
|   ZoomInIcon, | ||||
|   ZoomOutIcon, | ||||
| } from "../components/icons"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { DarkModeToggle } from "../components/DarkModeToggle"; | ||||
| import { 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"; | ||||
| @@ -18,6 +23,8 @@ import { newElementWith } from "../element/mutateElement"; | ||||
| 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", | ||||
| @@ -103,13 +110,13 @@ export const actionZoomIn = register({ | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={zoomIn} | ||||
|       className="zoom-in-button zoom-button" | ||||
|       icon={ZoomInIcon} | ||||
|       title={`${t("buttons.zoomIn")} — ${getShortcutKey("CtrlOrCmd++")}`} | ||||
|       aria-label={t("buttons.zoomIn")} | ||||
|       onClick={() => { | ||||
|         updateData(null); | ||||
|       }} | ||||
|       size="small" | ||||
|     /> | ||||
|   ), | ||||
|   keyTest: (event) => | ||||
| @@ -139,13 +146,13 @@ export const actionZoomOut = register({ | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={zoomOut} | ||||
|       className="zoom-out-button zoom-button" | ||||
|       icon={ZoomOutIcon} | ||||
|       title={`${t("buttons.zoomOut")} — ${getShortcutKey("CtrlOrCmd+-")}`} | ||||
|       aria-label={t("buttons.zoomOut")} | ||||
|       onClick={() => { | ||||
|         updateData(null); | ||||
|       }} | ||||
|       size="small" | ||||
|     /> | ||||
|   ), | ||||
|   keyTest: (event) => | ||||
| @@ -176,13 +183,12 @@ export const actionResetZoom = register({ | ||||
|     <Tooltip label={t("buttons.resetZoom")} style={{ height: "100%" }}> | ||||
|       <ToolButton | ||||
|         type="button" | ||||
|         className="reset-zoom-button" | ||||
|         className="reset-zoom-button zoom-button" | ||||
|         title={t("buttons.resetZoom")} | ||||
|         aria-label={t("buttons.resetZoom")} | ||||
|         onClick={() => { | ||||
|           updateData(null); | ||||
|         }} | ||||
|         size="small" | ||||
|       > | ||||
|         {(appState.zoom.value * 100).toFixed(0)}% | ||||
|       </ToolButton> | ||||
| @@ -206,7 +212,7 @@ const zoomValueToFitBoundsOnViewport = ( | ||||
|   const zoomAdjustedToSteps = | ||||
|     Math.floor(smallestZoomValue / ZOOM_STEP) * ZOOM_STEP; | ||||
|   const clampedZoomValueToFitElements = Math.min( | ||||
|     Math.max(zoomAdjustedToSteps, ZOOM_STEP), | ||||
|     Math.max(zoomAdjustedToSteps, MIN_ZOOM), | ||||
|     1, | ||||
|   ); | ||||
|   return clampedZoomValueToFitElements as NormalizedZoomValue; | ||||
| @@ -288,14 +294,19 @@ export const actionToggleTheme = register({ | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ appState, updateData }) => ( | ||||
|     <div style={{ marginInlineStart: "0.25rem" }}> | ||||
|       <DarkModeToggle | ||||
|         value={appState.theme} | ||||
|         onChange={(theme) => { | ||||
|           updateData(theme); | ||||
|         }} | ||||
|       /> | ||||
|     </div> | ||||
|     <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, | ||||
| }); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { isSomeElementSelected } from "../scene"; | ||||
| import { KEYS } from "../keys"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { trash } from "../components/icons"; | ||||
| import { t } from "../i18n"; | ||||
| import { register } from "./register"; | ||||
| import { getNonDeletedElements } from "../element"; | ||||
| @@ -13,6 +12,7 @@ import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| import { fixBindingsAfterDeletion } from "../element/binding"; | ||||
| import { isBoundToContainer } from "../element/typeChecks"; | ||||
| import { updateActiveTool } from "../utils"; | ||||
| import { TrashIcon } from "../components/icons"; | ||||
|  | ||||
| const deleteSelectedElements = ( | ||||
|   elements: readonly ExcalidrawElement[], | ||||
| @@ -72,13 +72,22 @@ export const actionDeleteSelected = register({ | ||||
|       if (!element) { | ||||
|         return false; | ||||
|       } | ||||
|       if ( | ||||
|         // case: no point selected → delete whole element | ||||
|         selectedPointsIndices == null || | ||||
|         // case: deleting last remaining point | ||||
|         element.points.length < 2 | ||||
|       ) { | ||||
|         const nextElements = elements.filter((el) => el.id !== element.id); | ||||
|       // case: no point selected → do nothing, as deleting the whole element | ||||
|       // is most likely a mistake, where you wanted to delete a specific point | ||||
|       // but failed to select it (or you thought it's selected, while it was | ||||
|       // only in a hover state) | ||||
|       if (selectedPointsIndices == null) { | ||||
|         return false; | ||||
|       } | ||||
|  | ||||
|       // case: deleting last remaining point | ||||
|       if (element.points.length < 2) { | ||||
|         const nextElements = elements.map((el) => { | ||||
|           if (el.id === element.id) { | ||||
|             return newElementWith(el, { isDeleted: true }); | ||||
|           } | ||||
|           return el; | ||||
|         }); | ||||
|         const nextAppState = handleGroupEditingState(appState, nextElements); | ||||
|  | ||||
|         return { | ||||
| @@ -149,7 +158,7 @@ export const actionDeleteSelected = register({ | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={trash} | ||||
|       icon={TrashIcon} | ||||
|       title={t("labels.delete")} | ||||
|       aria-label={t("labels.delete")} | ||||
|       onClick={() => updateData(null)} | ||||
|   | ||||
| @@ -56,7 +56,7 @@ export const distributeHorizontally = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<DistributeHorizontallyIcon theme={appState.theme} />} | ||||
|       icon={DistributeHorizontallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.distributeHorizontally")} — ${getShortcutKey( | ||||
|         "Alt+H", | ||||
| @@ -86,7 +86,7 @@ export const distributeVertically = register({ | ||||
|     <ToolButton | ||||
|       hidden={!enableActionGroup(elements, appState)} | ||||
|       type="button" | ||||
|       icon={<DistributeVerticallyIcon theme={appState.theme} />} | ||||
|       icon={DistributeVerticallyIcon} | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.distributeVertically")} — ${getShortcutKey("Alt+V")}`} | ||||
|       aria-label={t("labels.distributeVertically")} | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import { ExcalidrawElement } from "../element/types"; | ||||
| import { duplicateElement, getNonDeletedElements } from "../element"; | ||||
| import { getSelectedElements, isSomeElementSelected } from "../scene"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { clone } from "../components/icons"; | ||||
| import { t } from "../i18n"; | ||||
| import { arrayToMap, getShortcutKey } from "../utils"; | ||||
| import { LinearElementEditor } from "../element/linearElementEditor"; | ||||
| @@ -19,6 +18,7 @@ import { ActionResult } from "./types"; | ||||
| import { GRID_SIZE } from "../constants"; | ||||
| import { bindTextToShapeAfterDuplication } from "../element/textElement"; | ||||
| import { isBoundToContainer } from "../element/typeChecks"; | ||||
| import { DuplicateIcon } from "../components/icons"; | ||||
|  | ||||
| export const actionDuplicateSelection = register({ | ||||
|   name: "duplicateSelection", | ||||
| @@ -49,7 +49,7 @@ export const actionDuplicateSelection = register({ | ||||
|   PanelComponent: ({ elements, appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={clone} | ||||
|       icon={DuplicateIcon} | ||||
|       title={`${t("labels.duplicateSelection")} — ${getShortcutKey( | ||||
|         "CtrlOrCmd+D", | ||||
|       )}`} | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { load, 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"; | ||||
| @@ -19,6 +19,8 @@ import { ActiveFile } from "../components/ActiveFile"; | ||||
| import { isImageFileHandle } from "../data/blob"; | ||||
| import { nativeFileSystemSupported } from "../data/filesystem"; | ||||
| import { Theme } from "../element/types"; | ||||
| import MenuItem from "../components/MenuItem"; | ||||
| import { getShortcutFromShortcutName } from "./shortcuts"; | ||||
|  | ||||
| export const actionChangeProjectName = register({ | ||||
|   name: "changeProjectName", | ||||
| @@ -245,14 +247,12 @@ export const actionLoadScene = register({ | ||||
|   }, | ||||
|   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.O, | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={load} | ||||
|       title={t("buttons.load")} | ||||
|       aria-label={t("buttons.load")} | ||||
|       showAriaLabel={useDevice().isMobile} | ||||
|     <MenuItem | ||||
|       label={t("buttons.load")} | ||||
|       icon={LoadIcon} | ||||
|       onClick={updateData} | ||||
|       data-testid="load-button" | ||||
|       dataTestId="load-button" | ||||
|       shortcut={getShortcutFromShortcutName("loadScene")} | ||||
|     /> | ||||
|   ), | ||||
| }); | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Action, ActionResult } from "./types"; | ||||
| import { undo, redo } from "../components/icons"; | ||||
| import { UndoIcon, RedoIcon } from "../components/icons"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { t } from "../i18n"; | ||||
| import History, { HistoryEntry } from "../history"; | ||||
| @@ -72,7 +72,7 @@ export const createUndoAction: ActionCreator = (history) => ({ | ||||
|   PanelComponent: ({ updateData, data }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={undo} | ||||
|       icon={UndoIcon} | ||||
|       aria-label={t("buttons.undo")} | ||||
|       onClick={updateData} | ||||
|       size={data?.size || "medium"} | ||||
| @@ -94,7 +94,7 @@ export const createRedoAction: ActionCreator = (history) => ({ | ||||
|   PanelComponent: ({ updateData, data }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={redo} | ||||
|       icon={RedoIcon} | ||||
|       aria-label={t("buttons.redo")} | ||||
|       onClick={updateData} | ||||
|       size={data?.size || "medium"} | ||||
|   | ||||
| @@ -1,11 +1,12 @@ | ||||
| import { menu, 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 { HelpIcon } from "../components/HelpIcon"; | ||||
| import { HelpButton } from "../components/HelpButton"; | ||||
| import MenuItem from "../components/MenuItem"; | ||||
|  | ||||
| export const actionToggleCanvasMenu = register({ | ||||
|   name: "toggleCanvasMenu", | ||||
| @@ -20,7 +21,7 @@ export const actionToggleCanvasMenu = register({ | ||||
|   PanelComponent: ({ appState, updateData }) => ( | ||||
|     <ToolButton | ||||
|       type="button" | ||||
|       icon={menu} | ||||
|       icon={HamburgerMenuIcon} | ||||
|       aria-label={t("buttons.menu")} | ||||
|       onClick={updateData} | ||||
|       selected={appState.openMenu === "canvas"} | ||||
| @@ -74,19 +75,28 @@ export const actionShortcuts = register({ | ||||
|   name: "toggleShortcuts", | ||||
|   trackEvent: { category: "menu", action: "toggleHelpDialog" }, | ||||
|   perform: (_elements, appState, _, { focusContainer }) => { | ||||
|     if (appState.showHelpDialog) { | ||||
|     if (appState.openDialog === "help") { | ||||
|       focusContainer(); | ||||
|     } | ||||
|     return { | ||||
|       appState: { | ||||
|         ...appState, | ||||
|         showHelpDialog: !appState.showHelpDialog, | ||||
|         openDialog: appState.openDialog === "help" ? null : "help", | ||||
|       }, | ||||
|       commitToHistory: false, | ||||
|     }; | ||||
|   }, | ||||
|   PanelComponent: ({ updateData }) => ( | ||||
|     <HelpIcon title={t("helpDialog.title")} onClick={updateData} /> | ||||
|   ), | ||||
|   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, | ||||
| }); | ||||
|   | ||||
| @@ -2,37 +2,41 @@ import { AppState } from "../../src/types"; | ||||
| import { ButtonIconSelect } from "../components/ButtonIconSelect"; | ||||
| import { ColorPicker } from "../components/ColorPicker"; | ||||
| import { IconPicker } from "../components/IconPicker"; | ||||
| // TODO barnabasmolnar/editor-redesign | ||||
| // TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon, | ||||
| // ArrowHead icons | ||||
| import { | ||||
|   ArrowheadArrowIcon, | ||||
|   ArrowheadBarIcon, | ||||
|   ArrowheadDotIcon, | ||||
|   ArrowheadTriangleIcon, | ||||
|   ArrowheadNoneIcon, | ||||
|   EdgeRoundIcon, | ||||
|   EdgeSharpIcon, | ||||
|   FillCrossHatchIcon, | ||||
|   FillHachureIcon, | ||||
|   FillSolidIcon, | ||||
|   FontFamilyCodeIcon, | ||||
|   FontFamilyHandDrawnIcon, | ||||
|   FontFamilyNormalIcon, | ||||
|   FontSizeExtraLargeIcon, | ||||
|   FontSizeLargeIcon, | ||||
|   FontSizeMediumIcon, | ||||
|   FontSizeSmallIcon, | ||||
|   SloppinessArchitectIcon, | ||||
|   SloppinessArtistIcon, | ||||
|   SloppinessCartoonistIcon, | ||||
|   StrokeStyleDashedIcon, | ||||
|   StrokeStyleDottedIcon, | ||||
|   StrokeStyleSolidIcon, | ||||
|   StrokeWidthIcon, | ||||
|   TextAlignCenterIcon, | ||||
|   TextAlignLeftIcon, | ||||
|   TextAlignRightIcon, | ||||
|   TextAlignTopIcon, | ||||
|   TextAlignBottomIcon, | ||||
|   TextAlignMiddleIcon, | ||||
|   FillHachureIcon, | ||||
|   FillCrossHatchIcon, | ||||
|   FillSolidIcon, | ||||
|   SloppinessArchitectIcon, | ||||
|   SloppinessArtistIcon, | ||||
|   SloppinessCartoonistIcon, | ||||
|   StrokeWidthBaseIcon, | ||||
|   StrokeWidthBoldIcon, | ||||
|   StrokeWidthExtraBoldIcon, | ||||
|   FontSizeSmallIcon, | ||||
|   FontSizeMediumIcon, | ||||
|   FontSizeLargeIcon, | ||||
|   FontSizeExtraLargeIcon, | ||||
|   EdgeSharpIcon, | ||||
|   EdgeRoundIcon, | ||||
|   FreedrawIcon, | ||||
|   FontFamilyNormalIcon, | ||||
|   FontFamilyCodeIcon, | ||||
|   TextAlignLeftIcon, | ||||
|   TextAlignCenterIcon, | ||||
|   TextAlignRightIcon, | ||||
| } from "../components/icons"; | ||||
| import { | ||||
|   DEFAULT_FONT_FAMILY, | ||||
| @@ -307,17 +311,17 @@ export const actionChangeFillStyle = register({ | ||||
|           { | ||||
|             value: "hachure", | ||||
|             text: t("labels.hachure"), | ||||
|             icon: <FillHachureIcon theme={appState.theme} />, | ||||
|             icon: FillHachureIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "cross-hatch", | ||||
|             text: t("labels.crossHatch"), | ||||
|             icon: <FillCrossHatchIcon theme={appState.theme} />, | ||||
|             icon: FillCrossHatchIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "solid", | ||||
|             text: t("labels.solid"), | ||||
|             icon: <FillSolidIcon theme={appState.theme} />, | ||||
|             icon: FillSolidIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         group="fill" | ||||
| @@ -358,17 +362,17 @@ export const actionChangeStrokeWidth = register({ | ||||
|           { | ||||
|             value: 1, | ||||
|             text: t("labels.thin"), | ||||
|             icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={2} />, | ||||
|             icon: StrokeWidthBaseIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 2, | ||||
|             text: t("labels.bold"), | ||||
|             icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={6} />, | ||||
|             icon: StrokeWidthBoldIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 4, | ||||
|             text: t("labels.extraBold"), | ||||
|             icon: <StrokeWidthIcon theme={appState.theme} strokeWidth={10} />, | ||||
|             icon: StrokeWidthExtraBoldIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -407,17 +411,17 @@ export const actionChangeSloppiness = register({ | ||||
|           { | ||||
|             value: 0, | ||||
|             text: t("labels.architect"), | ||||
|             icon: <SloppinessArchitectIcon theme={appState.theme} />, | ||||
|             icon: SloppinessArchitectIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 1, | ||||
|             text: t("labels.artist"), | ||||
|             icon: <SloppinessArtistIcon theme={appState.theme} />, | ||||
|             icon: SloppinessArtistIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: 2, | ||||
|             text: t("labels.cartoonist"), | ||||
|             icon: <SloppinessCartoonistIcon theme={appState.theme} />, | ||||
|             icon: SloppinessCartoonistIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -455,17 +459,17 @@ export const actionChangeStrokeStyle = register({ | ||||
|           { | ||||
|             value: "solid", | ||||
|             text: t("labels.strokeStyle_solid"), | ||||
|             icon: <StrokeStyleSolidIcon theme={appState.theme} />, | ||||
|             icon: StrokeWidthBaseIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "dashed", | ||||
|             text: t("labels.strokeStyle_dashed"), | ||||
|             icon: <StrokeStyleDashedIcon theme={appState.theme} />, | ||||
|             icon: StrokeStyleDashedIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "dotted", | ||||
|             text: t("labels.strokeStyle_dotted"), | ||||
|             icon: <StrokeStyleDottedIcon theme={appState.theme} />, | ||||
|             icon: StrokeStyleDottedIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -535,25 +539,25 @@ export const actionChangeFontSize = register({ | ||||
|           { | ||||
|             value: 16, | ||||
|             text: t("labels.small"), | ||||
|             icon: <FontSizeSmallIcon theme={appState.theme} />, | ||||
|             icon: FontSizeSmallIcon, | ||||
|             testId: "fontSize-small", | ||||
|           }, | ||||
|           { | ||||
|             value: 20, | ||||
|             text: t("labels.medium"), | ||||
|             icon: <FontSizeMediumIcon theme={appState.theme} />, | ||||
|             icon: FontSizeMediumIcon, | ||||
|             testId: "fontSize-medium", | ||||
|           }, | ||||
|           { | ||||
|             value: 28, | ||||
|             text: t("labels.large"), | ||||
|             icon: <FontSizeLargeIcon theme={appState.theme} />, | ||||
|             icon: FontSizeLargeIcon, | ||||
|             testId: "fontSize-large", | ||||
|           }, | ||||
|           { | ||||
|             value: 36, | ||||
|             text: t("labels.veryLarge"), | ||||
|             icon: <FontSizeExtraLargeIcon theme={appState.theme} />, | ||||
|             icon: FontSizeExtraLargeIcon, | ||||
|             testId: "fontSize-veryLarge", | ||||
|           }, | ||||
|         ]} | ||||
| @@ -658,17 +662,17 @@ export const actionChangeFontFamily = register({ | ||||
|       { | ||||
|         value: FONT_FAMILY.Virgil, | ||||
|         text: t("labels.handDrawn"), | ||||
|         icon: <FontFamilyHandDrawnIcon theme={appState.theme} />, | ||||
|         icon: FreedrawIcon, | ||||
|       }, | ||||
|       { | ||||
|         value: FONT_FAMILY.Helvetica, | ||||
|         text: t("labels.normal"), | ||||
|         icon: <FontFamilyNormalIcon theme={appState.theme} />, | ||||
|         icon: FontFamilyNormalIcon, | ||||
|       }, | ||||
|       { | ||||
|         value: FONT_FAMILY.Cascadia, | ||||
|         text: t("labels.code"), | ||||
|         icon: <FontFamilyCodeIcon theme={appState.theme} />, | ||||
|         icon: FontFamilyCodeIcon, | ||||
|       }, | ||||
|     ]; | ||||
|  | ||||
| @@ -739,17 +743,17 @@ export const actionChangeTextAlign = register({ | ||||
|             { | ||||
|               value: "left", | ||||
|               text: t("labels.left"), | ||||
|               icon: <TextAlignLeftIcon theme={appState.theme} />, | ||||
|               icon: TextAlignLeftIcon, | ||||
|             }, | ||||
|             { | ||||
|               value: "center", | ||||
|               text: t("labels.center"), | ||||
|               icon: <TextAlignCenterIcon theme={appState.theme} />, | ||||
|               icon: TextAlignCenterIcon, | ||||
|             }, | ||||
|             { | ||||
|               value: "right", | ||||
|               text: t("labels.right"), | ||||
|               icon: <TextAlignRightIcon theme={appState.theme} />, | ||||
|               icon: TextAlignRightIcon, | ||||
|             }, | ||||
|           ]} | ||||
|           value={getFormValue( | ||||
| @@ -882,12 +886,12 @@ export const actionChangeSharpness = register({ | ||||
|           { | ||||
|             value: "sharp", | ||||
|             text: t("labels.sharp"), | ||||
|             icon: <EdgeSharpIcon theme={appState.theme} />, | ||||
|             icon: EdgeSharpIcon, | ||||
|           }, | ||||
|           { | ||||
|             value: "round", | ||||
|             text: t("labels.round"), | ||||
|             icon: <EdgeRoundIcon theme={appState.theme} />, | ||||
|             icon: EdgeRoundIcon, | ||||
|           }, | ||||
|         ]} | ||||
|         value={getFormValue( | ||||
| @@ -949,42 +953,38 @@ export const actionChangeArrowhead = register({ | ||||
|     return ( | ||||
|       <fieldset> | ||||
|         <legend>{t("labels.arrowheads")}</legend> | ||||
|         <div className="iconSelectList"> | ||||
|         <div className="iconSelectList buttonList"> | ||||
|           <IconPicker | ||||
|             label="arrowhead_start" | ||||
|             options={[ | ||||
|               { | ||||
|                 value: null, | ||||
|                 text: t("labels.arrowhead_none"), | ||||
|                 icon: <ArrowheadNoneIcon theme={appState.theme} />, | ||||
|                 icon: ArrowheadNoneIcon, | ||||
|                 keyBinding: "q", | ||||
|               }, | ||||
|               { | ||||
|                 value: "arrow", | ||||
|                 text: t("labels.arrowhead_arrow"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadArrowIcon theme={appState.theme} flip={!isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadArrowIcon flip={!isRTL} />, | ||||
|                 keyBinding: "w", | ||||
|               }, | ||||
|               { | ||||
|                 value: "bar", | ||||
|                 text: t("labels.arrowhead_bar"), | ||||
|                 icon: <ArrowheadBarIcon theme={appState.theme} flip={!isRTL} />, | ||||
|                 icon: <ArrowheadBarIcon flip={!isRTL} />, | ||||
|                 keyBinding: "e", | ||||
|               }, | ||||
|               { | ||||
|                 value: "dot", | ||||
|                 text: t("labels.arrowhead_dot"), | ||||
|                 icon: <ArrowheadDotIcon theme={appState.theme} flip={!isRTL} />, | ||||
|                 icon: <ArrowheadDotIcon flip={!isRTL} />, | ||||
|                 keyBinding: "r", | ||||
|               }, | ||||
|               { | ||||
|                 value: "triangle", | ||||
|                 text: t("labels.arrowhead_triangle"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadTriangleIcon theme={appState.theme} flip={!isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadTriangleIcon flip={!isRTL} />, | ||||
|                 keyBinding: "t", | ||||
|               }, | ||||
|             ]} | ||||
| @@ -1007,34 +1007,30 @@ export const actionChangeArrowhead = register({ | ||||
|                 value: null, | ||||
|                 text: t("labels.arrowhead_none"), | ||||
|                 keyBinding: "q", | ||||
|                 icon: <ArrowheadNoneIcon theme={appState.theme} />, | ||||
|                 icon: ArrowheadNoneIcon, | ||||
|               }, | ||||
|               { | ||||
|                 value: "arrow", | ||||
|                 text: t("labels.arrowhead_arrow"), | ||||
|                 keyBinding: "w", | ||||
|                 icon: ( | ||||
|                   <ArrowheadArrowIcon theme={appState.theme} flip={isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadArrowIcon flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "bar", | ||||
|                 text: t("labels.arrowhead_bar"), | ||||
|                 keyBinding: "e", | ||||
|                 icon: <ArrowheadBarIcon theme={appState.theme} flip={isRTL} />, | ||||
|                 icon: <ArrowheadBarIcon flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "dot", | ||||
|                 text: t("labels.arrowhead_dot"), | ||||
|                 keyBinding: "r", | ||||
|                 icon: <ArrowheadDotIcon theme={appState.theme} flip={isRTL} />, | ||||
|                 icon: <ArrowheadDotIcon flip={isRTL} />, | ||||
|               }, | ||||
|               { | ||||
|                 value: "triangle", | ||||
|                 text: t("labels.arrowhead_triangle"), | ||||
|                 icon: ( | ||||
|                   <ArrowheadTriangleIcon theme={appState.theme} flip={isRTL} /> | ||||
|                 ), | ||||
|                 icon: <ArrowheadTriangleIcon flip={isRTL} />, | ||||
|                 keyBinding: "t", | ||||
|               }, | ||||
|             ]} | ||||
|   | ||||
| @@ -10,10 +10,10 @@ import { t } from "../i18n"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
| import { register } from "./register"; | ||||
| import { | ||||
|   SendBackwardIcon, | ||||
|   BringToFrontIcon, | ||||
|   SendToBackIcon, | ||||
|   BringForwardIcon, | ||||
|   BringToFrontIcon, | ||||
|   SendBackwardIcon, | ||||
|   SendToBackIcon, | ||||
| } from "../components/icons"; | ||||
|  | ||||
| export const actionSendBackward = register({ | ||||
| @@ -39,7 +39,7 @@ export const actionSendBackward = register({ | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.sendBackward")} — ${getShortcutKey("CtrlOrCmd+[")}`} | ||||
|     > | ||||
|       <SendBackwardIcon theme={appState.theme} /> | ||||
|       {SendBackwardIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
| @@ -67,7 +67,7 @@ export const actionBringForward = register({ | ||||
|       onClick={() => updateData(null)} | ||||
|       title={`${t("labels.bringForward")} — ${getShortcutKey("CtrlOrCmd+]")}`} | ||||
|     > | ||||
|       <BringForwardIcon theme={appState.theme} /> | ||||
|       {BringForwardIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
| @@ -102,7 +102,7 @@ export const actionSendToBack = register({ | ||||
|           : getShortcutKey("CtrlOrCmd+Shift+[") | ||||
|       }`} | ||||
|     > | ||||
|       <SendToBackIcon theme={appState.theme} /> | ||||
|       {SendToBackIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
| @@ -138,7 +138,7 @@ export const actionBringToFront = register({ | ||||
|           : getShortcutKey("CtrlOrCmd+Shift+]") | ||||
|       }`} | ||||
|     > | ||||
|       <BringToFrontIcon theme={appState.theme} /> | ||||
|       {BringToFrontIcon} | ||||
|     </button> | ||||
|   ), | ||||
| }); | ||||
|   | ||||
| @@ -135,8 +135,13 @@ 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 ( | ||||
|       this.actions[name] && | ||||
|       "PanelComponent" in this.actions[name] && | ||||
| @@ -169,6 +174,7 @@ export class ActionManager { | ||||
|           updateData={updateData} | ||||
|           appProps={this.app.props} | ||||
|           data={data} | ||||
|           isInHamburgerMenu={isInHamburgerMenu} | ||||
|         /> | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -3,36 +3,45 @@ import { isDarwin } from "../keys"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
| import { ActionName } from "./types"; | ||||
|  | ||||
| export type ShortcutName = SubtypeOf< | ||||
|   ActionName, | ||||
|   | "cut" | ||||
|   | "copy" | ||||
|   | "paste" | ||||
|   | "copyStyles" | ||||
|   | "pasteStyles" | ||||
|   | "selectAll" | ||||
|   | "deleteSelectedElements" | ||||
|   | "duplicateSelection" | ||||
|   | "sendBackward" | ||||
|   | "bringForward" | ||||
|   | "sendToBack" | ||||
|   | "bringToFront" | ||||
|   | "copyAsPng" | ||||
|   | "copyAsSvg" | ||||
|   | "group" | ||||
|   | "ungroup" | ||||
|   | "gridMode" | ||||
|   | "zenMode" | ||||
|   | "stats" | ||||
|   | "addToLibrary" | ||||
|   | "viewMode" | ||||
|   | "flipHorizontal" | ||||
|   | "flipVertical" | ||||
|   | "hyperlink" | ||||
|   | "toggleLock" | ||||
| >; | ||||
| export type ShortcutName = | ||||
|   | SubtypeOf< | ||||
|       ActionName, | ||||
|       | "toggleTheme" | ||||
|       | "loadScene" | ||||
|       | "cut" | ||||
|       | "copy" | ||||
|       | "paste" | ||||
|       | "copyStyles" | ||||
|       | "pasteStyles" | ||||
|       | "selectAll" | ||||
|       | "deleteSelectedElements" | ||||
|       | "duplicateSelection" | ||||
|       | "sendBackward" | ||||
|       | "bringForward" | ||||
|       | "sendToBack" | ||||
|       | "bringToFront" | ||||
|       | "copyAsPng" | ||||
|       | "copyAsSvg" | ||||
|       | "group" | ||||
|       | "ungroup" | ||||
|       | "gridMode" | ||||
|       | "zenMode" | ||||
|       | "stats" | ||||
|       | "addToLibrary" | ||||
|       | "viewMode" | ||||
|       | "flipHorizontal" | ||||
|       | "flipVertical" | ||||
|       | "hyperlink" | ||||
|       | "toggleLock" | ||||
|     > | ||||
|   | "saveScene" | ||||
|   | "imageExport"; | ||||
|  | ||||
| const shortcutMap: Record<ShortcutName, string[]> = { | ||||
|   toggleTheme: [getShortcutKey("Shift+Alt+D")], | ||||
|   saveScene: [getShortcutKey("CtrlOrCmd+S")], | ||||
|   loadScene: [getShortcutKey("CtrlOrCmd+O")], | ||||
|   imageExport: [getShortcutKey("CtrlOrCmd+Shift+E")], | ||||
|   cut: [getShortcutKey("CtrlOrCmd+X")], | ||||
|   copy: [getShortcutKey("CtrlOrCmd+C")], | ||||
|   paste: [getShortcutKey("CtrlOrCmd+V")], | ||||
|   | ||||
| @@ -124,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?: ( | ||||
|   | ||||
| @@ -19,6 +19,7 @@ export const getDefaultAppState = (): Omit< | ||||
|   "offsetTop" | "offsetLeft" | "width" | "height" | ||||
| > => { | ||||
|   return { | ||||
|     showWelcomeScreen: false, | ||||
|     theme: THEME.LIGHT, | ||||
|     collaborators: new Map(), | ||||
|     currentChartType: "bar", | ||||
| @@ -67,6 +68,7 @@ export const getDefaultAppState = (): Omit< | ||||
|     openMenu: null, | ||||
|     openPopup: null, | ||||
|     openSidebar: null, | ||||
|     openDialog: null, | ||||
|     pasteDialog: { shown: false, data: null }, | ||||
|     previousSelectedElementIds: {}, | ||||
|     resizingElement: null, | ||||
| @@ -77,7 +79,6 @@ export const getDefaultAppState = (): Omit< | ||||
|     selectedGroupIds: {}, | ||||
|     selectionElement: null, | ||||
|     shouldCacheIgnoreZoom: false, | ||||
|     showHelpDialog: false, | ||||
|     showStats: false, | ||||
|     startBoundElement: null, | ||||
|     suggestedBindings: [], | ||||
| @@ -110,6 +111,7 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   T extends Record<keyof AppState, Values>, | ||||
| >(config: { [K in keyof T]: K extends keyof AppState ? T[K] : never }) => | ||||
|   config)({ | ||||
|   showWelcomeScreen: { browser: true, export: false, server: false }, | ||||
|   theme: { browser: true, export: false, server: false }, | ||||
|   collaborators: { browser: false, export: false, server: false }, | ||||
|   currentChartType: { browser: true, export: false, server: false }, | ||||
| @@ -160,6 +162,7 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   openMenu: { browser: true, export: false, server: false }, | ||||
|   openPopup: { browser: false, export: false, server: false }, | ||||
|   openSidebar: { browser: true, export: false, server: false }, | ||||
|   openDialog: { browser: false, export: false, server: false }, | ||||
|   pasteDialog: { browser: false, export: false, server: false }, | ||||
|   previousSelectedElementIds: { browser: true, export: false, server: false }, | ||||
|   resizingElement: { browser: false, export: false, server: false }, | ||||
| @@ -170,7 +173,6 @@ const APP_STATE_STORAGE_CONF = (< | ||||
|   selectedGroupIds: { browser: true, export: false, server: false }, | ||||
|   selectionElement: { browser: false, export: false, server: false }, | ||||
|   shouldCacheIgnoreZoom: { browser: true, export: false, server: false }, | ||||
|   showHelpDialog: { browser: false, export: false, server: false }, | ||||
|   showStats: { browser: true, export: false, server: false }, | ||||
|   startBoundElement: { browser: false, export: false, server: false }, | ||||
|   suggestedBindings: { browser: false, export: false, server: false }, | ||||
|   | ||||
| @@ -11,27 +11,18 @@ export const getClientColors = (clientId: string, appState: AppState) => { | ||||
|   // Naive way of getting an integer out of the clientId | ||||
|   const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0); | ||||
|  | ||||
|   // Skip transparent background. | ||||
|   const backgrounds = colors.elementBackground.slice(1); | ||||
|   const strokes = colors.elementStroke.slice(1); | ||||
|   // Skip transparent & gray colors | ||||
|   const backgrounds = colors.elementBackground.slice(3); | ||||
|   const strokes = colors.elementStroke.slice(3); | ||||
|   return { | ||||
|     background: backgrounds[sum % backgrounds.length], | ||||
|     stroke: strokes[sum % strokes.length], | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export const getClientInitials = (username?: string | null) => { | ||||
|   if (!username) { | ||||
| export const getClientInitials = (userName?: string | null) => { | ||||
|   if (!userName) { | ||||
|     return "?"; | ||||
|   } | ||||
|   const names = username.trim().split(" "); | ||||
|  | ||||
|   if (names.length < 2) { | ||||
|     return names[0].substring(0, 2).toUpperCase(); | ||||
|   } | ||||
|  | ||||
|   const firstName = names[0]; | ||||
|   const lastName = names[names.length - 1]; | ||||
|  | ||||
|   return (firstName[0] + lastName[0]).toUpperCase(); | ||||
|   return userName.trim()[0].toUpperCase(); | ||||
| }; | ||||
|   | ||||
							
								
								
									
										92
									
								
								src/components/Actions.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								src/components/Actions.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| .zoom-actions, | ||||
| .undo-redo-buttons { | ||||
|   background-color: var(--island-bg-color); | ||||
|   border-radius: var(--border-radius-lg); | ||||
| } | ||||
|  | ||||
| .zoom-button, | ||||
| .undo-redo-buttons button { | ||||
|   border: 1px solid var(--default-border-color) !important; | ||||
|   border-radius: 0 !important; | ||||
|   background-color: transparent !important; | ||||
|   font-size: 0.875rem !important; | ||||
|   width: var(--lg-button-size); | ||||
|   height: var(--lg-button-size); | ||||
|   svg { | ||||
|     width: var(--lg-icon-size) !important; | ||||
|     height: var(--lg-icon-size) !important; | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     width: 100%; | ||||
|     height: 100%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .reset-zoom-button { | ||||
|   border-left: 0 !important; | ||||
|   border-right: 0 !important; | ||||
|   padding: 0 0.625rem !important; | ||||
|   width: 3.75rem !important; | ||||
|   justify-content: center; | ||||
|   color: var(--text-primary-color); | ||||
| } | ||||
|  | ||||
| .zoom-out-button { | ||||
|   border-top-left-radius: var(--border-radius-lg) !important; | ||||
|   border-bottom-left-radius: var(--border-radius-lg) !important; | ||||
|  | ||||
|   :root[dir="rtl"] & { | ||||
|     transform: scaleX(-1); | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     border-top-right-radius: 0 !important; | ||||
|     border-bottom-right-radius: 0 !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .zoom-in-button { | ||||
|   border-top-right-radius: var(--border-radius-lg) !important; | ||||
|   border-bottom-right-radius: var(--border-radius-lg) !important; | ||||
|  | ||||
|   :root[dir="rtl"] & { | ||||
|     transform: scaleX(-1); | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     border-top-left-radius: 0 !important; | ||||
|     border-bottom-left-radius: 0 !important; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .undo-redo-buttons { | ||||
|   .undo-button-container button { | ||||
|     border-top-left-radius: var(--border-radius-lg) !important; | ||||
|     border-bottom-left-radius: var(--border-radius-lg) !important; | ||||
|     border-right: 0 !important; | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       transform: scaleX(-1); | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__icon { | ||||
|       border-top-right-radius: 0 !important; | ||||
|       border-bottom-right-radius: 0 !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .redo-button-container button { | ||||
|     border-top-right-radius: var(--border-radius-lg) !important; | ||||
|     border-bottom-right-radius: var(--border-radius-lg) !important; | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       transform: scaleX(-1); | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__icon { | ||||
|       border-top-left-radius: 0 !important; | ||||
|       border-bottom-left-radius: 0 !important; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -28,6 +28,8 @@ import { trackEvent } from "../analytics"; | ||||
| import { hasBoundTextElement, isBoundToContainer } from "../element/typeChecks"; | ||||
| import clsx from "clsx"; | ||||
| import { actionToggleZenMode } from "../actions"; | ||||
| import "./Actions.scss"; | ||||
| import { Tooltip } from "./Tooltip"; | ||||
|  | ||||
| export const SelectedShapeActions = ({ | ||||
|   appState, | ||||
| @@ -79,12 +81,16 @@ export const SelectedShapeActions = ({ | ||||
|  | ||||
|   return ( | ||||
|     <div className="panelColumn"> | ||||
|       {((hasStrokeColor(appState.activeTool.type) && | ||||
|         appState.activeTool.type !== "image" && | ||||
|         commonSelectedType !== "image") || | ||||
|         targetElements.some((element) => hasStrokeColor(element.type))) && | ||||
|         renderAction("changeStrokeColor")} | ||||
|       {showChangeBackgroundIcons && renderAction("changeBackgroundColor")} | ||||
|       <div> | ||||
|         {((hasStrokeColor(appState.activeTool.type) && | ||||
|           appState.activeTool.type !== "image" && | ||||
|           commonSelectedType !== "image") || | ||||
|           targetElements.some((element) => hasStrokeColor(element.type))) && | ||||
|           renderAction("changeStrokeColor")} | ||||
|       </div> | ||||
|       {showChangeBackgroundIcons && ( | ||||
|         <div>{renderAction("changeBackgroundColor")}</div> | ||||
|       )} | ||||
|       {showFillIcons && renderAction("changeFillStyle")} | ||||
|  | ||||
|       {(hasStrokeWidth(appState.activeTool.type) || | ||||
| @@ -163,7 +169,16 @@ export const SelectedShapeActions = ({ | ||||
|             )} | ||||
|             {targetElements.length > 2 && | ||||
|               renderAction("distributeHorizontally")} | ||||
|             <div className="iconRow"> | ||||
|             {/* breaks the row ˇˇ */} | ||||
|             <div style={{ flexBasis: "100%", height: 0 }} /> | ||||
|             <div | ||||
|               style={{ | ||||
|                 display: "flex", | ||||
|                 flexWrap: "wrap", | ||||
|                 gap: ".5rem", | ||||
|                 marginTop: "-0.5rem", | ||||
|               }} | ||||
|             > | ||||
|               {renderAction("alignTop")} | ||||
|               {renderAction("alignVerticallyCentered")} | ||||
|               {renderAction("alignBottom")} | ||||
| @@ -203,25 +218,25 @@ export const ShapesSwitcher = ({ | ||||
|   appState: AppState; | ||||
| }) => ( | ||||
|   <> | ||||
|     {SHAPES.map(({ value, icon, key }, index) => { | ||||
|     {SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => { | ||||
|       const label = t(`toolBar.${value}`); | ||||
|       const letter = key && (typeof key === "string" ? key : key[0]); | ||||
|       const shortcut = letter | ||||
|         ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${index + 1}` | ||||
|         : `${index + 1}`; | ||||
|         ? `${capitalizeString(letter)} ${t("helpDialog.or")} ${numericKey}` | ||||
|         : `${numericKey}`; | ||||
|       return ( | ||||
|         <ToolButton | ||||
|           className="Shape" | ||||
|           className={clsx("Shape", { fillable })} | ||||
|           key={value} | ||||
|           type="radio" | ||||
|           icon={icon} | ||||
|           checked={activeTool.type === value} | ||||
|           name="editor-current-shape" | ||||
|           title={`${capitalizeString(label)} — ${shortcut}`} | ||||
|           keyBindingLabel={`${index + 1}`} | ||||
|           keyBindingLabel={numericKey} | ||||
|           aria-label={capitalizeString(label)} | ||||
|           aria-keyshortcuts={shortcut} | ||||
|           data-testid={value} | ||||
|           data-testid={`toolbar-${value}`} | ||||
|           onPointerDown={({ pointerType }) => { | ||||
|             if (!appState.penDetected && pointerType === "pen") { | ||||
|               setAppState({ | ||||
| @@ -263,11 +278,11 @@ export const ZoomActions = ({ | ||||
|   renderAction: ActionManager["renderAction"]; | ||||
|   zoom: Zoom; | ||||
| }) => ( | ||||
|   <Stack.Col gap={1}> | ||||
|     <Stack.Row gap={1} align="center"> | ||||
|   <Stack.Col gap={1} className="zoom-actions"> | ||||
|     <Stack.Row align="center"> | ||||
|       {renderAction("zoomOut")} | ||||
|       {renderAction("zoomIn")} | ||||
|       {renderAction("resetZoom")} | ||||
|       {renderAction("zoomIn")} | ||||
|     </Stack.Row> | ||||
|   </Stack.Col> | ||||
| ); | ||||
| @@ -280,8 +295,12 @@ export const UndoRedoActions = ({ | ||||
|   className?: string; | ||||
| }) => ( | ||||
|   <div className={`undo-redo-buttons ${className}`}> | ||||
|     {renderAction("undo", { size: "small" })} | ||||
|     {renderAction("redo", { size: "small" })} | ||||
|     <div className="undo-button-container"> | ||||
|       <Tooltip label={t("buttons.undo")}>{renderAction("undo")}</Tooltip> | ||||
|     </div> | ||||
|     <div className="redo-button-container"> | ||||
|       <Tooltip label={t("buttons.redo")}> {renderAction("redo")}</Tooltip> | ||||
|     </div> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import Stack from "../components/Stack"; | ||||
| import { ToolButton } from "../components/ToolButton"; | ||||
| import { save, file } from "../components/icons"; | ||||
| // 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; | ||||
| @@ -11,18 +13,11 @@ type ActiveFileProps = { | ||||
| }; | ||||
|  | ||||
| export const ActiveFile = ({ fileName, onSave }: ActiveFileProps) => ( | ||||
|   <Stack.Row className="ActiveFile" gap={1} align="center"> | ||||
|     <span className="ActiveFile__fileName"> | ||||
|       {file} | ||||
|       <span>{fileName}</span> | ||||
|     </span> | ||||
|     <ToolButton | ||||
|       type="icon" | ||||
|       icon={save} | ||||
|       title={t("buttons.save")} | ||||
|       aria-label={t("buttons.save")} | ||||
|       onClick={onSave} | ||||
|       data-testid="save-button" | ||||
|     /> | ||||
|   </Stack.Row> | ||||
|   <MenuItem | ||||
|     label={`${t("buttons.save")}`} | ||||
|     shortcut={getShortcutFromShortcutName("saveScene")} | ||||
|     dataTestId="save-button" | ||||
|     onClick={onSave} | ||||
|     icon={save} | ||||
|   /> | ||||
| ); | ||||
|   | ||||
| @@ -76,6 +76,7 @@ import { | ||||
|   THEME, | ||||
|   TOUCH_CTX_MENU_TIMEOUT, | ||||
|   VERTICAL_ALIGN, | ||||
|   ZOOM_STEP, | ||||
| } from "../constants"; | ||||
| import { loadFromBlob } from "../data"; | ||||
| import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library"; | ||||
| @@ -265,6 +266,10 @@ import { | ||||
|   isLocalLink, | ||||
| } from "../element/Hyperlink"; | ||||
| import { shouldShowBoundingBox } from "../element/transformHandles"; | ||||
| import { atom } from "jotai"; | ||||
|  | ||||
| export const isMenuOpenAtom = atom(false); | ||||
| export const isDropdownOpenAtom = atom(false); | ||||
|  | ||||
| const deviceContextInitialValue = { | ||||
|   isSmScreen: false, | ||||
| @@ -570,6 +575,11 @@ class App extends React.Component<AppProps, AppState> { | ||||
|                     library={this.library} | ||||
|                     id={this.id} | ||||
|                     onImageAction={this.onImageAction} | ||||
|                     renderWelcomeScreen={ | ||||
|                       this.state.showWelcomeScreen && | ||||
|                       this.state.activeTool.type === "selection" && | ||||
|                       !this.scene.getElementsIncludingDeleted().length | ||||
|                     } | ||||
|                   /> | ||||
|                   <div className="excalidraw-textEditorContainer" /> | ||||
|                   <div className="excalidraw-contextMenuContainer" /> | ||||
| @@ -1084,6 +1094,13 @@ class App extends React.Component<AppProps, AppState> { | ||||
|   } | ||||
|  | ||||
|   componentDidUpdate(prevProps: AppProps, prevState: AppState) { | ||||
|     if ( | ||||
|       !this.state.showWelcomeScreen && | ||||
|       !this.scene.getElementsIncludingDeleted().length | ||||
|     ) { | ||||
|       this.setState({ showWelcomeScreen: true }); | ||||
|     } | ||||
|  | ||||
|     if ( | ||||
|       this.excalidrawContainerRef.current && | ||||
|       prevProps.UIOptions.dockedSidebarBreakpoint !== | ||||
| @@ -1275,6 +1292,10 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         ); | ||||
|       }); | ||||
|  | ||||
|     const selectionColor = getComputedStyle( | ||||
|       document.querySelector(".excalidraw")!, | ||||
|     ).getPropertyValue("--color-selection"); | ||||
|  | ||||
|     renderScene( | ||||
|       { | ||||
|         elements: renderingElements, | ||||
| @@ -1283,6 +1304,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         rc: this.rc!, | ||||
|         canvas: this.canvas!, | ||||
|         renderConfig: { | ||||
|           selectionColor, | ||||
|           scrollX: this.state.scrollX, | ||||
|           scrollY: this.state.scrollY, | ||||
|           viewBackgroundColor: this.state.viewBackgroundColor, | ||||
| @@ -1866,8 +1888,16 @@ class App extends React.Component<AppProps, AppState> { | ||||
|  | ||||
|       if (event.key === KEYS.QUESTION_MARK) { | ||||
|         this.setState({ | ||||
|           showHelpDialog: true, | ||||
|           openDialog: "help", | ||||
|         }); | ||||
|         return; | ||||
|       } else if ( | ||||
|         event.key.toLowerCase() === KEYS.E && | ||||
|         event.shiftKey && | ||||
|         event[KEYS.CTRL_OR_CMD] | ||||
|       ) { | ||||
|         this.setState({ openDialog: "imageExport" }); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       if (this.actionManager.handleKeyDown(event)) { | ||||
| @@ -1882,18 +1912,6 @@ class App extends React.Component<AppProps, AppState> { | ||||
|         this.setState({ isBindingEnabled: false }); | ||||
|       } | ||||
|  | ||||
|       if (event.code === CODES.ZERO) { | ||||
|         const nextState = this.toggleMenu("library"); | ||||
|         // track only openings | ||||
|         if (nextState) { | ||||
|           trackEvent( | ||||
|             "library", | ||||
|             "toggleLibrary (open)", | ||||
|             `keyboard (${this.device.isMobile ? "mobile" : "desktop"})`, | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       if (isArrowKey(event.key)) { | ||||
|         const step = | ||||
|           (this.state.gridSize && | ||||
| @@ -4806,10 +4824,6 @@ class App extends React.Component<AppProps, AppState> { | ||||
|           } else { | ||||
|             this.setState((prevState) => ({ | ||||
|               draggingElement: null, | ||||
|               selectedElementIds: { | ||||
|                 ...prevState.selectedElementIds, | ||||
|                 [draggingElement.id]: true, | ||||
|               }, | ||||
|             })); | ||||
|           } | ||||
|         } | ||||
| @@ -5217,6 +5231,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|               id: fileId, | ||||
|               dataURL, | ||||
|               created: Date.now(), | ||||
|               lastRetrieved: Date.now(), | ||||
|             }, | ||||
|           }; | ||||
|           const cachedImageData = this.imageCache.get(fileId); | ||||
| @@ -6097,7 +6112,7 @@ class App extends React.Component<AppProps, AppState> { | ||||
|     // note that event.ctrlKey is necessary to handle pinch zooming | ||||
|     if (event.metaKey || event.ctrlKey) { | ||||
|       const sign = Math.sign(deltaY); | ||||
|       const MAX_STEP = 10; | ||||
|       const MAX_STEP = ZOOM_STEP * 100; | ||||
|       const absDelta = Math.abs(deltaY); | ||||
|       let delta = deltaY; | ||||
|       if (absDelta > MAX_STEP) { | ||||
|   | ||||
| @@ -2,16 +2,19 @@ | ||||
|  | ||||
| .excalidraw { | ||||
|   .Avatar { | ||||
|     width: 2.5rem; | ||||
|     height: 2.5rem; | ||||
|     border-radius: 1.25rem; | ||||
|     width: 1.25rem; | ||||
|     height: 1.25rem; | ||||
|     border-radius: 100%; | ||||
|     outline: 2px solid var(--avatar-border-color); | ||||
|     outline-offset: 2px; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     color: $oc-white; | ||||
|     cursor: pointer; | ||||
|     font-size: 0.8rem; | ||||
|     font-size: 0.625rem; | ||||
|     font-weight: 500; | ||||
|     line-height: 1; | ||||
|  | ||||
|     &-img { | ||||
|       width: 100%; | ||||
|   | ||||
| @@ -11,13 +11,11 @@ type AvatarProps = { | ||||
|   src?: string; | ||||
| }; | ||||
|  | ||||
| export const Avatar = ({ color, border, onClick, name, src }: AvatarProps) => { | ||||
| export const Avatar = ({ color, onClick, name, src }: AvatarProps) => { | ||||
|   const shortName = getClientInitials(name); | ||||
|   const [error, setError] = useState(false); | ||||
|   const loadImg = !error && src; | ||||
|   const style = loadImg | ||||
|     ? undefined | ||||
|     : { background: color, border: `1px solid ${border}` }; | ||||
|   const style = loadImg ? undefined : { background: color }; | ||||
|   return ( | ||||
|     <div className="Avatar" style={style} onClick={onClick}> | ||||
|       {loadImg ? ( | ||||
|   | ||||
| @@ -1,12 +0,0 @@ | ||||
| import { ActionManager } from "../actions/manager"; | ||||
|  | ||||
| export const BackgroundPickerAndDarkModeToggle = ({ | ||||
|   actionManager, | ||||
| }: { | ||||
|   actionManager: ActionManager; | ||||
| }) => ( | ||||
|   <div style={{ display: "flex" }}> | ||||
|     {actionManager.renderAction("changeViewBackgroundColor")} | ||||
|     {actionManager.renderAction("toggleTheme")} | ||||
|   </div> | ||||
| ); | ||||
| @@ -64,6 +64,8 @@ | ||||
|  | ||||
|       color: #{$oc-blue-7}; | ||||
|  | ||||
|       border: 0; | ||||
|  | ||||
|       &:focus { | ||||
|         box-shadow: 0 0 0 3px #{$oc-blue-7}; | ||||
|       } | ||||
|   | ||||
| @@ -1,10 +1,9 @@ | ||||
| import { useState } from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "./App"; | ||||
| import { trash } from "./icons"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { TrashIcon } from "./icons"; | ||||
|  | ||||
| import ConfirmDialog from "./ConfirmDialog"; | ||||
| import MenuItem from "./MenuItem"; | ||||
|  | ||||
| const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { | ||||
|   const [showDialog, setShowDialog] = useState(false); | ||||
| @@ -14,14 +13,11 @@ const ClearCanvas = ({ onConfirm }: { onConfirm: () => void }) => { | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         type="button" | ||||
|         icon={trash} | ||||
|         title={t("buttons.clearReset")} | ||||
|         aria-label={t("buttons.clearReset")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|       <MenuItem | ||||
|         label={t("buttons.clearReset")} | ||||
|         icon={TrashIcon} | ||||
|         onClick={toggleDialog} | ||||
|         data-testid="clear-canvas-button" | ||||
|         dataTestId="clear-canvas-button" | ||||
|       /> | ||||
|  | ||||
|       {showDialog && ( | ||||
|   | ||||
| @@ -1,6 +1,51 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .collab-button { | ||||
|     @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; | ||||
|  | ||||
|     &: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; | ||||
|  | ||||
|       svg { | ||||
|         color: #fff; | ||||
|       } | ||||
|  | ||||
|       &:hover, | ||||
|       &:active { | ||||
|         background-color: #0fb884; | ||||
|         border-color: #0fb884; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .collab-button { | ||||
|       color: var(--color-gray-90); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .CollabButton.is-collaborating { | ||||
|     background-color: var(--button-special-active-bg-color); | ||||
|  | ||||
| @@ -24,9 +69,9 @@ | ||||
|     bottom: -5px; | ||||
|     padding: 3px; | ||||
|     border-radius: 50%; | ||||
|     background-color: $oc-green-6; | ||||
|     color: $oc-white; | ||||
|     font-size: 0.6em; | ||||
|     background-color: $oc-green-2; | ||||
|     color: $oc-green-9; | ||||
|     font-size: 0.6rem; | ||||
|     font-family: "Cascadia"; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,37 +1,47 @@ | ||||
| import clsx from "clsx"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "../components/App"; | ||||
| import { users } from "./icons"; | ||||
| 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 ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         className={clsx("CollabButton", { | ||||
|           "is-collaborating": isCollaborating, | ||||
|         })} | ||||
|         onClick={onClick} | ||||
|         icon={users} | ||||
|         type="button" | ||||
|         title={t("labels.liveCollaboration")} | ||||
|         aria-label={t("labels.liveCollaboration")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|       > | ||||
|         {isCollaborating && ( | ||||
|           <div className="CollabButton-collaborators">{collaboratorCount}</div> | ||||
|         )} | ||||
|       </ToolButton> | ||||
|       {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> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -21,6 +21,23 @@ | ||||
|     display: grid; | ||||
|     grid-template-columns: auto 1fr; | ||||
|     align-items: center; | ||||
|     column-gap: 0.5rem; | ||||
|   } | ||||
|  | ||||
|   .color-picker-control-container + .popover { | ||||
|     position: static; | ||||
|   } | ||||
|  | ||||
|   .color-picker-popover-container { | ||||
|     margin-top: -0.25rem; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       margin-left: 0.5rem; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       margin-left: -3rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-triangle { | ||||
| @@ -30,20 +47,29 @@ | ||||
|     border-width: 0 9px 10px; | ||||
|     border-color: transparent transparent var(--popup-bg-color); | ||||
|     position: absolute; | ||||
|     top: -10px; | ||||
|     top: 10px; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       left: 12px; | ||||
|       transform: rotate(270deg); | ||||
|       left: -14px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       right: 12px; | ||||
|       transform: rotate(90deg); | ||||
|       right: -14px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-triangle-shadow { | ||||
|     border-color: transparent transparent transparentize($oc-black, 0.9); | ||||
|     top: -11px; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       left: -14px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       right: -16px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-content--default { | ||||
| @@ -119,16 +145,21 @@ | ||||
|   } | ||||
|  | ||||
|   .color-picker-hash { | ||||
|     background: var(--input-border-color); | ||||
|     height: 1.875rem; | ||||
|     width: 1.875rem; | ||||
|     height: var(--default-button-size); | ||||
|     flex-shrink: 0; | ||||
|     padding: 0.5rem 0.5rem 0.5rem 0.75rem; | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     border-right: 0; | ||||
|     box-sizing: border-box; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       border-radius: 4px 0 0 4px; | ||||
|       border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       border-radius: 0 4px 4px 0; | ||||
|       border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; | ||||
|       border-right: 1px solid var(--default-border-color); | ||||
|       border-left: 0; | ||||
|     } | ||||
|  | ||||
|     color: var(--input-label-color); | ||||
| @@ -138,81 +169,64 @@ | ||||
|     position: relative; | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash { | ||||
|     box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash::before, | ||||
|   .color-input-container:focus-within .color-picker-hash::after { | ||||
|     content: ""; | ||||
|     width: 1px; | ||||
|     height: 100%; | ||||
|     position: absolute; | ||||
|     top: 0; | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash::before { | ||||
|     background: var(--input-border-color); | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       right: -1px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       left: -1px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-input-container:focus-within .color-picker-hash::after { | ||||
|     background: var(--input-bg-color); | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       right: -2px; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       left: -2px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-input-container { | ||||
|     display: flex; | ||||
|  | ||||
|     &:focus-within { | ||||
|       box-shadow: 0 0 0 1px var(--color-primary-darkest); | ||||
|       border-radius: var(--border-radius-lg); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-input { | ||||
|     width: 11ch; /* length of `transparent` */ | ||||
|     box-sizing: border-box; | ||||
|     width: 100%; | ||||
|     margin: 0; | ||||
|     font-size: 1rem; | ||||
|     background-color: var(--input-bg-color); | ||||
|     font-size: 0.875rem; | ||||
|     background-color: transparent; | ||||
|     color: var(--text-primary-color); | ||||
|     border: 0; | ||||
|     outline: none; | ||||
|     height: 1.75em; | ||||
|     box-shadow: var(--input-border-color) 0 0 0 1px inset; | ||||
|     height: var(--default-button-size); | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     border-left: 0; | ||||
|     letter-spacing: 0.4px; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       border-radius: 0 4px 4px 0; | ||||
|       border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       border-radius: 4px 0 0 4px; | ||||
|       border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg); | ||||
|       border-left: 1px solid var(--default-border-color); | ||||
|       border-right: 0; | ||||
|     } | ||||
|  | ||||
|     float: left; | ||||
|     padding: 1px; | ||||
|     padding-inline-start: 0.5em; | ||||
|     padding: 0.5rem; | ||||
|     padding-left: 0.25rem; | ||||
|     appearance: none; | ||||
|  | ||||
|     &:focus-visible { | ||||
|       box-shadow: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .color-picker-label-swatch-container { | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     width: var(--default-button-size); | ||||
|     height: var(--default-button-size); | ||||
|     box-sizing: border-box; | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   .color-picker-label-swatch { | ||||
|     height: 1.875rem; | ||||
|     width: 1.875rem; | ||||
|     margin-inline-end: 0.25rem; | ||||
|     border: 1px solid $oc-gray-3; | ||||
|     position: relative; | ||||
|     @include outlineButtonStyles; | ||||
|     background-color: var(--swatch-color) !important; | ||||
|     overflow: hidden; | ||||
|     background-color: transparent !important; | ||||
|     position: relative; | ||||
|     filter: var(--theme-filter); | ||||
|     border: 0 !important; | ||||
|  | ||||
|     &:after { | ||||
|       content: ""; | ||||
|   | ||||
| @@ -365,17 +365,20 @@ export const ColorPicker = ({ | ||||
|   appState: AppState; | ||||
| }) => { | ||||
|   const pickerButton = React.useRef<HTMLButtonElement>(null); | ||||
|   const coords = pickerButton.current?.getBoundingClientRect(); | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <div className="color-picker-control-container"> | ||||
|         <button | ||||
|           className="color-picker-label-swatch" | ||||
|           aria-label={label} | ||||
|           style={color ? { "--swatch-color": color } : undefined} | ||||
|           onClick={() => setActive(!isActive)} | ||||
|           ref={pickerButton} | ||||
|         /> | ||||
|         <div className="color-picker-label-swatch-container"> | ||||
|           <button | ||||
|             className="color-picker-label-swatch" | ||||
|             aria-label={label} | ||||
|             style={color ? { "--swatch-color": color } : undefined} | ||||
|             onClick={() => setActive(!isActive)} | ||||
|             ref={pickerButton} | ||||
|           /> | ||||
|         </div> | ||||
|         <ColorInput | ||||
|           color={color} | ||||
|           label={label} | ||||
| @@ -386,27 +389,37 @@ export const ColorPicker = ({ | ||||
|       </div> | ||||
|       <React.Suspense fallback=""> | ||||
|         {isActive ? ( | ||||
|           <Popover | ||||
|             onCloseRequest={(event) => | ||||
|               event.target !== pickerButton.current && setActive(false) | ||||
|             } | ||||
|           <div | ||||
|             className="color-picker-popover-container" | ||||
|             style={{ | ||||
|               position: "fixed", | ||||
|               top: coords?.top, | ||||
|               left: coords?.right, | ||||
|               zIndex: 1, | ||||
|             }} | ||||
|           > | ||||
|             <Picker | ||||
|               colors={colors[type]} | ||||
|               color={color || null} | ||||
|               onChange={(changedColor) => { | ||||
|                 onChange(changedColor); | ||||
|               }} | ||||
|               onClose={() => { | ||||
|                 setActive(false); | ||||
|                 pickerButton.current?.focus(); | ||||
|               }} | ||||
|               label={label} | ||||
|               showInput={false} | ||||
|               type={type} | ||||
|               elements={elements} | ||||
|             /> | ||||
|           </Popover> | ||||
|             <Popover | ||||
|               onCloseRequest={(event) => | ||||
|                 event.target !== pickerButton.current && setActive(false) | ||||
|               } | ||||
|             > | ||||
|               <Picker | ||||
|                 colors={colors[type]} | ||||
|                 color={color || null} | ||||
|                 onChange={(changedColor) => { | ||||
|                   onChange(changedColor); | ||||
|                 }} | ||||
|                 onClose={() => { | ||||
|                   setActive(false); | ||||
|                   pickerButton.current?.focus(); | ||||
|                 }} | ||||
|                 label={label} | ||||
|                 showInput={false} | ||||
|                 type={type} | ||||
|                 elements={elements} | ||||
|               /> | ||||
|             </Popover> | ||||
|           </div> | ||||
|         ) : null} | ||||
|       </React.Suspense> | ||||
|     </div> | ||||
|   | ||||
| @@ -4,34 +4,8 @@ | ||||
|   .confirm-dialog { | ||||
|     &-buttons { | ||||
|       display: flex; | ||||
|       padding: 0.2rem 0; | ||||
|       column-gap: 0.5rem; | ||||
|       justify-content: flex-end; | ||||
|     } | ||||
|     .ToolIcon__icon { | ||||
|       min-width: 2.5rem; | ||||
|       width: auto; | ||||
|       font-size: 1rem; | ||||
|     } | ||||
|  | ||||
|     .ToolIcon_type_button { | ||||
|       margin-left: 0.8rem; | ||||
|       padding: 0 0.5rem; | ||||
|     } | ||||
|  | ||||
|     &__content { | ||||
|       font-size: 1rem; | ||||
|     } | ||||
|  | ||||
|     &--confirm.ToolIcon_type_button { | ||||
|       background-color: $oc-red-6; | ||||
|  | ||||
|       &:hover { | ||||
|         background-color: $oc-red-8; | ||||
|       } | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         color: $oc-white; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,11 @@ | ||||
| import { t } from "../i18n"; | ||||
| import { Dialog, DialogProps } from "./Dialog"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import "./ConfirmDialog.scss"; | ||||
| import DialogActionButton from "./DialogActionButton"; | ||||
| import { isMenuOpenAtom } from "./App"; | ||||
| import { isDropdownOpenAtom } from "./App"; | ||||
| import { useSetAtom } from "jotai"; | ||||
|  | ||||
| interface Props extends Omit<DialogProps, "onCloseRequest"> { | ||||
|   onConfirm: () => void; | ||||
| @@ -20,6 +23,10 @@ const ConfirmDialog = (props: Props) => { | ||||
|     className = "", | ||||
|     ...rest | ||||
|   } = props; | ||||
|  | ||||
|   const setIsMenuOpen = useSetAtom(isMenuOpenAtom); | ||||
|   const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom); | ||||
|  | ||||
|   return ( | ||||
|     <Dialog | ||||
|       onCloseRequest={onCancel} | ||||
| @@ -29,21 +36,22 @@ const ConfirmDialog = (props: Props) => { | ||||
|     > | ||||
|       {children} | ||||
|       <div className="confirm-dialog-buttons"> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={cancelText} | ||||
|           aria-label={cancelText} | ||||
|         <DialogActionButton | ||||
|           label={cancelText} | ||||
|           onClick={onCancel} | ||||
|           className="confirm-dialog--cancel" | ||||
|           onClick={() => { | ||||
|             setIsMenuOpen(false); | ||||
|             setIsDropdownOpen(false); | ||||
|             onCancel(); | ||||
|           }} | ||||
|         /> | ||||
|         <ToolButton | ||||
|           type="button" | ||||
|           title={confirmText} | ||||
|           aria-label={confirmText} | ||||
|         <DialogActionButton | ||||
|           label={confirmText} | ||||
|           onClick={onConfirm} | ||||
|           className="confirm-dialog--confirm" | ||||
|           onClick={() => { | ||||
|             setIsMenuOpen(false); | ||||
|             setIsDropdownOpen(false); | ||||
|             onConfirm(); | ||||
|           }} | ||||
|           actionType="danger" | ||||
|         /> | ||||
|       </div> | ||||
|     </Dialog> | ||||
|   | ||||
| @@ -7,68 +7,11 @@ | ||||
|   } | ||||
|  | ||||
|   .Dialog__title { | ||||
|     display: grid; | ||||
|     align-items: center; | ||||
|     margin-top: 0; | ||||
|     grid-template-columns: 1fr calc(var(--space-factor) * 7); | ||||
|     grid-gap: var(--metric); | ||||
|     padding: calc(var(--space-factor) * 2); | ||||
|     text-align: center; | ||||
|     font-variant: small-caps; | ||||
|     font-size: 1.2em; | ||||
|   } | ||||
|  | ||||
|   .Dialog__titleContent { | ||||
|     flex: 1; | ||||
|   } | ||||
|  | ||||
|   .Dialog .Modal__close { | ||||
|     color: var(--icon-fill-color); | ||||
|     margin: 0; | ||||
|   } | ||||
|  | ||||
|   .Dialog__content { | ||||
|     padding: 0 16px 16px; | ||||
|   } | ||||
|  | ||||
|   @include isMobile { | ||||
|     .Dialog { | ||||
|       --metric: calc(var(--space-factor) * 4); | ||||
|       --inset-left: #{"max(var(--metric), var(--sal))"}; | ||||
|       --inset-right: #{"max(var(--metric), var(--sar))"}; | ||||
|     } | ||||
|  | ||||
|     .Dialog__title { | ||||
|       grid-template-columns: calc(var(--space-factor) * 7) 1fr calc( | ||||
|           var(--space-factor) * 7 | ||||
|         ); | ||||
|       position: sticky; | ||||
|       top: 0; | ||||
|       padding: calc(var(--space-factor) * 2); | ||||
|       background: var(--island-bg-color); | ||||
|       font-size: 1.25em; | ||||
|  | ||||
|       box-sizing: border-box; | ||||
|       border-bottom: 1px solid var(--button-gray-2); | ||||
|       z-index: 1; | ||||
|     } | ||||
|  | ||||
|     .Dialog__titleContent { | ||||
|       text-align: center; | ||||
|     } | ||||
|  | ||||
|     .Dialog .Island { | ||||
|       width: 100vw; | ||||
|       height: 100%; | ||||
|       box-sizing: border-box; | ||||
|       overflow-y: auto; | ||||
|       padding-left: #{"max(calc(var(--padding) * var(--space-factor)), var(--sal))"}; | ||||
|       padding-right: #{"max(calc(var(--padding) * var(--space-factor)), var(--sar))"}; | ||||
|       padding-bottom: #{"max(calc(var(--padding) * var(--space-factor)), var(--sab))"}; | ||||
|     } | ||||
|  | ||||
|     .Dialog .Modal__close { | ||||
|       order: -1; | ||||
|     } | ||||
|     text-align: left; | ||||
|     font-size: 1.25rem; | ||||
|     border-bottom: 1px solid var(--dialog-border-color); | ||||
|     padding: 0 0 0.75rem; | ||||
|     margin-bottom: 1.5rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -5,11 +5,13 @@ import { t } from "../i18n"; | ||||
| import { useExcalidrawContainer, useDevice } from "../components/App"; | ||||
| import { KEYS } from "../keys"; | ||||
| import "./Dialog.scss"; | ||||
| import { back, close } from "./icons"; | ||||
| import { back, CloseIcon } from "./icons"; | ||||
| 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"; | ||||
|  | ||||
| export interface DialogProps { | ||||
|   children: React.ReactNode; | ||||
| @@ -65,7 +67,12 @@ export const Dialog = (props: DialogProps) => { | ||||
|     return () => islandNode.removeEventListener("keydown", handleKeyDown); | ||||
|   }, [islandNode, props.autofocus]); | ||||
|  | ||||
|   const setIsMenuOpen = useSetAtom(isMenuOpenAtom); | ||||
|   const setIsDropdownOpen = useSetAtom(isDropdownOpenAtom); | ||||
|  | ||||
|   const onClose = () => { | ||||
|     setIsMenuOpen(false); | ||||
|     setIsDropdownOpen(false); | ||||
|     (lastActiveElement as HTMLElement).focus(); | ||||
|     props.onCloseRequest(); | ||||
|   }; | ||||
| @@ -88,7 +95,7 @@ export const Dialog = (props: DialogProps) => { | ||||
|             title={t("buttons.close")} | ||||
|             aria-label={t("buttons.close")} | ||||
|           > | ||||
|             {useDevice().isMobile ? back : close} | ||||
|             {useDevice().isMobile ? back : CloseIcon} | ||||
|           </button> | ||||
|         </h2> | ||||
|         <div className="Dialog__content">{props.children}</div> | ||||
|   | ||||
							
								
								
									
										47
									
								
								src/components/DialogActionButton.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								src/components/DialogActionButton.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,47 @@ | ||||
| .excalidraw { | ||||
|   .Dialog__action-button { | ||||
|     position: relative; | ||||
|     display: flex; | ||||
|     column-gap: 0.5rem; | ||||
|     align-items: center; | ||||
|     padding: 0.5rem 1.5rem; | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     background-color: transparent; | ||||
|     height: 3rem; | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     letter-spacing: 0.4px; | ||||
|     color: inherit; | ||||
|     font-family: inherit; | ||||
|     font-size: 0.875rem; | ||||
|     font-weight: 600; | ||||
|     user-select: none; | ||||
|  | ||||
|     svg { | ||||
|       display: block; | ||||
|       width: 1rem; | ||||
|       height: 1rem; | ||||
|     } | ||||
|  | ||||
|     &--danger { | ||||
|       background-color: var(--color-danger); | ||||
|       border-color: var(--color-danger); | ||||
|       color: #fff; | ||||
|     } | ||||
|  | ||||
|     &--primary { | ||||
|       background-color: var(--color-primary); | ||||
|       border-color: var(--color-primary); | ||||
|       color: #fff; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .Dialog__action-button--danger { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|  | ||||
|     .Dialog__action-button--primary { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										46
									
								
								src/components/DialogActionButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/components/DialogActionButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import clsx from "clsx"; | ||||
| import { ReactNode } from "react"; | ||||
| import "./DialogActionButton.scss"; | ||||
| import Spinner from "./Spinner"; | ||||
|  | ||||
| interface DialogActionButtonProps { | ||||
|   label: string; | ||||
|   children?: ReactNode; | ||||
|   actionType?: "primary" | "danger"; | ||||
|   isLoading?: boolean; | ||||
| } | ||||
|  | ||||
| const DialogActionButton = ({ | ||||
|   label, | ||||
|   onClick, | ||||
|   className, | ||||
|   children, | ||||
|   actionType, | ||||
|   type = "button", | ||||
|   isLoading, | ||||
|   ...rest | ||||
| }: DialogActionButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||
|   const cs = actionType ? `Dialog__action-button--${actionType}` : ""; | ||||
|  | ||||
|   return ( | ||||
|     <button | ||||
|       className={clsx("Dialog__action-button", cs, className)} | ||||
|       type={type} | ||||
|       aria-label={label} | ||||
|       onClick={onClick} | ||||
|       {...rest} | ||||
|     > | ||||
|       {children && ( | ||||
|         <div style={isLoading ? { visibility: "hidden" } : {}}>{children}</div> | ||||
|       )} | ||||
|       <div style={isLoading ? { visibility: "hidden" } : {}}>{label}</div> | ||||
|       {isLoading && ( | ||||
|         <div style={{ position: "absolute", inset: 0 }}> | ||||
|           <Spinner /> | ||||
|         </div> | ||||
|       )} | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default DialogActionButton; | ||||
							
								
								
									
										19
									
								
								src/components/EncryptedIcon.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src/components/EncryptedIcon.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { t } from "../i18n"; | ||||
| import { shield } from "./icons"; | ||||
| import { Tooltip } from "./Tooltip"; | ||||
|  | ||||
| const EncryptedIcon = () => ( | ||||
|   <a | ||||
|     className="encrypted-icon tooltip" | ||||
|     href="https://blog.excalidraw.com/end-to-end-encryption/" | ||||
|     target="_blank" | ||||
|     rel="noopener noreferrer" | ||||
|     aria-label={t("encrypted.link")} | ||||
|   > | ||||
|     <Tooltip label={t("encrypted.tooltip")} long={true}> | ||||
|       {shield} | ||||
|     </Tooltip> | ||||
|   </a> | ||||
| ); | ||||
|  | ||||
| export default EncryptedIcon; | ||||
| @@ -91,6 +91,8 @@ | ||||
|   } | ||||
|  | ||||
|   button.ExportDialog-imageExportButton { | ||||
|     border: 0; | ||||
|  | ||||
|     width: 5rem; | ||||
|     height: 5rem; | ||||
|     margin: 0 0.2em; | ||||
|   | ||||
| @@ -9,9 +9,10 @@ | ||||
|   } | ||||
|  | ||||
|   .FixedSideContainer_side_top { | ||||
|     left: var(--space-factor); | ||||
|     top: var(--space-factor); | ||||
|     right: var(--space-factor); | ||||
|     left: 1rem; | ||||
|     top: 1rem; | ||||
|     right: 1rem; | ||||
|     bottom: 1rem; | ||||
|     z-index: 2; | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import clsx from "clsx"; | ||||
| import { ActionManager } from "../actions/manager"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppState, ExcalidrawProps } from "../types"; | ||||
| import { | ||||
|   ExitZenModeAction, | ||||
| @@ -8,20 +9,23 @@ import { | ||||
|   ZoomActions, | ||||
| } from "./Actions"; | ||||
| import { useDevice } from "./App"; | ||||
| import { Island } from "./Island"; | ||||
| 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 device = useDevice(); | ||||
|   const showFinalize = | ||||
| @@ -39,31 +43,19 @@ const Footer = ({ | ||||
|       > | ||||
|         <Stack.Col gap={2}> | ||||
|           <Section heading="canvasActions"> | ||||
|             <Island padding={1}> | ||||
|               <ZoomActions | ||||
|                 renderAction={actionManager.renderAction} | ||||
|                 zoom={appState.zoom} | ||||
|               /> | ||||
|             </Island> | ||||
|             {!appState.viewModeEnabled && ( | ||||
|               <> | ||||
|                 <UndoRedoActions | ||||
|                   renderAction={actionManager.renderAction} | ||||
|                   className={clsx("zen-mode-transition", { | ||||
|                     "layer-ui__wrapper__footer-left--transition-bottom": | ||||
|                       appState.zenModeEnabled, | ||||
|                   })} | ||||
|                 /> | ||||
|             <ZoomActions | ||||
|               renderAction={actionManager.renderAction} | ||||
|               zoom={appState.zoom} | ||||
|             /> | ||||
|  | ||||
|                 <div | ||||
|                   className={clsx("eraser-buttons zen-mode-transition", { | ||||
|                     "layer-ui__wrapper__footer-left--transition-left": | ||||
|                       appState.zenModeEnabled, | ||||
|                   })} | ||||
|                 > | ||||
|                   {actionManager.renderAction("eraser", { size: "small" })} | ||||
|                 </div> | ||||
|               </> | ||||
|             {!appState.viewModeEnabled && ( | ||||
|               <UndoRedoActions | ||||
|                 renderAction={actionManager.renderAction} | ||||
|                 className={clsx("zen-mode-transition", { | ||||
|                   "layer-ui__wrapper__footer-left--transition-bottom": | ||||
|                     appState.zenModeEnabled, | ||||
|                 })} | ||||
|               /> | ||||
|             )} | ||||
|             {showFinalize && ( | ||||
|               <FinalizeAction | ||||
| @@ -93,7 +85,18 @@ const Footer = ({ | ||||
|           "transition-right disable-pointerEvents": appState.zenModeEnabled, | ||||
|         })} | ||||
|       > | ||||
|         {actionManager.renderAction("toggleShortcuts")} | ||||
|         <div style={{ position: "relative" }}> | ||||
|           <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 | ||||
|         actionManager={actionManager} | ||||
|   | ||||
| @@ -1,13 +1,13 @@ | ||||
| import { questionCircle } from "../components/icons"; | ||||
| import { HelpIcon } from "./icons"; | ||||
| 
 | ||||
| type HelpIconProps = { | ||||
| type HelpButtonProps = { | ||||
|   title?: string; | ||||
|   name?: string; | ||||
|   id?: string; | ||||
|   onClick?(): void; | ||||
| }; | ||||
| 
 | ||||
| export const HelpIcon = (props: HelpIconProps) => ( | ||||
| export const HelpButton = (props: HelpButtonProps) => ( | ||||
|   <button | ||||
|     className="help-icon" | ||||
|     onClick={props.onClick} | ||||
| @@ -15,6 +15,6 @@ export const HelpIcon = (props: HelpIconProps) => ( | ||||
|     title={`${props.title} — ?`} | ||||
|     aria-label={props.title} | ||||
|   > | ||||
|     {questionCircle} | ||||
|     {HelpIcon} | ||||
|   </button> | ||||
| ); | ||||
| @@ -1,56 +1,115 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .HelpDialog h3 { | ||||
|     border-bottom: 1px solid var(--button-gray-2); | ||||
|     padding-bottom: 4px; | ||||
|   } | ||||
|   .HelpDialog { | ||||
|     .Modal__content { | ||||
|       max-width: 960px; | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--island { | ||||
|     border: 1px solid var(--button-gray-2); | ||||
|     margin-bottom: 16px; | ||||
|   } | ||||
|     h3 { | ||||
|       margin: 1.5rem 0; | ||||
|       font-weight: bold; | ||||
|       font-size: 1.125rem; | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--island-title { | ||||
|     margin: 0; | ||||
|     padding: 4px; | ||||
|     background-color: var(--button-gray-1); | ||||
|     text-align: center; | ||||
|   } | ||||
|     &__header { | ||||
|       display: flex; | ||||
|       flex-wrap: wrap; | ||||
|       gap: 0.75rem; | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--shortcut { | ||||
|     border-top: 1px solid var(--button-gray-2); | ||||
|   } | ||||
|     &__btn { | ||||
|       display: flex; | ||||
|       column-gap: 0.5rem; | ||||
|       align-items: center; | ||||
|       border: 1px solid var(--default-border-color); | ||||
|       padding: 0.625rem 1rem; | ||||
|       border-radius: var(--border-radius-lg); | ||||
|       color: var(--text-primary-color); | ||||
|       font-weight: 600; | ||||
|       font-size: 0.75rem; | ||||
|       letter-spacing: 0.4px; | ||||
|  | ||||
|   .HelpDialog--key { | ||||
|     word-break: keep-all; | ||||
|     border: 1px solid var(--button-gray-2); | ||||
|     padding: 2px 8px; | ||||
|     margin: auto 4px; | ||||
|     background-color: var(--button-gray-1); | ||||
|     border-radius: 2px; | ||||
|     font-size: 0.8em; | ||||
|     min-height: 26px; | ||||
|     box-sizing: border-box; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     font-family: inherit; | ||||
|   } | ||||
|       &:hover { | ||||
|         text-decoration: none; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--header { | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-evenly; | ||||
|     margin-bottom: 32px; | ||||
|     padding-bottom: 16px; | ||||
|   } | ||||
|     &__link-icon { | ||||
|       line-height: 0; | ||||
|       svg { | ||||
|         width: 1rem; | ||||
|         height: 1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|   .HelpDialog--btn { | ||||
|     border: 1px solid var(--link-color); | ||||
|     padding: 8px 32px; | ||||
|     border-radius: 4px; | ||||
|   } | ||||
|   .HelpDialog--btn:hover { | ||||
|     text-decoration: none; | ||||
|     &__islands-container { | ||||
|       display: grid; | ||||
|       @media screen and (min-width: 1024px) { | ||||
|         grid-template-columns: 1fr 1fr; | ||||
|       } | ||||
|       grid-column-gap: 1.5rem; | ||||
|       grid-row-gap: 2rem; | ||||
|     } | ||||
|  | ||||
|     @media screen and (min-width: 1024px) { | ||||
|       &__island--tools { | ||||
|         grid-area: 1 / 1 / 2 / 2; | ||||
|       } | ||||
|       &__island--view { | ||||
|         grid-area: 2 / 1 / 3 / 2; | ||||
|       } | ||||
|       &__island--editor { | ||||
|         grid-area: 1 / 2 / 3 / 3; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__island { | ||||
|       h4 { | ||||
|         font-size: 1rem; | ||||
|         font-weight: bold; | ||||
|         margin: 0; | ||||
|         margin-bottom: 0.625rem; | ||||
|       } | ||||
|  | ||||
|       &-content { | ||||
|         border: 1px solid var(--dialog-border-color); | ||||
|         border-radius: var(--border-radius-lg); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__shortcut { | ||||
|       border-bottom: 1px solid var(--dialog-border-color); | ||||
|       padding: 0.375rem 0.75rem; | ||||
|       display: flex; | ||||
|       justify-content: space-between; | ||||
|       align-items: center; | ||||
|       font-size: 0.875rem; | ||||
|       column-gap: 0.5rem; | ||||
|  | ||||
|       &:last-child { | ||||
|         border-bottom: none; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__key-container { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       column-gap: 0.25rem; | ||||
|       flex-shrink: 0; | ||||
|     } | ||||
|  | ||||
|     &__key { | ||||
|       display: flex; | ||||
|       box-sizing: border-box; | ||||
|       font-size: 0.625rem; | ||||
|       background-color: var(--color-primary-light); | ||||
|       border-radius: var(--border-radius-md); | ||||
|       padding: 0.5rem; | ||||
|       word-break: keep-all; | ||||
|       align-items: center; | ||||
|       font-family: inherit; | ||||
|       line-height: 1; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,35 +1,39 @@ | ||||
| import React from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { isDarwin, isWindows } from "../keys"; | ||||
| import { isDarwin, isWindows, KEYS } from "../keys"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { getShortcutKey } from "../utils"; | ||||
| import "./HelpDialog.scss"; | ||||
| import { ExternalLinkIcon } from "./icons"; | ||||
|  | ||||
| const Header = () => ( | ||||
|   <div className="HelpDialog--header"> | ||||
|   <div className="HelpDialog__header"> | ||||
|     <a | ||||
|       className="HelpDialog--btn" | ||||
|       className="HelpDialog__btn" | ||||
|       href="https://github.com/excalidraw/excalidraw#documentation" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       {t("helpDialog.documentation")} | ||||
|       <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> | ||||
|     </a> | ||||
|     <a | ||||
|       className="HelpDialog--btn" | ||||
|       className="HelpDialog__btn" | ||||
|       href="https://blog.excalidraw.com" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       {t("helpDialog.blog")} | ||||
|       <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> | ||||
|     </a> | ||||
|     <a | ||||
|       className="HelpDialog--btn" | ||||
|       className="HelpDialog__btn" | ||||
|       href="https://github.com/excalidraw/excalidraw/issues" | ||||
|       target="_blank" | ||||
|       rel="noopener noreferrer" | ||||
|     > | ||||
|       {t("helpDialog.github")} | ||||
|       <div className="HelpDialog__link-icon">{ExternalLinkIcon}</div> | ||||
|     </a> | ||||
|   </div> | ||||
| ); | ||||
| @@ -37,88 +41,61 @@ const Header = () => ( | ||||
| const Section = (props: { title: string; children: React.ReactNode }) => ( | ||||
|   <> | ||||
|     <h3>{props.title}</h3> | ||||
|     {props.children} | ||||
|     <div className="HelpDialog__islands-container">{props.children}</div> | ||||
|   </> | ||||
| ); | ||||
|  | ||||
| const Columns = (props: { children: React.ReactNode }) => ( | ||||
|   <div | ||||
|     style={{ | ||||
|       display: "flex", | ||||
|       flexDirection: "row", | ||||
|       flexWrap: "wrap", | ||||
|       justifyContent: "space-between", | ||||
|     }} | ||||
|   > | ||||
|     {props.children} | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const Column = (props: { children: React.ReactNode }) => ( | ||||
|   <div style={{ width: "49%" }}>{props.children}</div> | ||||
| ); | ||||
|  | ||||
| const ShortcutIsland = (props: { | ||||
|   caption: string; | ||||
|   children: React.ReactNode; | ||||
|   className?: string; | ||||
| }) => ( | ||||
|   <div className="HelpDialog--island"> | ||||
|     <h3 className="HelpDialog--island-title">{props.caption}</h3> | ||||
|     {props.children} | ||||
|   <div className={`HelpDialog__island ${props.className}`}> | ||||
|     <h4 className="HelpDialog__island-title">{props.caption}</h4> | ||||
|     <div className="HelpDialog__island-content">{props.children}</div> | ||||
|   </div> | ||||
| ); | ||||
|  | ||||
| const Shortcut = (props: { | ||||
| function* intersperse(as: JSX.Element[][], delim: string | null) { | ||||
|   let first = true; | ||||
|   for (const x of as) { | ||||
|     if (!first) { | ||||
|       yield delim; | ||||
|     } | ||||
|     first = false; | ||||
|     yield x; | ||||
|   } | ||||
| } | ||||
|  | ||||
| const Shortcut = ({ | ||||
|   label, | ||||
|   shortcuts, | ||||
|   isOr = true, | ||||
| }: { | ||||
|   label: string; | ||||
|   shortcuts: string[]; | ||||
|   isOr: boolean; | ||||
|   isOr?: boolean; | ||||
| }) => { | ||||
|   const splitShortcutKeys = shortcuts.map((shortcut) => { | ||||
|     const keys = shortcut.endsWith("++") | ||||
|       ? [...shortcut.slice(0, -2).split("+"), "+"] | ||||
|       : shortcut.split("+"); | ||||
|  | ||||
|     return keys.map((key) => <ShortcutKey key={key}>{key}</ShortcutKey>); | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <div className="HelpDialog--shortcut"> | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           margin: "0", | ||||
|           padding: "4px 8px", | ||||
|           alignItems: "center", | ||||
|         }} | ||||
|       > | ||||
|         <div | ||||
|           style={{ | ||||
|             lineHeight: 1.4, | ||||
|           }} | ||||
|         > | ||||
|           {props.label} | ||||
|         </div> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             flex: "0 0 auto", | ||||
|             justifyContent: "flex-end", | ||||
|             marginInlineStart: "auto", | ||||
|             minWidth: "30%", | ||||
|           }} | ||||
|         > | ||||
|           {props.shortcuts.map((shortcut, index) => ( | ||||
|             <React.Fragment key={index}> | ||||
|               <ShortcutKey>{shortcut}</ShortcutKey> | ||||
|               {props.isOr && | ||||
|                 index !== props.shortcuts.length - 1 && | ||||
|                 t("helpDialog.or")} | ||||
|             </React.Fragment> | ||||
|           ))} | ||||
|         </div> | ||||
|     <div className="HelpDialog__shortcut"> | ||||
|       <div>{label}</div> | ||||
|       <div className="HelpDialog__key-container"> | ||||
|         {[...intersperse(splitShortcutKeys, isOr ? t("helpDialog.or") : null)]} | ||||
|       </div> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| Shortcut.defaultProps = { | ||||
|   isOr: true, | ||||
| }; | ||||
|  | ||||
| const ShortcutKey = (props: { children: React.ReactNode }) => ( | ||||
|   <kbd className="HelpDialog--key" {...props} /> | ||||
|   <kbd className="HelpDialog__key" {...props} /> | ||||
| ); | ||||
|  | ||||
| export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
| @@ -137,286 +114,296 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => { | ||||
|       > | ||||
|         <Header /> | ||||
|         <Section title={t("helpDialog.shortcuts")}> | ||||
|           <Columns> | ||||
|             <Column> | ||||
|               <ShortcutIsland caption={t("helpDialog.tools")}> | ||||
|                 <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={["Shift + P", "X", "7"]} | ||||
|                 /> | ||||
|                 <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={[getShortcutKey("E")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.editSelectedShape")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey("Enter"), | ||||
|                     t("helpDialog.doubleClick"), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.textNewLine")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey("Enter"), | ||||
|                     getShortcutKey("Shift+Enter"), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.textFinish")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey("Esc"), | ||||
|                     getShortcutKey("CtrlOrCmd+Enter"), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.curvedArrow")} | ||||
|                   shortcuts={[ | ||||
|                     "A", | ||||
|                     t("helpDialog.click"), | ||||
|                     t("helpDialog.click"), | ||||
|                     t("helpDialog.click"), | ||||
|                   ]} | ||||
|                   isOr={false} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.curvedLine")} | ||||
|                   shortcuts={[ | ||||
|                     "L", | ||||
|                     t("helpDialog.click"), | ||||
|                     t("helpDialog.click"), | ||||
|                     t("helpDialog.click"), | ||||
|                   ]} | ||||
|                   isOr={false} | ||||
|                 /> | ||||
|                 <Shortcut label={t("toolBar.lock")} shortcuts={["Q"]} /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.preventBinding")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("toolBar.link")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+K")]} | ||||
|                 /> | ||||
|               </ShortcutIsland> | ||||
|               <ShortcutIsland caption={t("helpDialog.view")}> | ||||
|                 <Shortcut | ||||
|                   label={t("buttons.zoomIn")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd++")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("buttons.zoomOut")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+-")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("buttons.resetZoom")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+0")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.zoomToFit")} | ||||
|                   shortcuts={["Shift+1"]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.zoomToSelection")} | ||||
|                   shortcuts={["Shift+2"]} | ||||
|                 /> | ||||
|                 <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> | ||||
|                 <Shortcut | ||||
|                   label={t("buttons.zenMode")} | ||||
|                   shortcuts={[getShortcutKey("Alt+Z")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.showGrid")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+'")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.viewMode")} | ||||
|                   shortcuts={[getShortcutKey("Alt+R")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.toggleTheme")} | ||||
|                   shortcuts={[getShortcutKey("Alt+Shift+D")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("stats.title")} | ||||
|                   shortcuts={[getShortcutKey("Alt+/")]} | ||||
|                 /> | ||||
|               </ShortcutIsland> | ||||
|             </Column> | ||||
|             <Column> | ||||
|               <ShortcutIsland caption={t("helpDialog.editor")}> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.selectAll")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+A")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.multiSelect")} | ||||
|                   shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.deepSelect")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.deepBoxSelect")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.moveCanvas")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey(`Space+${t("helpDialog.drag")}`), | ||||
|                     getShortcutKey(`Wheel+${t("helpDialog.drag")}`), | ||||
|                   ]} | ||||
|                   isOr={true} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.cut")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+X")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.copy")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+C")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.paste")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+V")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.copyAsPng")} | ||||
|                   shortcuts={[getShortcutKey("Shift+Alt+C")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.copyStyles")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.pasteStyles")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.delete")} | ||||
|                   shortcuts={[getShortcutKey("Del")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.sendToBack")} | ||||
|                   shortcuts={[ | ||||
|                     isDarwin | ||||
|                       ? getShortcutKey("CtrlOrCmd+Alt+[") | ||||
|                       : getShortcutKey("CtrlOrCmd+Shift+["), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.bringToFront")} | ||||
|                   shortcuts={[ | ||||
|                     isDarwin | ||||
|                       ? getShortcutKey("CtrlOrCmd+Alt+]") | ||||
|                       : getShortcutKey("CtrlOrCmd+Shift+]"), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.sendBackward")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+[")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.bringForward")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+]")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.alignTop")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.alignBottom")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.alignLeft")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.alignRight")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.duplicateSelection")} | ||||
|                   shortcuts={[ | ||||
|                     getShortcutKey("CtrlOrCmd+D"), | ||||
|                     getShortcutKey(`Alt+${t("helpDialog.drag")}`), | ||||
|                   ]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("helpDialog.toggleElementLock")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("buttons.undo")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Z")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("buttons.redo")} | ||||
|                   shortcuts={ | ||||
|                     isWindows | ||||
|                       ? [ | ||||
|                           getShortcutKey("CtrlOrCmd+Y"), | ||||
|                           getShortcutKey("CtrlOrCmd+Shift+Z"), | ||||
|                         ] | ||||
|                       : [getShortcutKey("CtrlOrCmd+Shift+Z")] | ||||
|                   } | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.group")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+G")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.ungroup")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.flipHorizontal")} | ||||
|                   shortcuts={[getShortcutKey("Shift+H")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.flipVertical")} | ||||
|                   shortcuts={[getShortcutKey("Shift+V")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.showStroke")} | ||||
|                   shortcuts={[getShortcutKey("S")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.showBackground")} | ||||
|                   shortcuts={[getShortcutKey("G")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.decreaseFontSize")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} | ||||
|                 /> | ||||
|                 <Shortcut | ||||
|                   label={t("labels.increaseFontSize")} | ||||
|                   shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]} | ||||
|                 /> | ||||
|               </ShortcutIsland> | ||||
|             </Column> | ||||
|           </Columns> | ||||
|           <ShortcutIsland | ||||
|             className="HelpDialog__island--tools" | ||||
|             caption={t("helpDialog.tools")} | ||||
|           > | ||||
|             <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.P, KEYS["6"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.freedraw")} | ||||
|               shortcuts={["Shift + P", KEYS["7"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.text")} | ||||
|               shortcuts={[KEYS.T, KEYS["8"]]} | ||||
|             /> | ||||
|             <Shortcut label={t("toolBar.image")} shortcuts={[KEYS["9"]]} /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.eraser")} | ||||
|               shortcuts={[KEYS.E, KEYS["0"]]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.editSelectedShape")} | ||||
|               shortcuts={[getShortcutKey("Enter"), t("helpDialog.doubleClick")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.textNewLine")} | ||||
|               shortcuts={[ | ||||
|                 getShortcutKey("Enter"), | ||||
|                 getShortcutKey("Shift+Enter"), | ||||
|               ]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.textFinish")} | ||||
|               shortcuts={[ | ||||
|                 getShortcutKey("Esc"), | ||||
|                 getShortcutKey("CtrlOrCmd+Enter"), | ||||
|               ]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.curvedArrow")} | ||||
|               shortcuts={[ | ||||
|                 "A", | ||||
|                 t("helpDialog.click"), | ||||
|                 t("helpDialog.click"), | ||||
|                 t("helpDialog.click"), | ||||
|               ]} | ||||
|               isOr={false} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.curvedLine")} | ||||
|               shortcuts={[ | ||||
|                 "L", | ||||
|                 t("helpDialog.click"), | ||||
|                 t("helpDialog.click"), | ||||
|                 t("helpDialog.click"), | ||||
|               ]} | ||||
|               isOr={false} | ||||
|             /> | ||||
|             <Shortcut label={t("toolBar.lock")} shortcuts={[KEYS.Q]} /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.preventBinding")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("toolBar.link")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+K")]} | ||||
|             /> | ||||
|           </ShortcutIsland> | ||||
|           <ShortcutIsland | ||||
|             className="HelpDialog__island--view" | ||||
|             caption={t("helpDialog.view")} | ||||
|           > | ||||
|             <Shortcut | ||||
|               label={t("buttons.zoomIn")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd++")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("buttons.zoomOut")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+-")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("buttons.resetZoom")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+0")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.zoomToFit")} | ||||
|               shortcuts={["Shift+1"]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.zoomToSelection")} | ||||
|               shortcuts={["Shift+2"]} | ||||
|             /> | ||||
|             <Shortcut label={t("buttons.fullScreen")} shortcuts={["F"]} /> | ||||
|             <Shortcut | ||||
|               label={t("buttons.zenMode")} | ||||
|               shortcuts={[getShortcutKey("Alt+Z")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.showGrid")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+'")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.viewMode")} | ||||
|               shortcuts={[getShortcutKey("Alt+R")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.toggleTheme")} | ||||
|               shortcuts={[getShortcutKey("Alt+Shift+D")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("stats.title")} | ||||
|               shortcuts={[getShortcutKey("Alt+/")]} | ||||
|             /> | ||||
|           </ShortcutIsland> | ||||
|           <ShortcutIsland | ||||
|             className="HelpDialog__island--editor" | ||||
|             caption={t("helpDialog.editor")} | ||||
|           > | ||||
|             <Shortcut | ||||
|               label={t("labels.selectAll")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+A")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.multiSelect")} | ||||
|               shortcuts={[getShortcutKey(`Shift+${t("helpDialog.click")}`)]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.deepSelect")} | ||||
|               shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.click")}`)]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.deepBoxSelect")} | ||||
|               shortcuts={[getShortcutKey(`CtrlOrCmd+${t("helpDialog.drag")}`)]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.moveCanvas")} | ||||
|               shortcuts={[ | ||||
|                 getShortcutKey(`Space+${t("helpDialog.drag")}`), | ||||
|                 getShortcutKey(`Wheel+${t("helpDialog.drag")}`), | ||||
|               ]} | ||||
|               isOr={true} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.cut")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+X")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.copy")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+C")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.paste")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+V")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.copyAsPng")} | ||||
|               shortcuts={[getShortcutKey("Shift+Alt+C")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.copyStyles")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Alt+C")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.pasteStyles")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Alt+V")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.delete")} | ||||
|               shortcuts={[getShortcutKey("Del")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.sendToBack")} | ||||
|               shortcuts={[ | ||||
|                 isDarwin | ||||
|                   ? getShortcutKey("CtrlOrCmd+Alt+[") | ||||
|                   : getShortcutKey("CtrlOrCmd+Shift+["), | ||||
|               ]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.bringToFront")} | ||||
|               shortcuts={[ | ||||
|                 isDarwin | ||||
|                   ? getShortcutKey("CtrlOrCmd+Alt+]") | ||||
|                   : getShortcutKey("CtrlOrCmd+Shift+]"), | ||||
|               ]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.sendBackward")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+[")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.bringForward")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+]")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.alignTop")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Up")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.alignBottom")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Down")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.alignLeft")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Left")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.alignRight")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+Right")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.duplicateSelection")} | ||||
|               shortcuts={[ | ||||
|                 getShortcutKey("CtrlOrCmd+D"), | ||||
|                 getShortcutKey(`Alt+${t("helpDialog.drag")}`), | ||||
|               ]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("helpDialog.toggleElementLock")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+L")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("buttons.undo")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Z")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("buttons.redo")} | ||||
|               shortcuts={ | ||||
|                 isWindows | ||||
|                   ? [ | ||||
|                       getShortcutKey("CtrlOrCmd+Y"), | ||||
|                       getShortcutKey("CtrlOrCmd+Shift+Z"), | ||||
|                     ] | ||||
|                   : [getShortcutKey("CtrlOrCmd+Shift+Z")] | ||||
|               } | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.group")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+G")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.ungroup")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+G")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.flipHorizontal")} | ||||
|               shortcuts={[getShortcutKey("Shift+H")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.flipVertical")} | ||||
|               shortcuts={[getShortcutKey("Shift+V")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.showStroke")} | ||||
|               shortcuts={[getShortcutKey("S")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.showBackground")} | ||||
|               shortcuts={[getShortcutKey("G")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.decreaseFontSize")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]} | ||||
|             /> | ||||
|             <Shortcut | ||||
|               label={t("labels.increaseFontSize")} | ||||
|               shortcuts={[getShortcutKey("CtrlOrCmd+Shift+>")]} | ||||
|             /> | ||||
|           </ShortcutIsland> | ||||
|         </Section> | ||||
|       </Dialog> | ||||
|     </> | ||||
|   | ||||
| @@ -14,20 +14,24 @@ $wide-viewport-width: 1000px; | ||||
|     top: 100%; | ||||
|     max-width: 100%; | ||||
|     width: 100%; | ||||
|     margin-top: 6px; | ||||
|     margin-top: 0.5rem; | ||||
|     text-align: center; | ||||
|     color: $oc-gray-6; | ||||
|     font-size: 0.8rem; | ||||
|     color: var(--color-gray-40); | ||||
|     font-size: 0.75rem; | ||||
|  | ||||
|     @include isMobile { | ||||
|       position: static; | ||||
|       padding-right: 2em; | ||||
|       padding-right: 2rem; | ||||
|     } | ||||
|  | ||||
|     > span { | ||||
|       padding: 0.2rem 0.4rem; | ||||
|       background-color: var(--overlay-bg-color); | ||||
|       border-radius: 4px; | ||||
|       padding: 0.25rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .HintViewer { | ||||
|       color: var(--color-gray-60); | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -10,7 +10,8 @@ | ||||
|   .picker { | ||||
|     background: var(--popup-bg-color); | ||||
|     border: 0 solid transparentize($oc-white, 0.75); | ||||
|     box-shadow: transparentize($oc-black, 0.75) 0 1px 4px; | ||||
|     // ˇˇ yeah, i dunno, open to suggestions here :D | ||||
|     box-shadow: rgb(0 0 0 / 25%) 2px 2px 4px 2px; | ||||
|     border-radius: 4px; | ||||
|     position: absolute; | ||||
|   } | ||||
| @@ -46,7 +47,6 @@ | ||||
|       margin: 0; | ||||
|       width: 36px; | ||||
|       height: 18px; | ||||
|       opacity: 0.6; | ||||
|       pointer-events: none; | ||||
|     } | ||||
|   } | ||||
|   | ||||
| @@ -4,6 +4,7 @@ import { Popover } from "./Popover"; | ||||
| import "./IconPicker.scss"; | ||||
| import { isArrowKey, KEYS } from "../keys"; | ||||
| import { getLanguage } from "../i18n"; | ||||
| import clsx from "clsx"; | ||||
|  | ||||
| function Picker<T>({ | ||||
|   options, | ||||
| @@ -102,7 +103,9 @@ function Picker<T>({ | ||||
|       <div className="picker-content" ref={rGallery}> | ||||
|         {options.map((option, i) => ( | ||||
|           <button | ||||
|             className="picker-option" | ||||
|             className={clsx("picker-option", { | ||||
|               active: value === option.value, | ||||
|             })} | ||||
|             onClick={(event) => { | ||||
|               (event.currentTarget as HTMLButtonElement).focus(); | ||||
|               onChange(option.value); | ||||
| @@ -150,7 +153,7 @@ export function IconPicker<T>({ | ||||
|   const isRTL = getLanguage().rtl; | ||||
|  | ||||
|   return ( | ||||
|     <label className={"picker-container"}> | ||||
|     <div> | ||||
|       <button | ||||
|         name={group} | ||||
|         className={isActive ? "active" : ""} | ||||
| @@ -184,6 +187,6 @@ export function IconPicker<T>({ | ||||
|           </> | ||||
|         ) : null} | ||||
|       </React.Suspense> | ||||
|     </label> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -5,14 +5,12 @@ import { canvasToBlob } from "../data/blob"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { CanvasError } from "../errors"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "./App"; | ||||
| import { getSelectedElements, isSomeElementSelected } from "../scene"; | ||||
| import { exportToCanvas } from "../scene/export"; | ||||
| import { AppState, BinaryFiles } from "../types"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { clipboard, exportImage } from "./icons"; | ||||
| import { clipboard } from "./icons"; | ||||
| import Stack from "./Stack"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import "./ExportDialog.scss"; | ||||
| import OpenColor from "open-color"; | ||||
| import { CheckboxItem } from "./CheckboxItem"; | ||||
| @@ -221,6 +219,7 @@ const ImageExportModal = ({ | ||||
| export const ImageExportDialog = ({ | ||||
|   elements, | ||||
|   appState, | ||||
|   setAppState, | ||||
|   files, | ||||
|   exportPadding = DEFAULT_EXPORT_PADDING, | ||||
|   actionManager, | ||||
| @@ -229,6 +228,7 @@ export const ImageExportDialog = ({ | ||||
|   onExportToClipboard, | ||||
| }: { | ||||
|   appState: AppState; | ||||
|   setAppState: React.Component<any, AppState>["setState"]; | ||||
|   elements: readonly NonDeletedExcalidrawElement[]; | ||||
|   files: BinaryFiles; | ||||
|   exportPadding?: number; | ||||
| @@ -237,26 +237,13 @@ export const ImageExportDialog = ({ | ||||
|   onExportToSvg: ExportCB; | ||||
|   onExportToClipboard: ExportCB; | ||||
| }) => { | ||||
|   const [modalIsShown, setModalIsShown] = useState(false); | ||||
|  | ||||
|   const handleClose = React.useCallback(() => { | ||||
|     setModalIsShown(false); | ||||
|   }, []); | ||||
|     setAppState({ openDialog: null }); | ||||
|   }, [setAppState]); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|         onClick={() => { | ||||
|           setModalIsShown(true); | ||||
|         }} | ||||
|         data-testid="image-export-button" | ||||
|         icon={exportImage} | ||||
|         type="button" | ||||
|         aria-label={t("buttons.exportImage")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|         title={t("buttons.exportImage")} | ||||
|       /> | ||||
|       {modalIsShown && ( | ||||
|       {appState.openDialog === "imageExport" && ( | ||||
|         <Dialog onCloseRequest={handleClose} title={t("buttons.exportImage")}> | ||||
|           <ImageExportModal | ||||
|             elements={elements} | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| .excalidraw { | ||||
|   .Island { | ||||
|     --padding: 0; | ||||
|     box-sizing: border-box; | ||||
|     background-color: var(--island-bg-color); | ||||
|     box-shadow: var(--shadow-island); | ||||
|     border-radius: var(--border-radius-lg); | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import React, { useState } from "react"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| import { useDevice } from "./App"; | ||||
|  | ||||
| import { AppState, ExportOpts, BinaryFiles } from "../types"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { exportFile, exportToFileIcon, link } 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[], | ||||
| @@ -63,7 +64,7 @@ const JSONExportModal = ({ | ||||
|         )} | ||||
|         {onExportToBackend && ( | ||||
|           <Card color="pink"> | ||||
|             <div className="Card-icon">{link}</div> | ||||
|             <div className="Card-icon">{LinkIcon}</div> | ||||
|             <h2>{t("exportDialog.link_title")}</h2> | ||||
|             <div className="Card-details">{t("exportDialog.link_details")}</div> | ||||
|             <ToolButton | ||||
| @@ -109,16 +110,13 @@ export const JSONExportDialog = ({ | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <ToolButton | ||||
|       <MenuItem | ||||
|         icon={ExportIcon} | ||||
|         label={t("buttons.export")} | ||||
|         onClick={() => { | ||||
|           setModalIsShown(true); | ||||
|         }} | ||||
|         data-testid="json-export-button" | ||||
|         icon={exportFile} | ||||
|         type="button" | ||||
|         aria-label={t("buttons.export")} | ||||
|         showAriaLabel={useDevice().isMobile} | ||||
|         title={t("buttons.export")} | ||||
|         dataTestId="json-export-button" | ||||
|       /> | ||||
|       {modalIsShown && ( | ||||
|         <Dialog onCloseRequest={handleClose} title={t("buttons.export")}> | ||||
|   | ||||
| @@ -16,8 +16,10 @@ | ||||
|     height: 100%; | ||||
|     pointer-events: none; | ||||
|     z-index: var(--zIndex-layerUI); | ||||
|  | ||||
|     &__top-right { | ||||
|       display: flex; | ||||
|       gap: 0.75rem; | ||||
|     } | ||||
|  | ||||
|     &__footer { | ||||
| @@ -48,13 +50,6 @@ | ||||
|         transform: translate(-999px, 0); | ||||
|       } | ||||
|  | ||||
|       :root[dir="ltr"] &.layer-ui__wrapper__footer-left--transition-left { | ||||
|         transform: translate(-76px, 0); | ||||
|       } | ||||
|       :root[dir="rtl"] &.layer-ui__wrapper__footer-left--transition-left { | ||||
|         transform: translate(76px, 0); | ||||
|       } | ||||
|  | ||||
|       &.layer-ui__wrapper__footer-left--transition-bottom { | ||||
|         transform: translate(0, 92px); | ||||
|       } | ||||
| @@ -97,14 +92,9 @@ | ||||
|       pointer-events: all; | ||||
|     } | ||||
|  | ||||
|     .layer-ui__wrapper__footer-left { | ||||
|       margin-bottom: 0.2em; | ||||
|     } | ||||
|  | ||||
|     .layer-ui__wrapper__footer-right { | ||||
|       margin-top: auto; | ||||
|       margin-bottom: auto; | ||||
|       margin-inline-end: 1em; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -11,7 +11,6 @@ import { ExportType } from "../scene/types"; | ||||
| import { AppProps, AppState, ExcalidrawProps, BinaryFiles } from "../types"; | ||||
| import { muteFSAbortError } from "../utils"; | ||||
| import { SelectedShapeActions, ShapesSwitcher } from "./Actions"; | ||||
| import { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; | ||||
| import CollabButton from "./CollabButton"; | ||||
| import { ErrorDialog } from "./ErrorDialog"; | ||||
| import { ExportCB, ImageExportDialog } from "./ImageExportDialog"; | ||||
| @@ -36,13 +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"; | ||||
| 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 { 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; | ||||
| @@ -68,6 +80,7 @@ interface LayerUIProps { | ||||
|   library: Library; | ||||
|   id: string; | ||||
|   onImageAction: (data: { insertOnCanvasDirectly: boolean }) => void; | ||||
|   renderWelcomeScreen: boolean; | ||||
| } | ||||
| const LayerUI = ({ | ||||
|   actionManager, | ||||
| @@ -92,6 +105,7 @@ const LayerUI = ({ | ||||
|   library, | ||||
|   id, | ||||
|   onImageAction, | ||||
|   renderWelcomeScreen, | ||||
| }: LayerUIProps) => { | ||||
|   const device = useDevice(); | ||||
|  | ||||
| @@ -151,6 +165,7 @@ const LayerUI = ({ | ||||
|       <ImageExportDialog | ||||
|         elements={elements} | ||||
|         appState={appState} | ||||
|         setAppState={setAppState} | ||||
|         files={files} | ||||
|         actionManager={actionManager} | ||||
|         onExportToPng={createExporter("png")} | ||||
| @@ -160,71 +175,107 @@ const LayerUI = ({ | ||||
|     ); | ||||
|   }; | ||||
|  | ||||
|   const Separator = () => { | ||||
|     return <div style={{ width: ".625em" }} />; | ||||
|   }; | ||||
|  | ||||
|   const renderViewModeCanvasActions = () => { | ||||
|     return ( | ||||
|       <Section | ||||
|         heading="canvasActions" | ||||
|         className={clsx("zen-mode-transition", { | ||||
|           "transition-left": appState.zenModeEnabled, | ||||
|         })} | ||||
|       > | ||||
|         {/* the zIndex ensures this menu has higher stacking order, | ||||
|          see https://github.com/excalidraw/excalidraw/pull/1445 */} | ||||
|         <Island padding={2} style={{ zIndex: 1 }}> | ||||
|           <Stack.Col gap={4}> | ||||
|             <Stack.Row gap={1} justifyContent="space-between"> | ||||
|               {renderJSONExportDialog()} | ||||
|               {renderImageExportDialog()} | ||||
|             </Stack.Row> | ||||
|           </Stack.Col> | ||||
|         </Island> | ||||
|       </Section> | ||||
|     ); | ||||
|   }; | ||||
|   const [isMenuOpen, setIsMenuOpen] = useAtom(isMenuOpenAtom); | ||||
|   const menuRef = useOutsideClickHook(() => setIsMenuOpen(false)); | ||||
|  | ||||
|   const renderCanvasActions = () => ( | ||||
|     <Section | ||||
|       heading="canvasActions" | ||||
|       className={clsx("zen-mode-transition", { | ||||
|         "transition-left": appState.zenModeEnabled, | ||||
|       })} | ||||
|     > | ||||
|       {/* the zIndex ensures this menu has higher stacking order, | ||||
|     <div style={{ position: "relative" }}> | ||||
|       <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 padding={2} style={{ zIndex: 1 }}> | ||||
|         <Stack.Col gap={4}> | ||||
|           <Stack.Row gap={1} justifyContent="space-between"> | ||||
|             {actionManager.renderAction("clearCanvas")} | ||||
|             <Separator /> | ||||
|             {actionManager.renderAction("loadScene")} | ||||
|             {renderJSONExportDialog()} | ||||
|             {renderImageExportDialog()} | ||||
|             <Separator /> | ||||
|             {onCollabButtonClick && ( | ||||
|               <CollabButton | ||||
|                 isCollaborating={isCollaborating} | ||||
|                 collaboratorCount={appState.collaborators.size} | ||||
|                 onClick={onCollabButtonClick} | ||||
|               /> | ||||
|             )} | ||||
|           </Stack.Row> | ||||
|           <BackgroundPickerAndDarkModeToggle actionManager={actionManager} /> | ||||
|           {appState.fileHandle && ( | ||||
|             <>{actionManager.renderAction("saveToActiveFile")}</> | ||||
|           )} | ||||
|         </Stack.Col> | ||||
|       </Island> | ||||
|     </Section> | ||||
|             <Island | ||||
|               className="menu-container" | ||||
|               padding={2} | ||||
|               style={{ zIndex: 1 }} | ||||
|             > | ||||
|               {!appState.viewModeEnabled && | ||||
|                 actionManager.renderAction("loadScene")} | ||||
|               {/* // TODO barnabasmolnar/editor-redesign  */} | ||||
|               {/* is this fine here? */} | ||||
|               {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")} | ||||
|               <Separator /> | ||||
|               <MenuLinks /> | ||||
|               <Separator /> | ||||
|               <div | ||||
|                 style={{ | ||||
|                   display: "flex", | ||||
|                   flexDirection: "column", | ||||
|                   rowGap: ".5rem", | ||||
|                 }} | ||||
|               > | ||||
|                 <div>{actionManager.renderAction("toggleTheme")}</div> | ||||
|                 <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> | ||||
|   ); | ||||
|  | ||||
|   const renderSelectedShapeActions = () => ( | ||||
|     <Section | ||||
|       heading="selectedShapeActions" | ||||
|       className={clsx("zen-mode-transition", { | ||||
|       className={clsx("selected-shape-actions zen-mode-transition", { | ||||
|         "transition-left": appState.zenModeEnabled, | ||||
|       })} | ||||
|     > | ||||
| @@ -232,10 +283,9 @@ const LayerUI = ({ | ||||
|         className={CLASSES.SHAPE_ACTIONS_MENU} | ||||
|         padding={2} | ||||
|         style={{ | ||||
|           // we want to make sure this doesn't overflow so subtracting 200 | ||||
|           // which is approximately height of zoom footer and top left menu items with some buffer | ||||
|           // if active file name is displayed, subtracting 248 to account for its height | ||||
|           maxHeight: `${appState.height - (appState.fileHandle ? 248 : 200)}px`, | ||||
|           // we want to make sure this doesn't overflow so subtracting the | ||||
|           // approximate height of hamburgerMenu + footer | ||||
|           maxHeight: `${appState.height - 166}px`, | ||||
|         }} | ||||
|       > | ||||
|         <SelectedShapeActions | ||||
| @@ -255,74 +305,89 @@ const LayerUI = ({ | ||||
|  | ||||
|     return ( | ||||
|       <FixedSideContainer side="top"> | ||||
|         {renderWelcomeScreen && !appState.isLoading && ( | ||||
|           <WelcomeScreen appState={appState} actionManager={actionManager} /> | ||||
|         )} | ||||
|         <div className="App-menu App-menu_top"> | ||||
|           <Stack.Col | ||||
|             gap={4} | ||||
|             className={clsx({ | ||||
|             gap={6} | ||||
|             className={clsx("App-menu_top__left", { | ||||
|               "disable-pointerEvents": appState.zenModeEnabled, | ||||
|             })} | ||||
|           > | ||||
|             {appState.viewModeEnabled | ||||
|               ? renderViewModeCanvasActions() | ||||
|               : renderCanvasActions()} | ||||
|             {renderCanvasActions()} | ||||
|             {shouldRenderSelectedShapeActions && renderSelectedShapeActions()} | ||||
|           </Stack.Col> | ||||
|           {!appState.viewModeEnabled && ( | ||||
|             <Section heading="shapes"> | ||||
|             <Section heading="shapes" className="shapes-section"> | ||||
|               {(heading: React.ReactNode) => ( | ||||
|                 <Stack.Col gap={4} align="start"> | ||||
|                   <Stack.Row | ||||
|                     gap={1} | ||||
|                     className={clsx("App-toolbar-container", { | ||||
|                       "zen-mode": appState.zenModeEnabled, | ||||
|                     })} | ||||
|                 <div style={{ position: "relative" }}> | ||||
|                   <WelcomeScreenDecor | ||||
|                     shouldRender={renderWelcomeScreen && !appState.isLoading} | ||||
|                   > | ||||
|                     <PenModeButton | ||||
|                       zenModeEnabled={appState.zenModeEnabled} | ||||
|                       checked={appState.penMode} | ||||
|                       onChange={onPenModeToggle} | ||||
|                       title={t("toolBar.penMode")} | ||||
|                       penDetected={appState.penDetected} | ||||
|                     /> | ||||
|                     <LockButton | ||||
|                       zenModeEnabled={appState.zenModeEnabled} | ||||
|                       checked={appState.activeTool.locked} | ||||
|                       onChange={() => onLockToggle()} | ||||
|                       title={t("toolBar.lock")} | ||||
|                     /> | ||||
|                     <Island | ||||
|                       padding={1} | ||||
|                       className={clsx("App-toolbar", { | ||||
|                     <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} | ||||
|                       className={clsx("App-toolbar-container", { | ||||
|                         "zen-mode": appState.zenModeEnabled, | ||||
|                       })} | ||||
|                     > | ||||
|                       <HintViewer | ||||
|                         appState={appState} | ||||
|                         elements={elements} | ||||
|                         isMobile={device.isMobile} | ||||
|                         device={device} | ||||
|                       /> | ||||
|                       {heading} | ||||
|                       <Stack.Row gap={1}> | ||||
|                         <ShapesSwitcher | ||||
|                       <Island | ||||
|                         padding={1} | ||||
|                         className={clsx("App-toolbar", { | ||||
|                           "zen-mode": appState.zenModeEnabled, | ||||
|                         })} | ||||
|                       > | ||||
|                         <HintViewer | ||||
|                           appState={appState} | ||||
|                           canvas={canvas} | ||||
|                           activeTool={appState.activeTool} | ||||
|                           setAppState={setAppState} | ||||
|                           onImageAction={({ pointerType }) => { | ||||
|                             onImageAction({ | ||||
|                               insertOnCanvasDirectly: pointerType !== "mouse", | ||||
|                             }); | ||||
|                           }} | ||||
|                           elements={elements} | ||||
|                           isMobile={device.isMobile} | ||||
|                           device={device} | ||||
|                         /> | ||||
|                       </Stack.Row> | ||||
|                     </Island> | ||||
|                     <LibraryButton | ||||
|                       appState={appState} | ||||
|                       setAppState={setAppState} | ||||
|                     /> | ||||
|                   </Stack.Row> | ||||
|                 </Stack.Col> | ||||
|                         {heading} | ||||
|                         <Stack.Row gap={1}> | ||||
|                           <PenModeButton | ||||
|                             zenModeEnabled={appState.zenModeEnabled} | ||||
|                             checked={appState.penMode} | ||||
|                             onChange={onPenModeToggle} | ||||
|                             title={t("toolBar.penMode")} | ||||
|                             penDetected={appState.penDetected} | ||||
|                           /> | ||||
|                           <LockButton | ||||
|                             zenModeEnabled={appState.zenModeEnabled} | ||||
|                             checked={appState.activeTool.locked} | ||||
|                             onChange={() => onLockToggle()} | ||||
|                             title={t("toolBar.lock")} | ||||
|                           /> | ||||
|                           <div className="App-toolbar__divider"></div> | ||||
|  | ||||
|                           <ShapesSwitcher | ||||
|                             appState={appState} | ||||
|                             canvas={canvas} | ||||
|                             activeTool={appState.activeTool} | ||||
|                             setAppState={setAppState} | ||||
|                             onImageAction={({ pointerType }) => { | ||||
|                               onImageAction({ | ||||
|                                 insertOnCanvasDirectly: pointerType !== "mouse", | ||||
|                               }); | ||||
|                             }} | ||||
|                           /> | ||||
|                           {/* {actionManager.renderAction("eraser", { | ||||
|                           // size: "small", | ||||
|                         })} */} | ||||
|                         </Stack.Row> | ||||
|                       </Island> | ||||
|                     </Stack.Row> | ||||
|                   </Stack.Col> | ||||
|                 </div> | ||||
|               )} | ||||
|             </Section> | ||||
|           )} | ||||
| @@ -338,7 +403,18 @@ const LayerUI = ({ | ||||
|               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} /> | ||||
|             )} | ||||
|           </div> | ||||
|         </div> | ||||
|       </FixedSideContainer> | ||||
| @@ -371,13 +447,14 @@ const LayerUI = ({ | ||||
|           onClose={() => setAppState({ errorMessage: null })} | ||||
|         /> | ||||
|       )} | ||||
|       {appState.showHelpDialog && ( | ||||
|       {appState.openDialog === "help" && ( | ||||
|         <HelpDialog | ||||
|           onClose={() => { | ||||
|             setAppState({ showHelpDialog: false }); | ||||
|             setAppState({ openDialog: null }); | ||||
|           }} | ||||
|         /> | ||||
|       )} | ||||
|       {renderImageExportDialog()} | ||||
|       {appState.pasteDialog.shown && ( | ||||
|         <PasteChartDialog | ||||
|           setAppState={setAppState} | ||||
| @@ -392,6 +469,7 @@ const LayerUI = ({ | ||||
|       )} | ||||
|       {device.isMobile && ( | ||||
|         <MobileMenu | ||||
|           renderWelcomeScreen={renderWelcomeScreen} | ||||
|           appState={appState} | ||||
|           elements={elements} | ||||
|           actionManager={actionManager} | ||||
| @@ -433,6 +511,7 @@ const LayerUI = ({ | ||||
|           > | ||||
|             {renderFixedSideContainer()} | ||||
|             <Footer | ||||
|               renderWelcomeScreen={renderWelcomeScreen} | ||||
|               appState={appState} | ||||
|               actionManager={actionManager} | ||||
|               renderCustomFooter={renderCustomFooter} | ||||
|   | ||||
							
								
								
									
										32
									
								
								src/components/LibraryButton.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/components/LibraryButton.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .library-button { | ||||
|   @include outlineButtonStyles; | ||||
|  | ||||
|   background-color: var(--island-bg-color); | ||||
|  | ||||
|   width: auto; | ||||
|   height: var(--lg-button-size); | ||||
|  | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   gap: 0.5rem; | ||||
|  | ||||
|   line-height: 0; | ||||
|  | ||||
|   font-size: 0.75rem; | ||||
|   letter-spacing: 0.4px; | ||||
|  | ||||
|   svg { | ||||
|     width: var(--lg-icon-size); | ||||
|     height: var(--lg-icon-size); | ||||
|   } | ||||
|  | ||||
|   &__label { | ||||
|     display: none; | ||||
|  | ||||
|     @media screen and (min-width: 1024px) { | ||||
|       display: block; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -1,19 +1,11 @@ | ||||
| import React from "react"; | ||||
| import clsx from "clsx"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppState } from "../types"; | ||||
| import { capitalizeString } from "../utils"; | ||||
| import { trackEvent } from "../analytics"; | ||||
| import { useDevice } from "./App"; | ||||
|  | ||||
| const LIBRARY_ICON = ( | ||||
|   <svg viewBox="0 0 576 512"> | ||||
|     <path | ||||
|       fill="currentColor" | ||||
|       d="M542.22 32.05c-54.8 3.11-163.72 14.43-230.96 55.59-4.64 2.84-7.27 7.89-7.27 13.17v363.87c0 11.55 12.63 18.85 23.28 13.49 69.18-34.82 169.23-44.32 218.7-46.92 16.89-.89 30.02-14.43 30.02-30.66V62.75c.01-17.71-15.35-31.74-33.77-30.7zM264.73 87.64C197.5 46.48 88.58 35.17 33.78 32.05 15.36 31.01 0 45.04 0 62.75V400.6c0 16.24 13.13 29.78 30.02 30.66 49.49 2.6 149.59 12.11 218.77 46.95 10.62 5.35 23.21-1.94 23.21-13.46V100.63c0-5.29-2.62-10.14-7.27-12.99z" | ||||
|     ></path> | ||||
|   </svg> | ||||
| ); | ||||
| import "./LibraryButton.scss"; | ||||
| import { LibraryIcon } from "./icons"; | ||||
|  | ||||
| export const LibraryButton: React.FC<{ | ||||
|   appState: AppState; | ||||
| @@ -21,17 +13,16 @@ export const LibraryButton: React.FC<{ | ||||
|   isMobile?: boolean; | ||||
| }> = ({ appState, setAppState, isMobile }) => { | ||||
|   const device = useDevice(); | ||||
|   const showLabel = !isMobile; | ||||
|  | ||||
|   // TODO barnabasmolnar/redesign | ||||
|   // not great, toolbar jumps in a jarring manner | ||||
|   if (appState.isSidebarDocked && appState.openSidebar === "library") { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <label | ||||
|       className={clsx( | ||||
|         "ToolIcon ToolIcon_type_floating ToolIcon__library", | ||||
|         `ToolIcon_size_medium`, | ||||
|         { | ||||
|           "is-mobile": isMobile, | ||||
|         }, | ||||
|       )} | ||||
|       title={`${capitalizeString(t("toolBar.library"))} — 0`} | ||||
|     > | ||||
|     <label title={`${capitalizeString(t("toolBar.library"))}`}> | ||||
|       <input | ||||
|         className="ToolIcon_type_checkbox" | ||||
|         type="checkbox" | ||||
| @@ -55,7 +46,12 @@ export const LibraryButton: React.FC<{ | ||||
|         aria-label={capitalizeString(t("toolBar.library"))} | ||||
|         aria-keyshortcuts="0" | ||||
|       /> | ||||
|       <div className="ToolIcon__icon">{LIBRARY_ICON}</div> | ||||
|       <div className="library-button"> | ||||
|         <div>{LibraryIcon}</div> | ||||
|         {showLabel && ( | ||||
|           <div className="library-button__label">{t("toolBar.library")}</div> | ||||
|         )} | ||||
|       </div> | ||||
|     </label> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -35,103 +35,32 @@ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-actions { | ||||
|     width: 100%; | ||||
|   .library-actions-counter { | ||||
|     background-color: var(--color-primary); | ||||
|     color: var(--color-primary-light); | ||||
|     font-weight: bold; | ||||
|     display: flex; | ||||
|     margin-right: auto; | ||||
|     align-items: center; | ||||
|  | ||||
|     button .library-actions-counter { | ||||
|       position: absolute; | ||||
|       right: 2px; | ||||
|       bottom: 2px; | ||||
|       border-radius: 50%; | ||||
|       width: 1em; | ||||
|       height: 1em; | ||||
|       padding: 1px; | ||||
|       font-size: 0.7rem; | ||||
|       background: #fff; | ||||
|     } | ||||
|  | ||||
|     &--remove { | ||||
|       background-color: $oc-red-7; | ||||
|       &:hover { | ||||
|         background-color: $oc-red-8; | ||||
|       } | ||||
|       &:active { | ||||
|         background-color: $oc-red-9; | ||||
|       } | ||||
|       svg { | ||||
|         color: $oc-white; | ||||
|       } | ||||
|       .library-actions-counter { | ||||
|         color: $oc-red-7; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--export { | ||||
|       background-color: $oc-lime-5; | ||||
|  | ||||
|       &:hover { | ||||
|         background-color: $oc-lime-7; | ||||
|       } | ||||
|  | ||||
|       &:active { | ||||
|         background-color: $oc-lime-8; | ||||
|       } | ||||
|       svg { | ||||
|         color: $oc-white; | ||||
|       } | ||||
|       .library-actions-counter { | ||||
|         color: $oc-lime-5; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--publish { | ||||
|       background-color: $oc-cyan-6; | ||||
|       &:hover { | ||||
|         background-color: $oc-cyan-7; | ||||
|       } | ||||
|       &:active { | ||||
|         background-color: $oc-cyan-9; | ||||
|       } | ||||
|       svg { | ||||
|         color: $oc-white; | ||||
|       } | ||||
|       label { | ||||
|         margin-left: -0.2em; | ||||
|         margin-right: 1.1em; | ||||
|         color: $oc-white; | ||||
|         font-size: 0.86em; | ||||
|       } | ||||
|       .library-actions-counter { | ||||
|         color: $oc-cyan-6; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--load { | ||||
|       background-color: $oc-blue-6; | ||||
|       &:hover { | ||||
|         background-color: $oc-blue-7; | ||||
|       } | ||||
|       &:active { | ||||
|         background-color: $oc-blue-9; | ||||
|       } | ||||
|       svg { | ||||
|         color: $oc-white; | ||||
|       } | ||||
|     } | ||||
|     justify-content: center; | ||||
|     border-radius: 50%; | ||||
|     width: 1rem; | ||||
|     height: 1rem; | ||||
|     position: absolute; | ||||
|     bottom: -0.25rem; | ||||
|     right: -0.25rem; | ||||
|     font-size: 0.625rem; | ||||
|     pointer-events: none; | ||||
|   } | ||||
|  | ||||
|   .layer-ui__library-message { | ||||
|     padding: 2em 4em; | ||||
|     padding: 2rem; | ||||
|     min-width: 200px; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     align-items: center; | ||||
|     .Spinner { | ||||
|       margin-bottom: 1em; | ||||
|     } | ||||
|     flex-grow: 1; | ||||
|     justify-content: center; | ||||
|  | ||||
|     span { | ||||
|       font-size: 0.8em; | ||||
|     } | ||||
| @@ -159,11 +88,10 @@ | ||||
|   } | ||||
|  | ||||
|   .library-menu-browse-button { | ||||
|     width: 80%; | ||||
|     min-height: 22px; | ||||
|     margin: 0 auto; | ||||
|     margin-top: 1rem; | ||||
|     padding: 10px; | ||||
|     margin: 1rem auto; | ||||
|  | ||||
|     padding: 0.875rem 1rem; | ||||
|  | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
| @@ -176,6 +104,10 @@ | ||||
|     text-align: center; | ||||
|     white-space: nowrap; | ||||
|     text-decoration: none !important; | ||||
|  | ||||
|     font-weight: 600; | ||||
|     font-size: 0.75rem; | ||||
|  | ||||
|     &:hover { | ||||
|       background-color: var(--color-primary-darker); | ||||
|     } | ||||
| @@ -184,6 +116,12 @@ | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .library-menu-browse-button { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-menu-browse-button--mobile { | ||||
|     min-height: 22px; | ||||
|     margin-left: auto; | ||||
|   | ||||
| @@ -16,7 +16,7 @@ import { LibraryItems, LibraryItem, AppState, ExcalidrawProps } from "../types"; | ||||
|  | ||||
| import "./LibraryMenu.scss"; | ||||
| import LibraryMenuItems from "./LibraryMenuItems"; | ||||
| import { EVENT, VERSIONS } from "../constants"; | ||||
| import { EVENT } from "../constants"; | ||||
| import { KEYS } from "../keys"; | ||||
| import { trackEvent } from "../analytics"; | ||||
| import { useAtom } from "jotai"; | ||||
| @@ -31,6 +31,7 @@ import { Sidebar } from "./Sidebar/Sidebar"; | ||||
| import { getSelectedElements } from "../scene"; | ||||
| import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { LibraryMenuHeader } from "./LibraryMenuHeaderContent"; | ||||
| import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; | ||||
|  | ||||
| const useOnClickOutside = ( | ||||
|   ref: RefObject<HTMLElement>, | ||||
| @@ -94,9 +95,6 @@ export const LibraryMenuContent = ({ | ||||
|   selectedItems: LibraryItem["id"][]; | ||||
|   onSelectItems: (id: LibraryItem["id"][]) => void; | ||||
| }) => { | ||||
|   const referrer = | ||||
|     libraryReturnUrl || window.location.origin + window.location.pathname; | ||||
|  | ||||
|   const [libraryItemsData] = useAtom(libraryItemsAtom, jotaiScope); | ||||
|  | ||||
|   const addToLibrary = useCallback( | ||||
| @@ -131,13 +129,18 @@ export const LibraryMenuContent = ({ | ||||
|     return ( | ||||
|       <LibraryMenuWrapper> | ||||
|         <div className="layer-ui__library-message"> | ||||
|           <Spinner size="2em" /> | ||||
|           <span>{t("labels.libraryLoadingMessage")}</span> | ||||
|           <div> | ||||
|             <Spinner size="2em" /> | ||||
|             <span>{t("labels.libraryLoadingMessage")}</span> | ||||
|           </div> | ||||
|         </div> | ||||
|       </LibraryMenuWrapper> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   const showBtn = | ||||
|     libraryItemsData.libraryItems.length > 0 || pendingElements.length > 0; | ||||
|  | ||||
|   return ( | ||||
|     <LibraryMenuWrapper> | ||||
|       <LibraryMenuItems | ||||
| @@ -150,18 +153,17 @@ export const LibraryMenuContent = ({ | ||||
|         pendingElements={pendingElements} | ||||
|         selectedItems={selectedItems} | ||||
|         onSelectItems={onSelectItems} | ||||
|         id={id} | ||||
|         libraryReturnUrl={libraryReturnUrl} | ||||
|         theme={appState.theme} | ||||
|       /> | ||||
|       <a | ||||
|         className="library-menu-browse-button" | ||||
|         href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ | ||||
|           window.name || "_blank" | ||||
|         }&referrer=${referrer}&useHash=true&token=${id}&theme=${ | ||||
|           appState.theme | ||||
|         }&version=${VERSIONS.excalidrawLibrary}`} | ||||
|         target="_excalidraw_libraries" | ||||
|       > | ||||
|         {t("labels.libraries")} | ||||
|       </a> | ||||
|       {showBtn && ( | ||||
|         <LibraryMenuBrowseButton | ||||
|           id={id} | ||||
|           libraryReturnUrl={libraryReturnUrl} | ||||
|           theme={appState.theme} | ||||
|         /> | ||||
|       )} | ||||
|     </LibraryMenuWrapper> | ||||
|   ); | ||||
| }; | ||||
| @@ -265,6 +267,7 @@ export const LibraryMenu: React.FC<{ | ||||
|       // is colled correctly | ||||
|       key="library" | ||||
|       className="layer-ui__library-sidebar" | ||||
|       initialDockedState={appState.isSidebarDocked} | ||||
|       onDock={(docked) => { | ||||
|         trackEvent( | ||||
|           "library", | ||||
|   | ||||
							
								
								
									
										31
									
								
								src/components/LibraryMenuBrowseButton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/components/LibraryMenuBrowseButton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| import { VERSIONS } from "../constants"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppState, ExcalidrawProps } from "../types"; | ||||
|  | ||||
| const LibraryMenuBrowseButton = ({ | ||||
|   theme, | ||||
|   id, | ||||
|   libraryReturnUrl, | ||||
| }: { | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   theme: AppState["theme"]; | ||||
|   id: string; | ||||
| }) => { | ||||
|   const referrer = | ||||
|     libraryReturnUrl || window.location.origin + window.location.pathname; | ||||
|   return ( | ||||
|     <a | ||||
|       className="library-menu-browse-button" | ||||
|       href={`${process.env.REACT_APP_LIBRARY_URL}?target=${ | ||||
|         window.name || "_blank" | ||||
|       }&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${ | ||||
|         VERSIONS.excalidrawLibrary | ||||
|       }`} | ||||
|       target="_excalidraw_libraries" | ||||
|     > | ||||
|       {t("labels.libraries")} | ||||
|     </a> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default LibraryMenuBrowseButton; | ||||
| @@ -3,9 +3,14 @@ import { saveLibraryAsJSON } from "../data/json"; | ||||
| import Library, { libraryItemsAtom } from "../data/library"; | ||||
| import { t } from "../i18n"; | ||||
| import { AppState, LibraryItem, LibraryItems } from "../types"; | ||||
| import { exportToFileIcon, load, publishIcon, trash } from "./icons"; | ||||
| import { | ||||
|   DotsIcon, | ||||
|   ExportIcon, | ||||
|   LoadIcon, | ||||
|   publishIcon, | ||||
|   TrashIcon, | ||||
| } from "./icons"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| import { Tooltip } from "./Tooltip"; | ||||
| import { fileOpen } from "../data/filesystem"; | ||||
| import { muteFSAbortError } from "../utils"; | ||||
| import { useAtom } from "jotai"; | ||||
| @@ -13,6 +18,9 @@ import { jotaiScope } from "../jotai"; | ||||
| import ConfirmDialog from "./ConfirmDialog"; | ||||
| import PublishLibrary from "./PublishLibrary"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { useOutsideClickHook } from "../hooks/useOutsideClick"; | ||||
| import MenuItem from "./MenuItem"; | ||||
| import { isDropdownOpenAtom } from "./App"; | ||||
|  | ||||
| const getSelectedItems = ( | ||||
|   libraryItems: LibraryItems, | ||||
| @@ -165,93 +173,84 @@ export const LibraryMenuHeader: React.FC<{ | ||||
|       }); | ||||
|   }; | ||||
|  | ||||
|   const [isDropdownOpen, setIsDropdownOpen] = useAtom(isDropdownOpenAtom); | ||||
|   const dropdownRef = useOutsideClickHook(() => setIsDropdownOpen(false)); | ||||
|  | ||||
|   return ( | ||||
|     <div className="library-actions"> | ||||
|       {showRemoveLibAlert && renderRemoveLibAlert()} | ||||
|       {showPublishLibraryDialog && ( | ||||
|         <PublishLibrary | ||||
|           onClose={() => setShowPublishLibraryDialog(false)} | ||||
|           libraryItems={getSelectedItems( | ||||
|             libraryItemsData.libraryItems, | ||||
|             selectedItems, | ||||
|     <div style={{ position: "relative" }}> | ||||
|       <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> | ||||
|       )} | ||||
|  | ||||
|       {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)) | ||||
|           } | ||||
|         /> | ||||
|       )} | ||||
|       {publishLibSuccess && renderPublishSuccess()} | ||||
|       {!itemsSelected && ( | ||||
|         <ToolButton | ||||
|           key="import" | ||||
|           type="button" | ||||
|           title={t("buttons.load")} | ||||
|           aria-label={t("buttons.load")} | ||||
|           icon={load} | ||||
|           onClick={onLibraryImport} | ||||
|           className="library-actions--load" | ||||
|         /> | ||||
|       )} | ||||
|       {!!items.length && ( | ||||
|         <> | ||||
|           <ToolButton | ||||
|             key="export" | ||||
|             type="button" | ||||
|             title={t("buttons.export")} | ||||
|             aria-label={t("buttons.export")} | ||||
|             icon={exportToFileIcon} | ||||
|             onClick={onLibraryExport} | ||||
|             className="library-actions--export" | ||||
|           > | ||||
|             {selectedItems.length > 0 && ( | ||||
|               <span className="library-actions-counter"> | ||||
|                 {selectedItems.length} | ||||
|               </span> | ||||
|             )} | ||||
|           </ToolButton> | ||||
|           <ToolButton | ||||
|             key="reset" | ||||
|             type="button" | ||||
|             title={resetLabel} | ||||
|             aria-label={resetLabel} | ||||
|             icon={trash} | ||||
|             onClick={() => setShowRemoveLibAlert(true)} | ||||
|             className="library-actions--remove" | ||||
|           > | ||||
|             {selectedItems.length > 0 && ( | ||||
|               <span className="library-actions-counter"> | ||||
|                 {selectedItems.length} | ||||
|               </span> | ||||
|             )} | ||||
|           </ToolButton> | ||||
|         </> | ||||
|       )} | ||||
|       {itemsSelected && ( | ||||
|         <Tooltip label={t("hints.publishLibrary")}> | ||||
|           <ToolButton | ||||
|             type="button" | ||||
|             aria-label={t("buttons.publishLibrary")} | ||||
|             label={t("buttons.publishLibrary")} | ||||
|             icon={publishIcon} | ||||
|             className="library-actions--publish" | ||||
|             onClick={() => setShowPublishLibraryDialog(true)} | ||||
|           > | ||||
|             <label>{t("buttons.publishLibrary")}</label> | ||||
|             {selectedItems.length > 0 && ( | ||||
|               <span className="library-actions-counter"> | ||||
|                 {selectedItems.length} | ||||
|               </span> | ||||
|             )} | ||||
|           </ToolButton> | ||||
|         </Tooltip> | ||||
|           {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> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
|   | ||||
| @@ -1,18 +1,70 @@ | ||||
| @import "open-color/open-color"; | ||||
|  | ||||
| .excalidraw { | ||||
|   --container-padding-y: 1.5rem; | ||||
|   --container-padding-x: 0.75rem; | ||||
|  | ||||
|   .library-menu-items__no-items { | ||||
|     text-align: center; | ||||
|     color: var(--color-gray-70); | ||||
|     line-height: 1.5; | ||||
|     font-size: 0.875rem; | ||||
|     width: 100%; | ||||
|  | ||||
|     &__label { | ||||
|       color: var(--color-primary); | ||||
|       font-weight: bold; | ||||
|       font-size: 1.125rem; | ||||
|       margin-bottom: 0.75rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .library-menu-items__no-items { | ||||
|       color: var(--color-gray-40); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-menu-items-container { | ||||
|     display: flex; | ||||
|     flex-grow: 1; | ||||
|     flex-shrink: 1; | ||||
|     flex-basis: 0; | ||||
|     overflow-y: auto; | ||||
|     flex-direction: column; | ||||
|     height: 100%; | ||||
|     justify-content: center; | ||||
|     margin: 0; | ||||
|     border-bottom: 1px solid var(--sidebar-border-color); | ||||
|  | ||||
|     position: relative; | ||||
|  | ||||
|     &__row { | ||||
|       display: grid; | ||||
|       grid-template-columns: repeat(4, 1fr); | ||||
|       gap: 1rem; | ||||
|     } | ||||
|  | ||||
|     &__items { | ||||
|       row-gap: 0.5rem; | ||||
|       padding: var(--container-padding-y) var(--container-padding-x); | ||||
|       flex: 1; | ||||
|       overflow-y: auto; | ||||
|       overflow-x: hidden; | ||||
|       margin-bottom: 1rem; | ||||
|     } | ||||
|  | ||||
|     &__header { | ||||
|       color: var(--color-primary); | ||||
|       font-size: 1.125rem; | ||||
|       font-weight: bold; | ||||
|       margin-bottom: 0.75rem; | ||||
|  | ||||
|       &--excal { | ||||
|         margin-top: 2.5rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .separator { | ||||
|       width: 100%; | ||||
|       display: flex; | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import React, { useState } from "react"; | ||||
| import { serializeLibraryAsJSON } from "../data/json"; | ||||
| import { ExcalidrawElement, NonDeleted } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| import { LibraryItem, LibraryItems } from "../types"; | ||||
| import { AppState, ExcalidrawProps, LibraryItem, LibraryItems } from "../types"; | ||||
| import { arrayToMap, chunk } from "../utils"; | ||||
| import { LibraryUnit } from "./LibraryUnit"; | ||||
| import Stack from "./Stack"; | ||||
| @@ -10,6 +10,8 @@ import Stack from "./Stack"; | ||||
| import "./LibraryMenuItems.scss"; | ||||
| import { MIME_TYPES } from "../constants"; | ||||
| import Spinner from "./Spinner"; | ||||
| import LibraryMenuBrowseButton from "./LibraryMenuBrowseButton"; | ||||
| import clsx from "clsx"; | ||||
|  | ||||
| const CELLS_PER_ROW = 4; | ||||
|  | ||||
| @@ -21,6 +23,9 @@ const LibraryMenuItems = ({ | ||||
|   pendingElements, | ||||
|   selectedItems, | ||||
|   onSelectItems, | ||||
|   theme, | ||||
|   id, | ||||
|   libraryReturnUrl, | ||||
| }: { | ||||
|   isLoading: boolean; | ||||
|   libraryItems: LibraryItems; | ||||
| @@ -29,6 +34,9 @@ const LibraryMenuItems = ({ | ||||
|   onAddToLibrary: (elements: LibraryItem["elements"]) => void; | ||||
|   selectedItems: LibraryItem["id"][]; | ||||
|   onSelectItems: (id: LibraryItem["id"][]) => void; | ||||
|   libraryReturnUrl: ExcalidrawProps["libraryReturnUrl"]; | ||||
|   theme: AppState["theme"]; | ||||
|   id: string; | ||||
| }) => { | ||||
|   const [lastSelectedItem, setLastSelectedItem] = useState< | ||||
|     LibraryItem["id"] | null | ||||
| @@ -167,7 +175,11 @@ const LibraryMenuItems = ({ | ||||
|         ); | ||||
|       } | ||||
|       return ( | ||||
|         <Stack.Row align="center" gap={1} key={index}> | ||||
|         <Stack.Row | ||||
|           align="center" | ||||
|           key={index} | ||||
|           className="library-menu-items-container__row" | ||||
|         > | ||||
|           {rowItems} | ||||
|         </Stack.Row> | ||||
|       ); | ||||
| @@ -181,19 +193,21 @@ const LibraryMenuItems = ({ | ||||
|     (item) => item.status === "published", | ||||
|   ); | ||||
|  | ||||
|   const showBtn = | ||||
|     !libraryItems.length && | ||||
|     !unpublishedItems.length && | ||||
|     !publishedItems.length && | ||||
|     !pendingElements.length; | ||||
|  | ||||
|   return ( | ||||
|     <div | ||||
|       className="library-menu-items-container" | ||||
|       style={ | ||||
|         publishedItems.length || unpublishedItems.length | ||||
|           ? { | ||||
|               flex: "1 1 0", | ||||
|               overflowY: "auto", | ||||
|             } | ||||
|           : { | ||||
|               marginBottom: "2rem", | ||||
|               flex: 0, | ||||
|             } | ||||
|         pendingElements.length || | ||||
|         unpublishedItems.length || | ||||
|         publishedItems.length | ||||
|           ? { justifyContent: "flex-start" } | ||||
|           : {} | ||||
|       } | ||||
|     > | ||||
|       <Stack.Col | ||||
| @@ -206,49 +220,37 @@ const LibraryMenuItems = ({ | ||||
|         }} | ||||
|       > | ||||
|         <> | ||||
|           <div className="separator"> | ||||
|           <div> | ||||
|             {(pendingElements.length > 0 || | ||||
|               unpublishedItems.length > 0 || | ||||
|               publishedItems.length > 0) && ( | ||||
|               <div>{t("labels.personalLib")}</div> | ||||
|               <div className="library-menu-items-container__header"> | ||||
|                 {t("labels.personalLib")} | ||||
|               </div> | ||||
|             )} | ||||
|             {isLoading && ( | ||||
|               <div | ||||
|                 style={{ | ||||
|                   marginLeft: "auto", | ||||
|                   marginRight: "1rem", | ||||
|                   display: "flex", | ||||
|                   alignItems: "center", | ||||
|                   fontWeight: "normal", | ||||
|                   position: "absolute", | ||||
|                   top: "var(--container-padding-y)", | ||||
|                   right: "var(--container-padding-x)", | ||||
|                   transform: "translateY(50%)", | ||||
|                 }} | ||||
|               > | ||||
|                 <div style={{ transform: "translateY(2px)" }}> | ||||
|                   <Spinner /> | ||||
|                 </div> | ||||
|                 <Spinner /> | ||||
|               </div> | ||||
|             )} | ||||
|           </div> | ||||
|           {!pendingElements.length && !unpublishedItems.length ? ( | ||||
|             <div | ||||
|               style={{ | ||||
|                 height: 65, | ||||
|                 display: "flex", | ||||
|                 flexDirection: "column", | ||||
|                 alignItems: "center", | ||||
|                 justifyContent: "center", | ||||
|                 width: "100%", | ||||
|                 fontSize: ".9rem", | ||||
|               }} | ||||
|             > | ||||
|               {t("library.noItems")} | ||||
|             <div className="library-menu-items__no-items"> | ||||
|               <div | ||||
|                 style={{ | ||||
|                   margin: ".6rem 0", | ||||
|                   fontSize: ".8em", | ||||
|                   width: "70%", | ||||
|                   textAlign: "center", | ||||
|                 }} | ||||
|                 className={clsx({ | ||||
|                   "library-menu-items__no-items__label": showBtn, | ||||
|                 })} | ||||
|               > | ||||
|                 {t("library.noItems")} | ||||
|               </div> | ||||
|               <div className="library-menu-items__no-items__hint"> | ||||
|                 {publishedItems.length > 0 | ||||
|                   ? t("library.hint_emptyPrivateLibrary") | ||||
|                   : t("library.hint_emptyLibrary")} | ||||
| @@ -269,7 +271,9 @@ const LibraryMenuItems = ({ | ||||
|           {(publishedItems.length > 0 || | ||||
|             pendingElements.length > 0 || | ||||
|             unpublishedItems.length > 0) && ( | ||||
|             <div className="separator">{t("labels.excalidrawLib")}</div> | ||||
|             <div className="library-menu-items-container__header library-menu-items-container__header--excal"> | ||||
|               {t("labels.excalidrawLib")} | ||||
|             </div> | ||||
|           )} | ||||
|           {publishedItems.length > 0 ? ( | ||||
|             renderLibrarySection(publishedItems) | ||||
| @@ -289,6 +293,14 @@ const LibraryMenuItems = ({ | ||||
|             </div> | ||||
|           ) : null} | ||||
|         </> | ||||
|  | ||||
|         {showBtn && ( | ||||
|           <LibraryMenuBrowseButton | ||||
|             id={id} | ||||
|             libraryReturnUrl={libraryReturnUrl} | ||||
|             theme={theme} | ||||
|           /> | ||||
|         )} | ||||
|       </Stack.Col> | ||||
|     </div> | ||||
|   ); | ||||
|   | ||||
| @@ -7,17 +7,18 @@ | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     position: relative; | ||||
|     width: 63px; | ||||
|     height: 63px; // match width | ||||
|     width: 55px; | ||||
|     height: 55px; | ||||
|     box-sizing: border-box; | ||||
|     border-radius: var(--border-radius-lg); | ||||
|  | ||||
|     &--hover { | ||||
|       box-shadow: inset 0px 0px 0px 2px $oc-blue-5; | ||||
|       border-color: $oc-blue-5; | ||||
|       border-color: var(--color-primary); | ||||
|     } | ||||
|  | ||||
|     &--selected { | ||||
|       box-shadow: inset 0px 0px 0px 2px $oc-blue-8; | ||||
|       border-color: $oc-blue-8; | ||||
|       border-color: var(--color-primary); | ||||
|       border-width: 1px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -59,20 +60,34 @@ | ||||
|  | ||||
|   .library-unit__checkbox { | ||||
|     position: absolute; | ||||
|     left: 2.3rem; | ||||
|     bottom: 2.3rem; | ||||
|     top: 0.125rem; | ||||
|     right: 0.125rem; | ||||
|     margin: 0; | ||||
|  | ||||
|     .Checkbox-box { | ||||
|       width: 13px; | ||||
|       height: 13px; | ||||
|       border-radius: 2px; | ||||
|       margin: 0.5em 0.5em 0.2em 0.2em; | ||||
|       background-color: $oc-blue-1; | ||||
|       margin: 0; | ||||
|       width: 1rem; | ||||
|       height: 1rem; | ||||
|       border-radius: 4px; | ||||
|       background-color: var(--color-primary-light); | ||||
|       border: 1px solid var(--color-primary); | ||||
|       box-shadow: none !important; | ||||
|       padding: 2px; | ||||
|     } | ||||
|  | ||||
|     &.Checkbox:hover { | ||||
|       .Checkbox-box { | ||||
|         background-color: $oc-blue-2; | ||||
|         background-color: var(--color-primary-light); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &.is-checked { | ||||
|       .Checkbox-box { | ||||
|         background-color: var(--color-primary) !important; | ||||
|  | ||||
|         svg { | ||||
|           color: var(--color-primary-light); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -85,25 +100,29 @@ | ||||
|   .library-unit__adder { | ||||
|     transform: scale(1); | ||||
|     animation: library-unit__adder-animation 1s ease-in infinite; | ||||
|  | ||||
|     position: absolute; | ||||
|     width: 1.5rem; | ||||
|     height: 1.5rem; | ||||
|     background-color: var(--color-primary); | ||||
|     border-radius: var(--border-radius-md); | ||||
|  | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|  | ||||
|     pointer-events: none; | ||||
|  | ||||
|     svg { | ||||
|       color: var(--color-primary-light); | ||||
|       width: 1rem; | ||||
|       height: 1rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .library-unit__adder { | ||||
|     position: absolute; | ||||
|     left: 40%; | ||||
|     top: 40%; | ||||
|     width: 2rem; | ||||
|     height: 2rem; | ||||
|     margin-left: -10px; | ||||
|     margin-top: -10px; | ||||
|     pointer-events: none; | ||||
|   } | ||||
|   .library-unit:hover .library-unit__adder { | ||||
|     fill: $oc-blue-7; | ||||
|   } | ||||
|   .library-unit:active .library-unit__adder { | ||||
|     animation: none; | ||||
|     transform: scale(0.8); | ||||
|     fill: $oc-black; | ||||
|   } | ||||
|  | ||||
|   .library-unit__active { | ||||
|   | ||||
| @@ -6,19 +6,7 @@ import { exportToSvg } from "../scene/export"; | ||||
| import { LibraryItem } from "../types"; | ||||
| import "./LibraryUnit.scss"; | ||||
| import { CheckboxItem } from "./CheckboxItem"; | ||||
|  | ||||
| const PLUS_ICON = ( | ||||
|   <svg viewBox="0 0 1792 1792"> | ||||
|     <path | ||||
|       d="M1600 736v192c0 26.667-9.33 49.333-28 68-18.67 18.67-41.33 28-68 28h-416v416c0 26.67-9.33 49.33-28 68s-41.33 28-68 28H800c-26.667 0-49.333-9.33-68-28s-28-41.33-28-68v-416H288c-26.667 0-49.333-9.33-68-28-18.667-18.667-28-41.333-28-68V736c0-26.667 9.333-49.333 28-68s41.333-28 68-28h416V224c0-26.667 9.333-49.333 28-68s41.333-28 68-28h192c26.67 0 49.33 9.333 68 28s28 41.333 28 68v416h416c26.67 0 49.33 9.333 68 28s28 41.333 28 68Z" | ||||
|       style={{ | ||||
|         stroke: "#fff", | ||||
|         strokeWidth: 140, | ||||
|       }} | ||||
|       transform="translate(0 64)" | ||||
|     /> | ||||
|   </svg> | ||||
| ); | ||||
| import { PlusIcon } from "./icons"; | ||||
|  | ||||
| export const LibraryUnit = ({ | ||||
|   id, | ||||
| @@ -67,7 +55,7 @@ export const LibraryUnit = ({ | ||||
|   const [isHovered, setIsHovered] = useState(false); | ||||
|   const isMobile = useDevice().isMobile; | ||||
|   const adder = isPending && ( | ||||
|     <div className="library-unit__adder">{PLUS_ICON}</div> | ||||
|     <div className="library-unit__adder">{PlusIcon}</div> | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import "./ToolIcon.scss"; | ||||
|  | ||||
| import React from "react"; | ||||
| import clsx from "clsx"; | ||||
| import { ToolButtonSize } from "./ToolButton"; | ||||
| import { LockedIcon, UnlockedIcon } from "./icons"; | ||||
|  | ||||
| type LockIconProps = { | ||||
|   title?: string; | ||||
| @@ -16,34 +16,15 @@ type LockIconProps = { | ||||
| const DEFAULT_SIZE: ToolButtonSize = "medium"; | ||||
|  | ||||
| const ICONS = { | ||||
|   CHECKED: ( | ||||
|     <svg | ||||
|       width="1792" | ||||
|       height="1792" | ||||
|       viewBox="0 0 1792 1792" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|     > | ||||
|       <path d="M640 768h512v-192q0-106-75-181t-181-75-181 75-75 181v192zm832 96v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h32v-192q0-184 132-316t316-132 316 132 132 316v192h32q40 0 68 28t28 68z" /> | ||||
|     </svg> | ||||
|   ), | ||||
|   UNCHECKED: ( | ||||
|     <svg | ||||
|       width="1792" | ||||
|       height="1792" | ||||
|       viewBox="0 0 1792 1792" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       className="unlocked-icon rtl-mirror" | ||||
|     > | ||||
|       <path d="M1728 576v256q0 26-19 45t-45 19h-64q-26 0-45-19t-19-45v-256q0-106-75-181t-181-75-181 75-75 181v192h96q40 0 68 28t28 68v576q0 40-28 68t-68 28h-960q-40 0-68-28t-28-68v-576q0-40 28-68t68-28h672v-192q0-185 131.5-316.5t316.5-131.5 316.5 131.5 131.5 316.5z" /> | ||||
|     </svg> | ||||
|   ), | ||||
|   CHECKED: LockedIcon, | ||||
|   UNCHECKED: UnlockedIcon, | ||||
| }; | ||||
|  | ||||
| export const LockButton = (props: LockIconProps) => { | ||||
|   return ( | ||||
|     <label | ||||
|       className={clsx( | ||||
|         "ToolIcon ToolIcon__lock ToolIcon_type_floating", | ||||
|         "ToolIcon ToolIcon__lock", | ||||
|         `ToolIcon_size_${DEFAULT_SIZE}`, | ||||
|         { | ||||
|           "is-mobile": props.isMobile, | ||||
| @@ -58,6 +39,7 @@ export const LockButton = (props: LockIconProps) => { | ||||
|         onChange={props.onChange} | ||||
|         checked={props.checked} | ||||
|         aria-label={props.title} | ||||
|         data-testid="toolbar-lock" | ||||
|       /> | ||||
|       <div className="ToolIcon__icon"> | ||||
|         {props.checked ? ICONS.CHECKED : ICONS.UNCHECKED} | ||||
|   | ||||
							
								
								
									
										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", | ||||
|     }} | ||||
|   /> | ||||
| ); | ||||
| @@ -8,18 +8,21 @@ import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { FixedSideContainer } from "./FixedSideContainer"; | ||||
| import { Island } from "./Island"; | ||||
| import { HintViewer } from "./HintViewer"; | ||||
| import { calculateScrollCenter, getSelectedElements } from "../scene"; | ||||
| 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 { BackgroundPickerAndDarkModeToggle } from "./BackgroundPickerAndDarkModeToggle"; | ||||
| import { LibraryButton } from "./LibraryButton"; | ||||
| import { PenModeButton } from "./PenModeButton"; | ||||
| import { Stats } from "./Stats"; | ||||
| import { actionToggleStats } from "../actions"; | ||||
| import { MenuLinks, Separator } from "./MenuUtils"; | ||||
| import WelcomeScreen from "./WelcomeScreen"; | ||||
| import MenuItem from "./MenuItem"; | ||||
| import { ExportImageIcon } from "./icons"; | ||||
|  | ||||
| type MobileMenuProps = { | ||||
|   appState: AppState; | ||||
| @@ -45,6 +48,7 @@ type MobileMenuProps = { | ||||
|   renderCustomStats?: ExcalidrawProps["renderCustomStats"]; | ||||
|   renderSidebars: () => JSX.Element | null; | ||||
|   device: Device; | ||||
|   renderWelcomeScreen?: boolean; | ||||
| }; | ||||
|  | ||||
| export const MobileMenu = ({ | ||||
| @@ -65,17 +69,35 @@ export const MobileMenu = ({ | ||||
|   renderCustomStats, | ||||
|   renderSidebars, | ||||
|   device, | ||||
|   renderWelcomeScreen, | ||||
| }: MobileMenuProps) => { | ||||
|   const renderToolbar = () => { | ||||
|     return ( | ||||
|       <FixedSideContainer side="top" className="App-top-bar"> | ||||
|         {renderWelcomeScreen && !appState.isLoading && ( | ||||
|           <WelcomeScreen appState={appState} actionManager={actionManager} /> | ||||
|         )} | ||||
|         <Section heading="shapes"> | ||||
|           {(heading: React.ReactNode) => ( | ||||
|             <Stack.Col gap={4} align="center"> | ||||
|               <Stack.Row gap={1} className="App-toolbar-container"> | ||||
|                 <Island padding={1} className="App-toolbar"> | ||||
|                 <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} | ||||
| @@ -90,24 +112,29 @@ export const MobileMenu = ({ | ||||
|                   </Stack.Row> | ||||
|                 </Island> | ||||
|                 {renderTopRightUI && renderTopRightUI(true, appState)} | ||||
|                 <LockButton | ||||
|                   checked={appState.activeTool.locked} | ||||
|                   onChange={onLockToggle} | ||||
|                   title={t("toolBar.lock")} | ||||
|                   isMobile | ||||
|                 /> | ||||
|                 <LibraryButton | ||||
|                   appState={appState} | ||||
|                   setAppState={setAppState} | ||||
|                   isMobile | ||||
|                 /> | ||||
|                 <PenModeButton | ||||
|                   checked={appState.penMode} | ||||
|                   onChange={onPenModeToggle} | ||||
|                   title={t("toolBar.penMode")} | ||||
|                   isMobile | ||||
|                   penDetected={appState.penDetected} | ||||
|                 /> | ||||
|                 <div className="mobile-misc-tools-container"> | ||||
|                   <PenModeButton | ||||
|                     checked={appState.penMode} | ||||
|                     onChange={onPenModeToggle} | ||||
|                     title={t("toolBar.penMode")} | ||||
|                     isMobile | ||||
|                     penDetected={appState.penDetected} | ||||
|                     // penDetected={true} | ||||
|                   /> | ||||
|                   <LockButton | ||||
|                     checked={appState.activeTool.locked} | ||||
|                     onChange={onLockToggle} | ||||
|                     title={t("toolBar.lock")} | ||||
|                     isMobile | ||||
|                   /> | ||||
|                   {!appState.viewModeEnabled && ( | ||||
|                     <LibraryButton | ||||
|                       appState={appState} | ||||
|                       setAppState={setAppState} | ||||
|                       isMobile | ||||
|                     /> | ||||
|                   )} | ||||
|                 </div> | ||||
|               </Stack.Row> | ||||
|             </Stack.Col> | ||||
|           )} | ||||
| @@ -123,11 +150,6 @@ export const MobileMenu = ({ | ||||
|   }; | ||||
|  | ||||
|   const renderAppToolbar = () => { | ||||
|     // Render eraser conditionally in mobile | ||||
|     const showEraser = | ||||
|       !appState.editingElement && | ||||
|       getSelectedElements(elements, appState).length === 0; | ||||
|  | ||||
|     if (appState.viewModeEnabled) { | ||||
|       return ( | ||||
|         <div className="App-toolbar-content"> | ||||
| @@ -140,14 +162,11 @@ export const MobileMenu = ({ | ||||
|       <div className="App-toolbar-content"> | ||||
|         {actionManager.renderAction("toggleCanvasMenu")} | ||||
|         {actionManager.renderAction("toggleEditMenu")} | ||||
|  | ||||
|         {actionManager.renderAction("undo")} | ||||
|         {actionManager.renderAction("redo")} | ||||
|         {showEraser | ||||
|           ? actionManager.renderAction("eraser") | ||||
|           : actionManager.renderAction( | ||||
|               appState.multiElement ? "finalize" : "duplicateSelection", | ||||
|             )} | ||||
|         {actionManager.renderAction( | ||||
|           appState.multiElement ? "finalize" : "duplicateSelection", | ||||
|         )} | ||||
|         {actionManager.renderAction("deleteSelectedElements")} | ||||
|       </div> | ||||
|     ); | ||||
| @@ -158,16 +177,27 @@ export const MobileMenu = ({ | ||||
|       return ( | ||||
|         <> | ||||
|           {renderJSONExportDialog()} | ||||
|           <MenuItem | ||||
|             label={t("buttons.exportImage")} | ||||
|             icon={ExportImageIcon} | ||||
|             dataTestId="image-export-button" | ||||
|             onClick={() => setAppState({ openDialog: "imageExport" })} | ||||
|           /> | ||||
|           {renderImageExportDialog()} | ||||
|         </> | ||||
|       ); | ||||
|     } | ||||
|     return ( | ||||
|       <> | ||||
|         {actionManager.renderAction("clearCanvas")} | ||||
|         {actionManager.renderAction("loadScene")} | ||||
|         {!appState.viewModeEnabled && actionManager.renderAction("loadScene")} | ||||
|         {renderJSONExportDialog()} | ||||
|         {renderImageExportDialog()} | ||||
|         <MenuItem | ||||
|           label={t("buttons.exportImage")} | ||||
|           icon={ExportImageIcon} | ||||
|           dataTestId="image-export-button" | ||||
|           onClick={() => setAppState({ openDialog: "imageExport" })} | ||||
|         /> | ||||
|         {onCollabButtonClick && ( | ||||
|           <CollabButton | ||||
|             isCollaborating={isCollaborating} | ||||
| @@ -175,7 +205,22 @@ export const MobileMenu = ({ | ||||
|             onClick={onCollabButtonClick} | ||||
|           /> | ||||
|         )} | ||||
|         {<BackgroundPickerAndDarkModeToggle actionManager={actionManager} />} | ||||
|         {actionManager.renderAction("toggleShortcuts", undefined, true)} | ||||
|         {!appState.viewModeEnabled && actionManager.renderAction("clearCanvas")} | ||||
|         <Separator /> | ||||
|         <MenuLinks /> | ||||
|         <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")} | ||||
|       </> | ||||
|     ); | ||||
|   }; | ||||
| @@ -206,7 +251,7 @@ export const MobileMenu = ({ | ||||
|           {appState.openMenu === "canvas" ? ( | ||||
|             <Section className="App-mobile-menu" heading="canvasActions"> | ||||
|               <div className="panelColumn"> | ||||
|                 <Stack.Col gap={4}> | ||||
|                 <Stack.Col gap={2}> | ||||
|                   {renderCanvasActions()} | ||||
|                   {renderCustomFooter?.(true, appState)} | ||||
|                   {appState.collaborators.size > 0 && ( | ||||
|   | ||||
| @@ -17,6 +17,10 @@ | ||||
|     justify-content: center; | ||||
|     overflow: auto; | ||||
|     padding: calc(var(--space-factor) * 10); | ||||
|  | ||||
|     .Island { | ||||
|       padding: 2.5rem !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .Modal__background { | ||||
| @@ -26,7 +30,7 @@ | ||||
|     right: 0; | ||||
|     bottom: 0; | ||||
|     z-index: 1; | ||||
|     background-color: transparentize($oc-black, 0.3); | ||||
|     background-color: rgba(#121212, 0.2); | ||||
|   } | ||||
|  | ||||
|   .Modal__content { | ||||
| @@ -46,7 +50,7 @@ | ||||
|     background: var(--island-bg-color); | ||||
|  | ||||
|     border: 1px solid var(--dialog-border-color); | ||||
|     box-shadow: 0 2px 10px transparentize($oc-black, 0.75); | ||||
|     box-shadow: var(--modal-shadow); | ||||
|     border-radius: 6px; | ||||
|     box-sizing: border-box; | ||||
|  | ||||
| @@ -73,14 +77,20 @@ | ||||
|   } | ||||
|  | ||||
|   .Modal__close { | ||||
|     width: calc(var(--space-factor) * 7); | ||||
|     height: calc(var(--space-factor) * 7); | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|     color: var(--icon-fill-color); | ||||
|     margin: 0; | ||||
|     padding: 0.375rem; | ||||
|     position: absolute; | ||||
|     top: 1rem; | ||||
|     right: 1rem; | ||||
|     border: 0; | ||||
|     background-color: transparent; | ||||
|     line-height: 0; | ||||
|     cursor: pointer; | ||||
|  | ||||
|     svg { | ||||
|       height: calc(var(--space-factor) * 5); | ||||
|       width: 1.5rem; | ||||
|       height: 1.5rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -39,6 +39,7 @@ export const Modal: React.FC<{ | ||||
|       aria-modal="true" | ||||
|       onKeyDown={handleKeydown} | ||||
|       aria-labelledby={props.labelledBy} | ||||
|       data-prevent-outside-click | ||||
|     > | ||||
|       <div | ||||
|         className="Modal__background" | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import "./ToolIcon.scss"; | ||||
|  | ||||
| import clsx from "clsx"; | ||||
| import { ToolButtonSize } from "./ToolButton"; | ||||
| import { PenModeIcon } from "./icons"; | ||||
|  | ||||
| type PenModeIconProps = { | ||||
|   title?: string; | ||||
| @@ -15,59 +16,15 @@ type PenModeIconProps = { | ||||
|  | ||||
| const DEFAULT_SIZE: ToolButtonSize = "medium"; | ||||
|  | ||||
| const ICONS = { | ||||
|   CHECKED: ( | ||||
|     <svg | ||||
|       width="205" | ||||
|       height="205" | ||||
|       viewBox="0 0 205 205" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|     > | ||||
|       <path d="m35 195-25-29.17V50h50v115l-25 30" /> | ||||
|       <path d="M10 40V10h50v30H10" /> | ||||
|       <path d="M125 145h70v50h-70" /> | ||||
|       <path d="M190 145v-30l-10-20h-40l-10 20v30h15v-30l5-5h20l5 5v30h15" /> | ||||
|     </svg> | ||||
|   ), | ||||
|   UNCHECKED: ( | ||||
|     <svg | ||||
|       width="205" | ||||
|       height="205" | ||||
|       viewBox="0 0 205 205" | ||||
|       xmlns="http://www.w3.org/2000/svg" | ||||
|       className="unlocked-icon rtl-mirror" | ||||
|     > | ||||
|       <path d="m35 195-25-29.17V50h50v115l-25 30" /> | ||||
|       <path d="M10 40V10h50v30H10" /> | ||||
|       <path d="M125 145h70v50h-70" /> | ||||
|       <path d="M145 145v-30l-10-20H95l-10 20v30h15v-30l5-5h20l5 5v30h15" /> | ||||
|     </svg> | ||||
|   ), | ||||
| }; | ||||
|  | ||||
| export const PenModeButton = (props: PenModeIconProps) => { | ||||
|   if (!props.penDetected) { | ||||
|     if (props.isMobile) { | ||||
|       return null; | ||||
|     } | ||||
|     return ( | ||||
|       <label | ||||
|         className={clsx( | ||||
|           "ToolIcon ToolIcon__penMode ToolIcon_type_floating", | ||||
|           `ToolIcon_size_${DEFAULT_SIZE}`, | ||||
|           { | ||||
|             "is-mobile": props.isMobile, | ||||
|           }, | ||||
|         )} | ||||
|       > | ||||
|         <div className="ToolIcon__icon ToolIcon__hidden" /> | ||||
|       </label> | ||||
|     ); | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <label | ||||
|       className={clsx( | ||||
|         "ToolIcon ToolIcon__penMode ToolIcon_type_floating", | ||||
|         "ToolIcon ToolIcon__penMode", | ||||
|         `ToolIcon_size_${DEFAULT_SIZE}`, | ||||
|         { | ||||
|           "is-mobile": props.isMobile, | ||||
| @@ -83,9 +40,7 @@ export const PenModeButton = (props: PenModeIconProps) => { | ||||
|         checked={props.checked} | ||||
|         aria-label={props.title} | ||||
|       /> | ||||
|       <div className="ToolIcon__icon"> | ||||
|         {props.checked ? ICONS.CHECKED : ICONS.UNCHECKED} | ||||
|       </div> | ||||
|       <div className="ToolIcon__icon">{PenModeIcon}</div> | ||||
|     </label> | ||||
|   ); | ||||
| }; | ||||
|   | ||||
| @@ -7,7 +7,7 @@ | ||||
|       flex-direction: column; | ||||
|  | ||||
|       label { | ||||
|         padding: 1em; | ||||
|         padding: 1em 0; | ||||
|         display: flex; | ||||
|         justify-content: space-between; | ||||
|         align-items: center; | ||||
| @@ -34,6 +34,7 @@ | ||||
|       display: flex; | ||||
|       padding: 0.2rem 0; | ||||
|       justify-content: flex-end; | ||||
|       gap: 0.5rem; | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         min-width: 2.5rem; | ||||
| @@ -74,7 +75,6 @@ | ||||
|  | ||||
|     .selected-library-items { | ||||
|       display: flex; | ||||
|       padding: 0 0.8rem; | ||||
|       flex-wrap: wrap; | ||||
|  | ||||
|       .single-library-item-wrapper { | ||||
| @@ -87,7 +87,7 @@ | ||||
|     } | ||||
|  | ||||
|     &-note { | ||||
|       padding: 1em; | ||||
|       padding: 1em 0; | ||||
|       font-style: italic; | ||||
|       font-size: 14px; | ||||
|       display: block; | ||||
|   | ||||
| @@ -4,8 +4,6 @@ import OpenColor from "open-color"; | ||||
| import { Dialog } from "./Dialog"; | ||||
| import { t } from "../i18n"; | ||||
|  | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| import { AppState, LibraryItems, LibraryItem } from "../types"; | ||||
| import { exportToCanvas } from "../packages/utils"; | ||||
| import { | ||||
| @@ -20,6 +18,7 @@ import "./PublishLibrary.scss"; | ||||
| import SingleLibraryItem from "./SingleLibraryItem"; | ||||
| import { canvasToBlob, resizeImageFile } from "../data/blob"; | ||||
| import { chunk } from "../utils"; | ||||
| import DialogActionButton from "./DialogActionButton"; | ||||
|  | ||||
| interface PublishLibraryDataParams { | ||||
|   authorName: string; | ||||
| @@ -434,21 +433,15 @@ const PublishLibrary = ({ | ||||
|             </span> | ||||
|           </div> | ||||
|           <div className="publish-library__buttons"> | ||||
|             <ToolButton | ||||
|               type="button" | ||||
|               title={t("buttons.cancel")} | ||||
|               aria-label={t("buttons.cancel")} | ||||
|             <DialogActionButton | ||||
|               label={t("buttons.cancel")} | ||||
|               onClick={onDialogClose} | ||||
|               data-testid="cancel-clear-canvas-button" | ||||
|               className="publish-library__buttons--cancel" | ||||
|             /> | ||||
|             <ToolButton | ||||
|             <DialogActionButton | ||||
|               type="submit" | ||||
|               title={t("buttons.submit")} | ||||
|               aria-label={t("buttons.submit")} | ||||
|               label={t("buttons.submit")} | ||||
|               className="publish-library__buttons--confirm" | ||||
|               actionType="primary" | ||||
|               isLoading={isSubmitting} | ||||
|             /> | ||||
|           </div> | ||||
|   | ||||
| @@ -2,20 +2,101 @@ | ||||
| @import "../../css/variables.module"; | ||||
|  | ||||
| .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 { | ||||
|       @include outlineButtonStyles; | ||||
|       width: var(--lg-button-size); | ||||
|       height: var(--lg-button-size); | ||||
|       padding: 0; | ||||
|  | ||||
|       svg { | ||||
|         width: var(--lg-icon-size); | ||||
|         height: var(--lg-icon-size); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__pin-btn { | ||||
|       &--pinned { | ||||
|         background-color: var(--color-primary); | ||||
|         border-color: var(--color-primary); | ||||
|  | ||||
|         svg { | ||||
|           color: #fff; | ||||
|         } | ||||
|  | ||||
|         &:hover, | ||||
|         &:active { | ||||
|           background-color: var(--color-primary-darker); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .Sidebar { | ||||
|       &__pin-btn { | ||||
|         &--pinned { | ||||
|           svg { | ||||
|             color: var(--color-gray-90); | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .layer-ui__sidebar { | ||||
|     position: absolute; | ||||
|     top: var(--sat); | ||||
|     bottom: var(--sab); | ||||
|     right: var(--sar); | ||||
|     top: 0; | ||||
|     bottom: 0; | ||||
|     right: 0; | ||||
|     z-index: 5; | ||||
|     margin: 0; | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       left: 0; | ||||
|       right: auto; | ||||
|     } | ||||
|  | ||||
|     background-color: var(--sidebar-bg-color); | ||||
|  | ||||
|     box-shadow: var(--sidebar-shadow); | ||||
|  | ||||
|     &--docked { | ||||
|       box-shadow: none; | ||||
|     } | ||||
|  | ||||
|     box-shadow: var(--shadow-island); | ||||
|     overflow: hidden; | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     margin: var(--space-factor); | ||||
|     border-radius: 0; | ||||
|     width: calc(#{$right-sidebar-width} - var(--space-factor) * 2); | ||||
|  | ||||
|     padding: 0.5rem; | ||||
|     border-left: 1px solid var(--sidebar-border-color); | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       border-right: 1px solid var(--sidebar-border-color); | ||||
|       border-left: 0; | ||||
|     } | ||||
|  | ||||
|     padding: 0; | ||||
|     box-sizing: border-box; | ||||
|  | ||||
|     .Island { | ||||
| @@ -48,42 +129,18 @@ | ||||
|   } | ||||
|  | ||||
|   .layer-ui__sidebar__header { | ||||
|     box-sizing: border-box; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: center; | ||||
|     width: 100%; | ||||
|     margin: 2px 0 15px 0; | ||||
|     &:empty { | ||||
|       margin: 0; | ||||
|     } | ||||
|     button { | ||||
|       // 2px from the left to account for focus border of left-most button | ||||
|       margin: 0 2px; | ||||
|     } | ||||
|     padding: 1rem; | ||||
|     border-bottom: 1px solid var(--sidebar-border-color); | ||||
|   } | ||||
|  | ||||
|   .layer-ui__sidebar__header__buttons { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     margin-left: auto; | ||||
|   } | ||||
|  | ||||
|   .layer-ui__sidebar-dock-button { | ||||
|     @include toolbarButtonColorStates; | ||||
|     margin-right: 0.2rem; | ||||
|  | ||||
|     .ToolIcon_type_floating .ToolIcon__icon { | ||||
|       width: calc(var(--space-factor) * 7); | ||||
|       height: calc(var(--space-factor) * 7); | ||||
|       svg { | ||||
|         // mirror | ||||
|         transform: scale(-1, 1); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .ToolIcon_type_checkbox { | ||||
|       &:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon { | ||||
|         background-color: var(--color-primary); | ||||
|       } | ||||
|     } | ||||
|     gap: 0.625rem; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -90,10 +90,10 @@ describe("Sidebar", () => { | ||||
|  | ||||
|     const sidebar = container.querySelector<HTMLElement>(".test-sidebar"); | ||||
|     expect(sidebar).not.toBe(null); | ||||
|     const closeButton = queryByTestId(sidebar!, "sidebar-close"); | ||||
|     const closeButton = queryByTestId(sidebar!, "sidebar-close")!; | ||||
|     expect(closeButton).not.toBe(null); | ||||
|  | ||||
|     fireEvent.click(closeButton!.querySelector("button")!); | ||||
|     fireEvent.click(closeButton); | ||||
|     await waitFor(() => { | ||||
|       expect(container.querySelector<HTMLElement>(".test-sidebar")).toBe(null); | ||||
|       expect(onClose).toHaveBeenCalled(); | ||||
|   | ||||
| @@ -33,6 +33,13 @@ export const Sidebar = Object.assign( | ||||
|         onClose, | ||||
|         onDock, | ||||
|         docked, | ||||
|         /** Undocumented, may be removed later. Generally should either be | ||||
|          * `props.docked` or `appState.isSidebarDocked`. Currently serves to | ||||
|          *  prevent unwanted animation of the shadow if initially docked. */ | ||||
|         // | ||||
|         // NOTE we'll want to remove this after we sort out how to subscribe to | ||||
|         // individual appState properties | ||||
|         initialDockedState = docked, | ||||
|         dockable = true, | ||||
|         className, | ||||
|         __isInternal, | ||||
| @@ -52,7 +59,9 @@ export const Sidebar = Object.assign( | ||||
|  | ||||
|       const setAppState = useExcalidrawSetAppState(); | ||||
|  | ||||
|       const [isDockedFallback, setIsDockedFallback] = useState(docked ?? false); | ||||
|       const [isDockedFallback, setIsDockedFallback] = useState( | ||||
|         docked ?? initialDockedState ?? false, | ||||
|       ); | ||||
|  | ||||
|       useLayoutEffect(() => { | ||||
|         if (docked === undefined) { | ||||
| @@ -119,8 +128,11 @@ export const Sidebar = Object.assign( | ||||
|  | ||||
|       return ( | ||||
|         <Island | ||||
|           padding={2} | ||||
|           className={clsx("layer-ui__sidebar", className)} | ||||
|           className={clsx( | ||||
|             "layer-ui__sidebar", | ||||
|             { "layer-ui__sidebar--docked": isDockedFallback }, | ||||
|             className, | ||||
|           )} | ||||
|           ref={ref} | ||||
|         > | ||||
|           <SidebarPropsContext.Provider value={headerPropsRef.current}> | ||||
|   | ||||
| @@ -3,16 +3,10 @@ import { useContext } from "react"; | ||||
| import { t } from "../../i18n"; | ||||
| import { useDevice } from "../App"; | ||||
| import { SidebarPropsContext } from "./common"; | ||||
| import { close } from "../icons"; | ||||
| import { CloseIcon, PinIcon } from "../icons"; | ||||
| import { withUpstreamOverride } from "../hoc/withUpstreamOverride"; | ||||
| import { Tooltip } from "../Tooltip"; | ||||
|  | ||||
| const SIDE_LIBRARY_TOGGLE_ICON = ( | ||||
|   <svg viewBox="0 0 24 24" fill="#ffffff"> | ||||
|     <path d="M19 22H5a3 3 0 01-3-3V5a3 3 0 013-3h14a3 3 0 013 3v14a3 3 0 01-3 3zm0-18h-9v16h9a1.01 1.01 0 001-1V5a1.01 1.01 0 00-1-1z"></path> | ||||
|   </svg> | ||||
| ); | ||||
|  | ||||
| export const SidebarDockButton = (props: { | ||||
|   checked: boolean; | ||||
|   onChange?(): void; | ||||
| @@ -33,8 +27,13 @@ export const SidebarDockButton = (props: { | ||||
|             checked={props.checked} | ||||
|             aria-label={t("labels.sidebarLock")} | ||||
|           />{" "} | ||||
|           <div className="ToolIcon__icon" tabIndex={0}> | ||||
|             {SIDE_LIBRARY_TOGGLE_ICON} | ||||
|           <div | ||||
|             className={clsx("Sidebar__pin-btn", { | ||||
|               "Sidebar__pin-btn--pinned": props.checked, | ||||
|             })} | ||||
|             tabIndex={0} | ||||
|           > | ||||
|             {PinIcon} | ||||
|           </div>{" "} | ||||
|         </label>{" "} | ||||
|       </Tooltip> | ||||
| @@ -64,24 +63,19 @@ const _SidebarHeader: React.FC<{ | ||||
|             <SidebarDockButton | ||||
|               checked={!!props.docked} | ||||
|               onChange={() => { | ||||
|                 document | ||||
|                   .querySelector(".layer-ui__wrapper") | ||||
|                   ?.classList.add("animate"); | ||||
|  | ||||
|                 props.onDock?.(!props.docked); | ||||
|               }} | ||||
|             /> | ||||
|           )} | ||||
|           {renderCloseButton && ( | ||||
|             <div className="ToolIcon__icon__close" data-testid="sidebar-close"> | ||||
|               <button | ||||
|                 className="Modal__close" | ||||
|                 onClick={props.onClose} | ||||
|                 aria-label={t("buttons.close")} | ||||
|               > | ||||
|                 {close} | ||||
|               </button> | ||||
|             </div> | ||||
|             <button | ||||
|               data-testid="sidebar-close" | ||||
|               className="Sidebar__close-btn" | ||||
|               onClick={props.onClose} | ||||
|               aria-label={t("buttons.close")} | ||||
|             > | ||||
|               {CloseIcon} | ||||
|             </button> | ||||
|           )} | ||||
|         </div> | ||||
|       )} | ||||
|   | ||||
| @@ -9,6 +9,7 @@ export type SidebarProps<P = {}> = { | ||||
|   /** if not supplied, sidebar won't be dockable */ | ||||
|   onDock?: (docked: boolean) => void; | ||||
|   docked?: boolean; | ||||
|   initialDockedState?: boolean; | ||||
|   dockable?: boolean; | ||||
|   className?: string; | ||||
| } & P; | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { useEffect, useRef } from "react"; | ||||
| import { t } from "../i18n"; | ||||
| import { exportToSvg } from "../packages/utils"; | ||||
| import { AppState, LibraryItem } from "../types"; | ||||
| import { close } from "./icons"; | ||||
| import { CloseIcon } from "./icons"; | ||||
|  | ||||
| import "./SingleLibraryItem.scss"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
| @@ -54,7 +54,7 @@ const SingleLibraryItem = ({ | ||||
|       <ToolButton | ||||
|         aria-label={t("buttons.remove")} | ||||
|         type="button" | ||||
|         icon={close} | ||||
|         icon={CloseIcon} | ||||
|         className="single-library-item--remove" | ||||
|         onClick={onRemove.bind(null, libItem.id)} | ||||
|         title={t("buttons.remove")} | ||||
| @@ -62,7 +62,7 @@ const SingleLibraryItem = ({ | ||||
|       <div | ||||
|         style={{ | ||||
|           display: "flex", | ||||
|           margin: "0.8rem 0.3rem", | ||||
|           margin: "0.8rem 0", | ||||
|           width: "100%", | ||||
|           fontSize: "14px", | ||||
|           fontWeight: 500, | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import { NonDeletedExcalidrawElement } from "../element/types"; | ||||
| import { t } from "../i18n"; | ||||
| import { getTargetElements } from "../scene"; | ||||
| import { AppState, ExcalidrawProps } from "../types"; | ||||
| import { close } from "./icons"; | ||||
| import { CloseIcon } from "./icons"; | ||||
| import { Island } from "./Island"; | ||||
| import "./Stats.scss"; | ||||
|  | ||||
| @@ -23,7 +23,7 @@ export const Stats = (props: { | ||||
|     <div className="Stats"> | ||||
|       <Island padding={2}> | ||||
|         <div className="close" onClick={props.onClose}> | ||||
|           {close} | ||||
|           {CloseIcon} | ||||
|         </div> | ||||
|         <h3>{t("stats.title")}</h3> | ||||
|         <table> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { useCallback, useEffect, useRef } from "react"; | ||||
| import { close } from "./icons"; | ||||
| import { CloseIcon } from "./icons"; | ||||
| import "./Toast.scss"; | ||||
| import { ToolButton } from "./ToolButton"; | ||||
|  | ||||
| @@ -47,7 +47,7 @@ export const Toast = ({ | ||||
|       <p className="Toast__message">{message}</p> | ||||
|       {closable && ( | ||||
|         <ToolButton | ||||
|           icon={close} | ||||
|           icon={CloseIcon} | ||||
|           aria-label="close" | ||||
|           type="icon" | ||||
|           onClick={onClose} | ||||
|   | ||||
| @@ -3,12 +3,19 @@ | ||||
|  | ||||
| .excalidraw { | ||||
|   .ToolIcon { | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     display: inline-flex; | ||||
|     align-items: center; | ||||
|     position: relative; | ||||
|     cursor: pointer; | ||||
|     -webkit-tap-highlight-color: transparent; | ||||
|     user-select: none; | ||||
|  | ||||
|     &__hidden { | ||||
|       display: none !important; | ||||
|     } | ||||
|  | ||||
|     @include toolbarButtonColorStates; | ||||
|   } | ||||
|  | ||||
|   .ToolIcon--plain { | ||||
| @@ -21,21 +28,15 @@ | ||||
|  | ||||
|   .ToolIcon_type_radio, | ||||
|   .ToolIcon_type_checkbox { | ||||
|     & + .ToolIcon__icon { | ||||
|       background-color: var(--button-gray-1); | ||||
|  | ||||
|       &:hover { | ||||
|         background-color: var(--button-gray-2); | ||||
|       } | ||||
|       &:active { | ||||
|         background-color: var(--button-gray-3); | ||||
|       } | ||||
|     } | ||||
|     position: absolute; | ||||
|     opacity: 0; | ||||
|     pointer-events: none; | ||||
|   } | ||||
|  | ||||
|   .ToolIcon__icon { | ||||
|     width: 2.5rem; | ||||
|     height: 2.5rem; | ||||
|     box-sizing: border-box; | ||||
|     width: var(--default-button-size); | ||||
|     height: var(--default-button-size); | ||||
|     color: var(--icon-fill-color); | ||||
|  | ||||
|     display: flex; | ||||
| @@ -50,8 +51,8 @@ | ||||
|  | ||||
|     svg { | ||||
|       position: relative; | ||||
|       height: 1em; | ||||
|       fill: var(--icon-fill-color); | ||||
|       width: var(--default-icon-size); | ||||
|       height: var(--default-icon-size); | ||||
|       color: var(--icon-fill-color); | ||||
|     } | ||||
|   } | ||||
| @@ -75,13 +76,14 @@ | ||||
|     font-size: 0.8em; | ||||
|   } | ||||
|  | ||||
|   .excalidraw .ToolIcon_type_button, | ||||
|   .ToolIcon_type_button, | ||||
|   .Modal .ToolIcon_type_button, | ||||
|   .ToolIcon_type_button { | ||||
|     padding: 0; | ||||
|     border: none; | ||||
|     margin: 0; | ||||
|     font-size: inherit; | ||||
|     background-color: initial; | ||||
|  | ||||
|     &:focus-visible { | ||||
|       box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
| @@ -95,9 +97,9 @@ | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       background-color: var(--button-gray-2); | ||||
|     } | ||||
|     // &:hover { | ||||
|     //   background-color: var(--button-gray-2); | ||||
|     // } | ||||
|  | ||||
|     &:active { | ||||
|       background-color: var(--button-gray-3); | ||||
| @@ -108,29 +110,8 @@ | ||||
|     } | ||||
|  | ||||
|     &--hide { | ||||
|       visibility: hidden; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .ToolIcon_type_radio, | ||||
|   .ToolIcon_type_checkbox { | ||||
|     position: absolute; | ||||
|     opacity: 0; | ||||
|     pointer-events: none; | ||||
|  | ||||
|     &:not(.ToolIcon_toggle_opaque):checked + .ToolIcon__icon { | ||||
|       background-color: var(--button-gray-2); | ||||
|       &:active { | ||||
|         background-color: var(--button-gray-3); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:focus-visible + .ToolIcon__icon { | ||||
|       box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|     } | ||||
|  | ||||
|     &:active + .ToolIcon__icon { | ||||
|       background-color: var(--button-gray-3); | ||||
|       // visibility: hidden; | ||||
|       display: none !important; | ||||
|     } | ||||
|   } | ||||
|  | ||||
| @@ -163,66 +144,12 @@ | ||||
|     position: absolute; | ||||
|     bottom: 2px; | ||||
|     right: 3px; | ||||
|     font-size: 0.5em; | ||||
|     font-size: 0.625rem; | ||||
|     color: var(--keybinding-color); | ||||
|     font-family: var(--ui-font); | ||||
|     user-select: none; | ||||
|   } | ||||
|  | ||||
|   // shrink shape icons on small viewports to make them fit | ||||
|   @media (max-width: 425px) { | ||||
|     .Shape .ToolIcon__icon { | ||||
|       width: 2rem; | ||||
|       height: 2rem; | ||||
|  | ||||
|       svg { | ||||
|         height: 0.8em; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // move the lock button out of the way on small viewports | ||||
|   // it begins to collide with the GitHub icon before we switch to mobile mode | ||||
|   @media (max-width: 760px) { | ||||
|     .ToolIcon.ToolIcon_type_floating { | ||||
|       display: inline-block; | ||||
|       position: absolute; | ||||
|       right: -8px; | ||||
|  | ||||
|       margin-left: 0; | ||||
|       border-radius: 20px 0 0 20px; | ||||
|       z-index: 1; | ||||
|  | ||||
|       background-color: var(--button-gray-1); | ||||
|  | ||||
|       &:hover { | ||||
|         background-color: var(--button-gray-1); | ||||
|       } | ||||
|  | ||||
|       &:active { | ||||
|         background-color: var(--button-gray-2); | ||||
|       } | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         border-radius: inherit; | ||||
|       } | ||||
|  | ||||
|       svg { | ||||
|         position: static; | ||||
|       } | ||||
|     } | ||||
|     .ToolIcon.ToolIcon__library { | ||||
|       top: calc(var(--sat) + 100px); | ||||
|     } | ||||
|  | ||||
|     .ToolIcon.ToolIcon__lock { | ||||
|       top: calc(var(--sat) + 60px); | ||||
|     } | ||||
|     .ToolIcon.ToolIcon__penMode { | ||||
|       top: calc(var(--sat) + 140px); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .unlocked-icon { | ||||
|     :root[dir="ltr"] & { | ||||
|       left: 2px; | ||||
| @@ -232,4 +159,16 @@ | ||||
|       right: 2px; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .App-toolbar-container { | ||||
|     .ToolIcon__icon { | ||||
|       width: var(--lg-button-size); | ||||
|       height: var(--lg-button-size); | ||||
|  | ||||
|       svg { | ||||
|         width: var(--lg-icon-size); | ||||
|         height: var(--lg-icon-size); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -2,101 +2,20 @@ | ||||
| @import "../css/variables.module"; | ||||
|  | ||||
| .excalidraw { | ||||
|   .App-toolbar-container { | ||||
|     .ToolIcon_type_floating { | ||||
|       @include toolbarButtonColorStates; | ||||
|  | ||||
|       &:not(.is-mobile) { | ||||
|         .ToolIcon__icon { | ||||
|           padding: 1px; | ||||
|           background-color: var(--island-bg-color); | ||||
|           box-shadow: 1px 3px 4px 0px rgb(0 0 0 / 15%); | ||||
|           border-radius: 50%; | ||||
|           transition: box-shadow 0.5s ease, transform 0.5s ease; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .ToolIcon_type_radio, | ||||
|       .ToolIcon_type_checkbox { | ||||
|         &:focus-within + .ToolIcon__icon { | ||||
|           // override for custom floating button shadow | ||||
|           box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__hidden { | ||||
|       box-shadow: none !important; | ||||
|       background-color: transparent !important; | ||||
|       pointer-events: none !important; | ||||
|     } | ||||
|  | ||||
|     .ToolIcon.ToolIcon__lock { | ||||
|       &.ToolIcon_type_floating { | ||||
|         margin-left: 0.1rem; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .ToolIcon__library { | ||||
|       margin-inline-start: var(--space-factor); | ||||
|     } | ||||
|  | ||||
|     &.zen-mode { | ||||
|       .ToolIcon_type_floating { | ||||
|         .ToolIcon__icon { | ||||
|           box-shadow: none; | ||||
|           transform: scale(0.9); | ||||
|         } | ||||
|         .ToolIcon_type_checkbox:not(:checked):not(:hover):not(:active) { | ||||
|           & + .ToolIcon__icon { | ||||
|             svg { | ||||
|               fill: $oc-gray-5; | ||||
|               color: $oc-gray-5; | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .App-toolbar { | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 1px 1px 5px rgb(0 0 0 / 15%); | ||||
|  | ||||
|     .ToolIcon { | ||||
|       &:hover { | ||||
|         --icon-fill-color: var( | ||||
|           --color-primary-contrast-offset, | ||||
|           var(--color-primary) | ||||
|         ); | ||||
|         --keybinding-color: var( | ||||
|           --color-primary-contrast-offset, | ||||
|           var(--color-primary) | ||||
|         ); | ||||
|       } | ||||
|       &:active { | ||||
|         --icon-fill-color: #{$oc-gray-9}; | ||||
|         --keybinding-color: #{$oc-gray-9}; | ||||
|       } | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         background: transparent; | ||||
|         border-radius: var(--border-radius-lg); | ||||
|       } | ||||
|  | ||||
|       @include toolbarButtonColorStates; | ||||
|     } | ||||
|  | ||||
|     &.zen-mode { | ||||
|       .ToolIcon__keybinding, | ||||
|       .HintViewer { | ||||
|         display: none; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark .App-toolbar .ToolIcon:active { | ||||
|     --icon-fill-color: #{$oc-gray-3}; | ||||
|     --keybinding-color: #{$oc-gray-3}; | ||||
|     &__divider { | ||||
|       width: 1px; | ||||
|       height: 1.5rem; | ||||
|       align-self: center; | ||||
|       background-color: var(--default-border-color); | ||||
|       margin: 0 0.5rem; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -7,23 +7,30 @@ | ||||
|     display: flex; | ||||
|     flex-wrap: wrap; | ||||
|     justify-content: flex-end; | ||||
|     gap: 0.625rem; | ||||
|  | ||||
|     &:empty { | ||||
|       display: none; | ||||
|     } | ||||
|  | ||||
|     // can fit max 5 avatars in a column | ||||
|     max-height: 140px; | ||||
|  | ||||
|     // can fit max 10 avatars in a row when there's enough space | ||||
|     max-width: 290px; | ||||
|  | ||||
|     // Tweak in 30px increments to fit more/fewer avatars in a row/column ^^ | ||||
|  | ||||
|     overflow: hidden; | ||||
|   } | ||||
|  | ||||
|   .UserList > * { | ||||
|     pointer-events: all; | ||||
|     margin: 0 0 var(--space-factor) var(--space-factor); | ||||
|   } | ||||
|  | ||||
|   .UserList_mobile { | ||||
|     padding: 0; | ||||
|     justify-content: normal; | ||||
|   } | ||||
|  | ||||
|   .UserList_mobile > * { | ||||
|     margin: 0 var(--space-factor) var(--space-factor) 0; | ||||
|     margin: 0.5rem 0; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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} | ||||
|   | ||||
							
								
								
									
										273
									
								
								src/components/WelcomeScreen.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										273
									
								
								src/components/WelcomeScreen.scss
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,273 @@ | ||||
| .excalidraw { | ||||
|   .virgil { | ||||
|     font-family: "Virgil"; | ||||
|   } | ||||
|  | ||||
|   .WelcomeScreen-logo { | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     column-gap: 0.75rem; | ||||
|     font-size: 2.25rem; | ||||
|  | ||||
|     svg { | ||||
|       width: 1.625rem; | ||||
|       height: auto; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .WelcomeScreen-decor { | ||||
|     pointer-events: none; | ||||
|  | ||||
|     color: var(--color-gray-40); | ||||
|  | ||||
|     &--subheading { | ||||
|       font-size: 1.125rem; | ||||
|       text-align: center; | ||||
|     } | ||||
|  | ||||
|     &--help-pointer { | ||||
|       display: flex; | ||||
|       position: absolute; | ||||
|       right: 0; | ||||
|       bottom: 100%; | ||||
|  | ||||
|       :root[dir="rtl"] & { | ||||
|         left: 0; | ||||
|         right: auto; | ||||
|       } | ||||
|  | ||||
|       svg { | ||||
|         margin-top: 0.5rem; | ||||
|         width: 85px; | ||||
|         height: 71px; | ||||
|  | ||||
|         transform: scaleX(-1) rotate(80deg); | ||||
|  | ||||
|         :root[dir="rtl"] & { | ||||
|           transform: rotate(80deg); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--top-toolbar-pointer { | ||||
|       position: absolute; | ||||
|       top: 100%; | ||||
|       left: 50%; | ||||
|       transform: translateX(-50%); | ||||
|       margin-top: 2.5rem; | ||||
|       display: flex; | ||||
|       align-items: baseline; | ||||
|  | ||||
|       &__label { | ||||
|         width: 120px; | ||||
|         position: relative; | ||||
|         top: -0.5rem; | ||||
|       } | ||||
|  | ||||
|       svg { | ||||
|         width: 38px; | ||||
|         height: 78px; | ||||
|  | ||||
|         :root[dir="rtl"] & { | ||||
|           transform: scaleX(-1); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &--menu-pointer { | ||||
|       position: absolute; | ||||
|       width: 320px; | ||||
|       font-size: 1rem; | ||||
|  | ||||
|       top: 100%; | ||||
|       margin-top: 0.25rem; | ||||
|       margin-inline-start: 0.6rem; | ||||
|  | ||||
|       display: flex; | ||||
|       align-items: flex-end; | ||||
|       gap: 0.5rem; | ||||
|  | ||||
|       svg { | ||||
|         width: 41px; | ||||
|         height: 94px; | ||||
|  | ||||
|         :root[dir="rtl"] & { | ||||
|           transform: scaleX(-1); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .WelcomeScreen-container { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 2rem; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     position: absolute; | ||||
|     pointer-events: none; | ||||
|     left: 1rem; | ||||
|     top: 1rem; | ||||
|     right: 1rem; | ||||
|     bottom: 1rem; | ||||
|   } | ||||
|  | ||||
|   .WelcomeScreen-items { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     gap: 2px; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|   } | ||||
|  | ||||
|   .WelcomeScreen-item { | ||||
|     box-sizing: border-box; | ||||
|  | ||||
|     pointer-events: all; | ||||
|  | ||||
|     color: var(--color-gray-50); | ||||
|     font-size: 0.875rem; | ||||
|  | ||||
|     min-width: 300px; | ||||
|     display: flex; | ||||
|     align-items: center; | ||||
|     justify-content: space-between; | ||||
|  | ||||
|     background: none; | ||||
|     border: none; | ||||
|  | ||||
|     padding: 0.75rem; | ||||
|  | ||||
|     border-radius: var(--border-radius-md); | ||||
|  | ||||
|     &__label { | ||||
|       display: flex; | ||||
|       align-items: center; | ||||
|       column-gap: 0.5rem; | ||||
|  | ||||
|       svg { | ||||
|         width: var(--default-icon-size); | ||||
|         height: var(--default-icon-size); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &__shortcut { | ||||
|       color: var(--color-gray-40); | ||||
|       font-size: 0.75rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:not(:active) .WelcomeScreen-item:hover { | ||||
|     text-decoration: none; | ||||
|     background: var(--color-gray-10); | ||||
|  | ||||
|     .WelcomeScreen-item__shortcut { | ||||
|       color: var(--color-gray-50); | ||||
|     } | ||||
|  | ||||
|     .WelcomeScreen-item__label { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .WelcomeScreen-item:active { | ||||
|     background: var(--color-gray-20); | ||||
|  | ||||
|     .WelcomeScreen-item__shortcut { | ||||
|       color: var(--color-gray-50); | ||||
|     } | ||||
|  | ||||
|     .WelcomeScreen-item__label { | ||||
|       color: var(--color-gray-100); | ||||
|     } | ||||
|  | ||||
|     &--promo { | ||||
|       color: var(--color-promo) !important; | ||||
|  | ||||
|       &:hover { | ||||
|         .WelcomeScreen-item__label { | ||||
|           color: var(--color-promo) !important; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &.theme--dark { | ||||
|     .WelcomeScreen-decor { | ||||
|       color: var(--color-gray-60); | ||||
|     } | ||||
|  | ||||
|     .WelcomeScreen-item { | ||||
|       color: var(--color-gray-60); | ||||
|  | ||||
|       &__shortcut { | ||||
|         color: var(--color-gray-60); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     &:not(:active) .WelcomeScreen-item:hover { | ||||
|       background: var(--color-gray-85); | ||||
|  | ||||
|       .WelcomeScreen-item__shortcut { | ||||
|         color: var(--color-gray-50); | ||||
|       } | ||||
|  | ||||
|       .WelcomeScreen-item__label { | ||||
|         color: var(--color-gray-10); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     .WelcomeScreen-item:active { | ||||
|       background-color: var(--color-gray-90); | ||||
|       .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) { | ||||
|     .WelcomeScreen-container { | ||||
|       margin-top: 4rem; | ||||
|     } | ||||
|   } | ||||
|   @media (min-height: 600px) and (max-height: 900px) { | ||||
|     .WelcomeScreen-container { | ||||
|       margin-top: 8rem; | ||||
|     } | ||||
|   } | ||||
|   @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; | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -122,6 +122,7 @@ export const TITLE_TIMEOUT = 10000; | ||||
| export const VERSION_TIMEOUT = 30000; | ||||
| export const SCROLL_TIMEOUT = 100; | ||||
| export const ZOOM_STEP = 0.1; | ||||
| export const MIN_ZOOM = 0.1; | ||||
| export const HYPERLINK_TOOLTIP_DELAY = 300; | ||||
|  | ||||
| // Report a user inactive after IDLE_THRESHOLD milliseconds | ||||
|   | ||||
| @@ -19,6 +19,10 @@ | ||||
|   height: 100%; | ||||
|   width: 100%; | ||||
|  | ||||
|   button { | ||||
|     cursor: pointer; | ||||
|   } | ||||
|  | ||||
|   &:focus { | ||||
|     outline: none; | ||||
|   } | ||||
| @@ -85,15 +89,16 @@ | ||||
|   .panelColumn { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     row-gap: 0.75rem; | ||||
|  | ||||
|     h3, | ||||
|     legend, | ||||
|     .control-label { | ||||
|       margin-top: 0.333rem; | ||||
|       margin-bottom: 0.333rem; | ||||
|       margin: 0; | ||||
|       margin-bottom: 0.25rem; | ||||
|       font-size: 0.75rem; | ||||
|       color: var(--text-primary-color); | ||||
|       font-weight: bold; | ||||
|       font-weight: normal; | ||||
|       display: block; | ||||
|     } | ||||
|  | ||||
| @@ -102,12 +107,6 @@ | ||||
|       width: 100%; | ||||
|     } | ||||
|  | ||||
|     h3:first-child, | ||||
|     legend:first-child, | ||||
|     .control-label:first-child { | ||||
|       margin-top: 0; | ||||
|     } | ||||
|  | ||||
|     legend { | ||||
|       padding: 0; | ||||
|     } | ||||
| @@ -119,11 +118,12 @@ | ||||
|  | ||||
|     .buttonList { | ||||
|       flex-wrap: wrap; | ||||
|       display: flex; | ||||
|       column-gap: 0.5rem; | ||||
|       row-gap: 0.5rem; | ||||
|  | ||||
|       label { | ||||
|         margin-right: 0.25rem; | ||||
|         font-size: 0.75rem; | ||||
|         display: inline-block; | ||||
|       } | ||||
|  | ||||
|       input[type="radio"], | ||||
| @@ -136,38 +136,10 @@ | ||||
|       .iconRow { | ||||
|         margin-top: 8px; | ||||
|       } | ||||
|  | ||||
|       .ToolIcon { | ||||
|         margin: 0; | ||||
|         margin-inline-end: 8px; | ||||
|  | ||||
|         &:focus { | ||||
|           outline: transparent; | ||||
|           box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|         } | ||||
|  | ||||
|         &:hover { | ||||
|           background-color: var(--button-gray-2); | ||||
|         } | ||||
|  | ||||
|         &:active { | ||||
|           background-color: var(--button-gray-3); | ||||
|         } | ||||
|  | ||||
|         &:disabled { | ||||
|           cursor: not-allowed; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       .ToolIcon__icon { | ||||
|         width: 28px; | ||||
|         height: 28px; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     fieldset { | ||||
|       margin: 0; | ||||
|       margin-top: 0.333rem; | ||||
|       padding: 0; | ||||
|       border: none; | ||||
|     } | ||||
| @@ -185,64 +157,26 @@ | ||||
|     box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|   } | ||||
|  | ||||
|   button, | ||||
|   .buttonList label { | ||||
|     user-select: none; | ||||
|     background-color: var(--button-gray-1); | ||||
|     border: 0; | ||||
|     border-radius: var(--border-radius-md); | ||||
|     margin: 0.125rem 0; | ||||
|     padding: 0.25rem; | ||||
|     white-space: nowrap; | ||||
|  | ||||
|     cursor: pointer; | ||||
|  | ||||
|     &:focus-visible { | ||||
|       outline: transparent; | ||||
|       box-shadow: 0 0 0 2px var(--focus-highlight-color); | ||||
|   .buttonList { | ||||
|     .ToolIcon__icon { | ||||
|       all: unset !important; | ||||
|       display: flex !important; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       background-color: var(--button-gray-2); | ||||
|     button { | ||||
|       background-color: transparent; | ||||
|     } | ||||
|  | ||||
|     &:active { | ||||
|       background-color: var(--button-gray-3); | ||||
|     } | ||||
|     label, | ||||
|     button, | ||||
|     .zIndexButton { | ||||
|       @include outlineButtonStyles; | ||||
|  | ||||
|     &:disabled { | ||||
|       cursor: not-allowed; | ||||
|     } | ||||
|   } | ||||
|       padding: 0; | ||||
|  | ||||
|   .active, | ||||
|   .buttonList label.active { | ||||
|     background-color: var(--color-primary); | ||||
|  | ||||
|     --icon-fill-color: #{$oc-white}; | ||||
|  | ||||
|     &:hover { | ||||
|       background-color: var(--color-primary-darker); | ||||
|     } | ||||
|  | ||||
|     &:active { | ||||
|       background-color: var(--color-primary-darkest); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .buttonList.buttonListIcon { | ||||
|     label { | ||||
|       display: inline-flex; | ||||
|       justify-content: center; | ||||
|       align-items: center; | ||||
|       svg { | ||||
|         width: 35px; | ||||
|         height: 14px; | ||||
|         padding: 2px; | ||||
|         opacity: 0.6; | ||||
|       } | ||||
|       &.active svg { | ||||
|         opacity: 1; | ||||
|         width: var(--default-icon-size); | ||||
|         height: var(--default-icon-size); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| @@ -289,8 +223,6 @@ | ||||
|   .App-toolbar { | ||||
|     width: 100%; | ||||
|  | ||||
|     box-sizing: border-box; | ||||
|  | ||||
|     .eraser { | ||||
|       &.ToolIcon:hover { | ||||
|         --icon-fill-color: #fff; | ||||
| @@ -322,12 +254,27 @@ | ||||
|     color: var(--icon-fill-color); | ||||
|   } | ||||
|  | ||||
|   .shapes-section { | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     pointer-events: none !important; | ||||
|  | ||||
|     & > * { | ||||
|       pointer-events: all; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .App-menu_top { | ||||
|     grid-template-columns: auto max-content auto; | ||||
|     grid-gap: 4px; | ||||
|     grid-template-columns: 1fr 2fr 1fr; | ||||
|     grid-gap: 2rem; | ||||
|     align-items: flex-start; | ||||
|     cursor: default; | ||||
|     pointer-events: none !important; | ||||
|  | ||||
|     @media (min-width: 1536px) { | ||||
|       grid-template-columns: 1fr 1fr 1fr; | ||||
|       grid-gap: 3rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .layer-ui__wrapper:not(.disable-pointerEvents) .App-menu_top > * { | ||||
| @@ -344,20 +291,14 @@ | ||||
|  | ||||
|   .App-menu_bottom { | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     grid-template-columns: min-content auto min-content; | ||||
|     grid-gap: 15px; | ||||
|     bottom: 1rem; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
|     align-items: flex-start; | ||||
|     cursor: default; | ||||
|     pointer-events: none !important; | ||||
|  | ||||
|     :root[dir="ltr"] & { | ||||
|       left: 0.25rem; | ||||
|     } | ||||
|  | ||||
|     :root[dir="rtl"] & { | ||||
|       right: 0.25rem; | ||||
|     } | ||||
|     box-sizing: border-box; | ||||
|     padding: 0 1rem; | ||||
|  | ||||
|     &--transition-left { | ||||
|       section { | ||||
| @@ -390,7 +331,10 @@ | ||||
|  | ||||
|   .App-menu__left { | ||||
|     overflow-y: auto; | ||||
|     box-shadow: var(--shadow-island); | ||||
|     padding: 0.75rem; | ||||
|     width: 202px; | ||||
|     box-sizing: border-box; | ||||
|     position: absolute; | ||||
|   } | ||||
|  | ||||
|   .dropdown-select { | ||||
| @@ -426,55 +370,65 @@ | ||||
|     &:active { | ||||
|       background-color: var(--button-gray-2); | ||||
|     } | ||||
|  | ||||
|     &__language { | ||||
|       height: 2rem; | ||||
|       background-color: var(--island-bg-color); | ||||
|       border-color: var(--default-border-color) !important; | ||||
|       cursor: pointer; | ||||
|  | ||||
|       &:hover { | ||||
|         background-color: var(--island-bg-color); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .zIndexButton { | ||||
|     margin: 0; | ||||
|     margin-inline-end: 8px; | ||||
|     padding: 5px; | ||||
|     display: inline-flex; | ||||
|     align-items: center; | ||||
|     justify-content: center; | ||||
|   .disable-zen-mode { | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     background-color: var(--color-gray-20); | ||||
|     border: 1px solid var(--color-gray-30); | ||||
|     padding: 10px 20px; | ||||
|  | ||||
|     svg { | ||||
|       width: 18px; | ||||
|       height: 18px; | ||||
|     &:hover { | ||||
|       background-color: var(--color-gray-30); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .scroll-back-to-content { | ||||
|     color: var(--popup-text-color); | ||||
|     border-radius: var(--border-radius-lg); | ||||
|     background-color: var(--island-bg-color); | ||||
|     color: var(--icon-fill-color); | ||||
|  | ||||
|     border: 1px solid var(--default-border-color); | ||||
|     padding: 10px 20px; | ||||
|     position: absolute; | ||||
|     left: 50%; | ||||
|     bottom: 30px; | ||||
|     transform: translateX(-50%); | ||||
|     padding: 10px 20px; | ||||
|     pointer-events: all; | ||||
|  | ||||
|     &:hover { | ||||
|       background-color: var(--button-hover); | ||||
|     } | ||||
|  | ||||
|     &:active { | ||||
|       border: 1px solid var(--color-primary-darkest); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .help-icon { | ||||
|     display: flex; | ||||
|     cursor: pointer; | ||||
|     fill: $oc-gray-6; | ||||
|     padding: 0; | ||||
|     margin: 0; | ||||
|     background: none; | ||||
|     color: var(--icon-fill-color); | ||||
|     @include outlineButtonStyles; | ||||
|     background-color: var(--island-bg-color); | ||||
|     width: var(--lg-button-size); | ||||
|     height: var(--lg-button-size); | ||||
|  | ||||
|     svg { | ||||
|       width: 1.5rem; | ||||
|       height: 1.5rem; | ||||
|     } | ||||
|  | ||||
|     &:hover { | ||||
|       background: none; | ||||
|       width: var(--lg-icon-size); | ||||
|       height: var(--lg-icon-size); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .reset-zoom-button { | ||||
|     padding: 0.2em; | ||||
|     background: transparent; | ||||
|     color: var(--text-primary-color); | ||||
|     font-family: var(--ui-font); | ||||
|   } | ||||
|  | ||||
| @@ -491,7 +445,6 @@ | ||||
|   .eraser-buttons { | ||||
|     display: grid; | ||||
|     grid-auto-flow: column; | ||||
|     gap: 0.4em; | ||||
|     margin-top: auto; | ||||
|     margin-bottom: auto; | ||||
|     margin-inline-start: 0.6em; | ||||
| @@ -572,17 +525,49 @@ | ||||
|   // use custom, minimalistic scrollbar | ||||
|   // (doesn't work in Firefox) | ||||
|   ::-webkit-scrollbar { | ||||
|     width: 5px; | ||||
|     width: 3px; | ||||
|   } | ||||
|  | ||||
|   ::-webkit-scrollbar-thumb { | ||||
|     background: var(--button-gray-2); | ||||
|     background: var(--scrollbar-thumb); | ||||
|     border-radius: 10px; | ||||
|   } | ||||
|   ::-webkit-scrollbar-thumb:hover { | ||||
|     background: var(--button-gray-3); | ||||
|     background: var(--scrollbar-thumb-hover); | ||||
|   } | ||||
|   ::-webkit-scrollbar-thumb:active { | ||||
|     background: var(--button-gray-2); | ||||
|     background: var(--scrollbar-thumb); | ||||
|   } | ||||
|  | ||||
|   .mobile-misc-tools-container { | ||||
|     position: fixed; | ||||
|     top: 5rem; | ||||
|     right: 0; | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     border: 1px solid var(--sidebar-border-color); | ||||
|     border-top-left-radius: var(--border-radius-lg); | ||||
|     border-bottom-left-radius: var(--border-radius-lg); | ||||
|     border-right: 0; | ||||
|  | ||||
|     background-color: var(--island-bg-color); | ||||
|  | ||||
|     .ToolIcon__icon { | ||||
|       border-radius: 0; | ||||
|     } | ||||
|  | ||||
|     .library-button { | ||||
|       border: 0; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .App-toolbar--mobile { | ||||
|     overflow-x: hidden; | ||||
|     max-width: 100vw; | ||||
|  | ||||
|     .ToolIcon__keybinding { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user