mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-11-13 09:14:15 +01:00
feat: compact layout for tablets (#9910)
* feat: allow the hiding of top picks * feat: allow the hiding of default fonts * refactor: rename to compactMode * feat: introduce layout (incomplete) * tweak icons * do not show border * lint * add isTouchMobile to device * add isTouchMobile to device * refactor to use showCompactSidebar instead * hide library label in compact * fix icon color in dark theme * fix library and share btns getting hidden in smaller tablet widths * update tests * use a smaller gap between shapes * proper fix of range * quicker switching between different popovers * to not show properties panel at all when editing text * fix switching between different popovers for texts * fix popover not closable and font search auto focus * change properties for a new or editing text * change icon for more style settings * use bolt icon for extra actions * fix breakpoints * use rem for icon sizes * fix tests * improve switching between triggers (incomplete) * improve trigger switching (complete) * clean up code * put compact into app state * fix button size * remove redundant PanelComponentProps["compactMode"] * move fontSize UI on top * mobile detection (breakpoints incomplete) * tweak compact mode detection * rename appState prop & values * update snapshots --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
This commit is contained in:
@@ -129,6 +129,7 @@ export const CLASSES = {
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||
};
|
||||
|
||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||
@@ -347,9 +348,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
// breakpoints
|
||||
// -----------------------------------------------------------------------------
|
||||
// md screen
|
||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
// mobile: up to 699px
|
||||
export const MQ_MAX_WIDTH_MOBILE = 699;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = 600; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||
|
||||
// desktop/laptop
|
||||
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||
|
||||
// sidebar
|
||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
} from "./constants";
|
||||
|
||||
@@ -1278,3 +1280,59 @@ export const reduceToCommonValue = <T, R = T>(
|
||||
|
||||
return commonValue;
|
||||
};
|
||||
|
||||
export const isMobileOrTablet = (): boolean => {
|
||||
const ua = navigator.userAgent || "";
|
||||
const platform = navigator.platform || "";
|
||||
const uaData = (navigator as any).userAgentData as
|
||||
| { mobile?: boolean; platform?: string }
|
||||
| undefined;
|
||||
|
||||
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||
if (uaData) {
|
||||
const plat = (uaData.platform || "").toLowerCase();
|
||||
const isDesktopOS =
|
||||
plat === "windows" ||
|
||||
plat === "macos" ||
|
||||
plat === "linux" ||
|
||||
plat === "chrome os";
|
||||
if (uaData.mobile === true) {
|
||||
return true;
|
||||
}
|
||||
if (uaData.mobile === false && plat === "android") {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
if (isDesktopOS) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) ios (includes ipad) --------------------------------------------
|
||||
if (isIOS) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- 3) android legacy ua fallback -------------------------------------
|
||||
if (isAndroid) {
|
||||
const isAndroidPhone = /Mobile/i.test(ua);
|
||||
const isAndroidTablet = !isAndroidPhone;
|
||||
if (isAndroidPhone || isAndroidTablet) {
|
||||
const looksTouchTablet =
|
||||
matchMedia?.("(hover: none)").matches &&
|
||||
matchMedia?.("(pointer: coarse)").matches;
|
||||
return looksTouchTablet;
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4) last resort desktop exclusion ----------------------------------
|
||||
const looksDesktopPlatform =
|
||||
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||
if (looksDesktopPlatform) {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||
return (
|
||||
<ColorPicker
|
||||
@@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
if (!selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t(
|
||||
selectedElement.type === "arrow"
|
||||
? "labels.lineEditor.editArrow"
|
||||
|
||||
@@ -137,6 +137,11 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
|
||||
import {
|
||||
withCaretPositionPreservation,
|
||||
restoreCaretPosition,
|
||||
} from "../hooks/useTextEditorFocus";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
@@ -321,9 +326,11 @@ export const actionChangeStrokeColor = register({
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<>
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||
@@ -341,6 +348,7 @@ export const actionChangeStrokeColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -398,9 +406,11 @@ export const actionChangeBackgroundColor = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<>
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||
)}
|
||||
<ColorPicker
|
||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||
@@ -418,6 +428,7 @@ export const actionChangeBackgroundColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -518,9 +529,11 @@ export const actionChangeStrokeWidth = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
)}
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="stroke-width"
|
||||
@@ -575,9 +588,11 @@ export const actionChangeSloppiness = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
)}
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="sloppiness"
|
||||
@@ -628,9 +643,11 @@ export const actionChangeStrokeStyle = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
)}
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="strokeStyle"
|
||||
@@ -697,7 +714,7 @@ export const actionChangeFontSize = register({
|
||||
perform: (elements, appState, value, app) => {
|
||||
return changeFontSize(elements, appState, app, () => value, value);
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontSize")}</legend>
|
||||
<div className="buttonList">
|
||||
@@ -756,7 +773,14 @@ export const actionChangeFontSize = register({
|
||||
? null
|
||||
: appState.currentItemFontSize || DEFAULT_FONT_SIZE,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -1016,7 +1040,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
PanelComponent: ({ elements, appState, app, updateData, data }) => {
|
||||
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
@@ -1094,20 +1118,28 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
)}
|
||||
<FontPicker
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
onSelect={(fontFamily) => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
currentItemFontFamily: fontFamily,
|
||||
});
|
||||
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
withCaretPositionPreservation(
|
||||
() => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
currentItemFontFamily: fontFamily,
|
||||
});
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
},
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
);
|
||||
}}
|
||||
onHover={(fontFamily) => {
|
||||
setBatchedData({
|
||||
@@ -1164,25 +1196,28 @@ export const actionChangeFontFamily = register({
|
||||
}
|
||||
|
||||
setBatchedData({
|
||||
...batchedData,
|
||||
openPopup: "fontFamily",
|
||||
});
|
||||
} else {
|
||||
// close, use the cache and clear it afterwards
|
||||
const data = {
|
||||
openPopup: null,
|
||||
const fontFamilyData = {
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
} as ChangeFontFamilyData;
|
||||
|
||||
if (isUnmounted.current) {
|
||||
// in case the component was unmounted by the parent, trigger the update directly
|
||||
updateData({ ...batchedData, ...data });
|
||||
} else {
|
||||
setBatchedData(data);
|
||||
}
|
||||
|
||||
setBatchedData({
|
||||
...fontFamilyData,
|
||||
});
|
||||
cachedElementsRef.current.clear();
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (
|
||||
appState.stylesPanelMode === "compact" &&
|
||||
appState.editingTextElement
|
||||
) {
|
||||
restoreCaretPosition(null); // Just refocus without saved position
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -1225,8 +1260,9 @@ export const actionChangeTextAlign = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.textAlign")}</legend>
|
||||
@@ -1275,7 +1311,14 @@ export const actionChangeTextAlign = register({
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemTextAlign,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -1317,7 +1360,7 @@ export const actionChangeVerticalAlign = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||
return (
|
||||
<fieldset>
|
||||
<div className="buttonList">
|
||||
@@ -1367,7 +1410,14 @@ export const actionChangeVerticalAlign = register({
|
||||
) !== null,
|
||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -1616,6 +1666,25 @@ export const actionChangeArrowhead = register({
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeArrowProperties = register({
|
||||
name: "changeArrowProperties",
|
||||
label: "Change arrow properties",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
// This action doesn't perform any changes directly
|
||||
// It's just a container for the arrow type and arrowhead actions
|
||||
return false;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||
return (
|
||||
<div className="selected-shape-actions">
|
||||
{renderAction("changeArrowType")}
|
||||
{renderAction("changeArrowhead")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionChangeArrowType = register({
|
||||
name: "changeArrowType",
|
||||
label: "Change arrow types",
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
actionChangeFontFamily,
|
||||
actionChangeTextAlign,
|
||||
actionChangeVerticalAlign,
|
||||
actionChangeArrowProperties,
|
||||
} from "./actionProperties";
|
||||
|
||||
export {
|
||||
|
||||
@@ -69,6 +69,7 @@ export type ActionName =
|
||||
| "changeStrokeStyle"
|
||||
| "changeArrowhead"
|
||||
| "changeArrowType"
|
||||
| "changeArrowProperties"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
|
||||
@@ -123,6 +123,7 @@ export const getDefaultAppState = (): Omit<
|
||||
searchMatches: null,
|
||||
lockedMultiSelections: {},
|
||||
activeLockedId: null,
|
||||
stylesPanelMode: "full",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -247,6 +248,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
searchMatches: { browser: false, export: false, server: false },
|
||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||
activeLockedId: { browser: false, export: false, server: false },
|
||||
stylesPanelMode: { browser: true, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@@ -91,3 +91,120 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
|
||||
.compact-action-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 2.5rem;
|
||||
|
||||
--default-button-size: 2rem;
|
||||
|
||||
.compact-action-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: transparent;
|
||||
color: var(--color-on-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--button-hover-bg, var(--island-bg-color));
|
||||
border-color: var(
|
||||
--button-hover-border,
|
||||
var(--button-border, var(--default-border-color))
|
||||
);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--button-active-bg, var(--island-bg-color));
|
||||
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||
}
|
||||
}
|
||||
|
||||
.compact-popover-content {
|
||||
.popover-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popover-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions-island {
|
||||
width: fit-content;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.compact-popover-content {
|
||||
.popover-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.popover-section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.shape-actions-theme-scope {
|
||||
--button-border: transparent;
|
||||
--button-bg: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
:root.theme--dark .shape-actions-theme-scope {
|
||||
--button-hover-bg: #363541;
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import {
|
||||
CLASSES,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isArrowElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
||||
@@ -46,15 +48,20 @@ import {
|
||||
hasStrokeWidth,
|
||||
} from "../scene";
|
||||
|
||||
import { getFormValue } from "../actions/actionProperties";
|
||||
|
||||
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
||||
|
||||
import { getToolbarTools } from "./shapes";
|
||||
|
||||
import "./Actions.scss";
|
||||
|
||||
import { useDevice } from "./App";
|
||||
import { useDevice, useExcalidrawContainer } from "./App";
|
||||
import Stack from "./Stack";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { PropertiesPopover } from "./PropertiesPopover";
|
||||
import {
|
||||
EmbedIcon,
|
||||
extraToolsIcon,
|
||||
@@ -63,11 +70,29 @@ import {
|
||||
laserPointerToolIcon,
|
||||
MagicIcon,
|
||||
LassoIcon,
|
||||
sharpArrowIcon,
|
||||
roundArrowIcon,
|
||||
elbowArrowIcon,
|
||||
TextSizeIcon,
|
||||
adjustmentsIcon,
|
||||
DotsHorizontalIcon,
|
||||
} from "./icons";
|
||||
|
||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
UIAppState,
|
||||
Zoom,
|
||||
AppState,
|
||||
} from "../types";
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
|
||||
// Common CSS class combinations
|
||||
const PROPERTIES_CLASSES = clsx([
|
||||
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
|
||||
"properties-content",
|
||||
]);
|
||||
|
||||
export const canChangeStrokeColor = (
|
||||
appState: UIAppState,
|
||||
targetElements: ExcalidrawElement[],
|
||||
@@ -280,6 +305,437 @@ export const SelectedShapeActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
export const CompactShapeActions = ({
|
||||
appState,
|
||||
elementsMap,
|
||||
renderAction,
|
||||
app,
|
||||
setAppState,
|
||||
}: {
|
||||
appState: UIAppState;
|
||||
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||
renderAction: ActionManager["renderAction"];
|
||||
app: AppClassProperties;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
}) => {
|
||||
const targetElements = getTargetElements(elementsMap, appState);
|
||||
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
const isEditingTextOrNewElement = Boolean(
|
||||
appState.editingTextElement || appState.newElement,
|
||||
);
|
||||
|
||||
const showFillIcons =
|
||||
(hasBackground(appState.activeTool.type) &&
|
||||
!isTransparent(appState.currentItemBackgroundColor)) ||
|
||||
targetElements.some(
|
||||
(element) =>
|
||||
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||
);
|
||||
|
||||
const showLinkIcon = targetElements.length === 1;
|
||||
|
||||
const showLineEditorAction =
|
||||
!appState.selectedLinearElement?.isEditing &&
|
||||
targetElements.length === 1 &&
|
||||
isLinearElement(targetElements[0]) &&
|
||||
!isElbowArrow(targetElements[0]);
|
||||
|
||||
const showCropEditorAction =
|
||||
!appState.croppingElementId &&
|
||||
targetElements.length === 1 &&
|
||||
isImageElement(targetElements[0]);
|
||||
|
||||
const showAlignActions = alignActionsPredicate(appState, app);
|
||||
|
||||
let isSingleElementBoundContainer = false;
|
||||
if (
|
||||
targetElements.length === 2 &&
|
||||
(hasBoundTextElement(targetElements[0]) ||
|
||||
hasBoundTextElement(targetElements[1]))
|
||||
) {
|
||||
isSingleElementBoundContainer = true;
|
||||
}
|
||||
|
||||
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||
|
||||
return (
|
||||
<div className="compact-shape-actions">
|
||||
{/* Stroke Color */}
|
||||
{canChangeStrokeColor(appState, targetElements) && (
|
||||
<div className={clsx("compact-action-item")}>
|
||||
{renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background Color */}
|
||||
{canChangeBackgroundColor(appState, targetElements) && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeBackgroundColor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Properties (Fill, Stroke, Opacity) */}
|
||||
{(showFillIcons ||
|
||||
hasStrokeWidth(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeWidth(element.type)) ||
|
||||
hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) => hasStrokeStyle(element.type)) ||
|
||||
canChangeRoundness(appState.activeTool.type) ||
|
||||
targetElements.some((element) => canChangeRoundness(element.type))) && (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactStrokeStyles"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "compactStrokeStyles" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.stroke")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setAppState({
|
||||
openPopup:
|
||||
appState.openPopup === "compactStrokeStyles"
|
||||
? null
|
||||
: "compactStrokeStyles",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{adjustmentsIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactStrokeStyles" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
{showFillIcons && renderAction("changeFillStyle")}
|
||||
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasStrokeWidth(element.type),
|
||||
)) &&
|
||||
renderAction("changeStrokeWidth")}
|
||||
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
hasStrokeStyle(element.type),
|
||||
)) && (
|
||||
<>
|
||||
{renderAction("changeStrokeStyle")}
|
||||
{renderAction("changeSloppiness")}
|
||||
</>
|
||||
)}
|
||||
{(canChangeRoundness(appState.activeTool.type) ||
|
||||
targetElements.some((element) =>
|
||||
canChangeRoundness(element.type),
|
||||
)) &&
|
||||
renderAction("changeRoundness")}
|
||||
{renderAction("changeOpacity")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Arrow Properties */}
|
||||
{(toolIsArrow(appState.activeTool.type) ||
|
||||
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactArrowProperties"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "compactArrowProperties" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.arrowtypes")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setAppState({
|
||||
openPopup:
|
||||
appState.openPopup === "compactArrowProperties"
|
||||
? null
|
||||
: "compactArrowProperties",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
// Show an icon based on the current arrow type
|
||||
const arrowType = getFormValue(
|
||||
targetElements,
|
||||
app,
|
||||
(element) => {
|
||||
if (isArrowElement(element)) {
|
||||
return element.elbowed
|
||||
? "elbow"
|
||||
: element.roundness
|
||||
? "round"
|
||||
: "sharp";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) => isArrowElement(element),
|
||||
(hasSelection) =>
|
||||
hasSelection ? null : appState.currentItemArrowType,
|
||||
);
|
||||
|
||||
if (arrowType === "elbow") {
|
||||
return elbowArrowIcon;
|
||||
}
|
||||
if (arrowType === "round") {
|
||||
return roundArrowIcon;
|
||||
}
|
||||
return sharpArrowIcon;
|
||||
})()}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactArrowProperties" && (
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
className="properties-content"
|
||||
style={{ maxWidth: "13rem" }}
|
||||
onClose={() => {}}
|
||||
>
|
||||
{renderAction("changeArrowProperties")}
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linear Editor */}
|
||||
{showLineEditorAction && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("toggleLinearEditor")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text Properties */}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
<div className="compact-action-item">
|
||||
{renderAction("changeFontFamily")}
|
||||
</div>
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactTextProperties"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
if (appState.editingTextElement) {
|
||||
saveCaretPosition();
|
||||
}
|
||||
setAppState({ openPopup: "compactTextProperties" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
if (appState.editingTextElement) {
|
||||
restoreCaretPosition();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.textAlign")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (appState.openPopup === "compactTextProperties") {
|
||||
setAppState({ openPopup: null });
|
||||
} else {
|
||||
if (appState.editingTextElement) {
|
||||
saveCaretPosition();
|
||||
}
|
||||
setAppState({ openPopup: "compactTextProperties" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{TextSizeIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactTextProperties" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onClose={() => {
|
||||
// Refocus text editor when popover closes with caret restoration
|
||||
if (appState.editingTextElement) {
|
||||
restoreCaretPosition();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) &&
|
||||
renderAction("changeFontSize")}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||
renderAction("changeVerticalAlign")}
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dedicated Copy Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("duplicateSelection")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dedicated Delete Button */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
{renderAction("deleteSelectedElements")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Combined Other Actions */}
|
||||
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||
<div className="compact-action-item">
|
||||
<Popover.Root
|
||||
open={appState.openPopup === "compactOtherProperties"}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setAppState({ openPopup: "compactOtherProperties" });
|
||||
} else {
|
||||
setAppState({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="compact-action-button properties-trigger"
|
||||
title={t("labels.actions")}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setAppState({
|
||||
openPopup:
|
||||
appState.openPopup === "compactOtherProperties"
|
||||
? null
|
||||
: "compactOtherProperties",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{DotsHorizontalIcon}
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
{appState.openPopup === "compactOtherProperties" && (
|
||||
<PropertiesPopover
|
||||
className={PROPERTIES_CLASSES}
|
||||
container={container}
|
||||
style={{
|
||||
maxWidth: "12rem",
|
||||
// center the popover content
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClose={() => {}}
|
||||
>
|
||||
<div className="selected-shape-actions">
|
||||
<fieldset>
|
||||
<legend>{t("labels.layers")}</legend>
|
||||
<div className="buttonList">
|
||||
{renderAction("sendToBack")}
|
||||
{renderAction("sendBackward")}
|
||||
{renderAction("bringForward")}
|
||||
{renderAction("bringToFront")}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{showAlignActions && !isSingleElementBoundContainer && (
|
||||
<fieldset>
|
||||
<legend>{t("labels.align")}</legend>
|
||||
<div className="buttonList">
|
||||
{isRTL ? (
|
||||
<>
|
||||
{renderAction("alignRight")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignLeft")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{renderAction("alignLeft")}
|
||||
{renderAction("alignHorizontallyCentered")}
|
||||
{renderAction("alignRight")}
|
||||
</>
|
||||
)}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeHorizontally")}
|
||||
{/* 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")}
|
||||
{targetElements.length > 2 &&
|
||||
renderAction("distributeVertically")}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend>{t("labels.actions")}</legend>
|
||||
<div className="buttonList">
|
||||
{renderAction("group")}
|
||||
{renderAction("ungroup")}
|
||||
{showLinkIcon && renderAction("hyperlink")}
|
||||
{showCropEditorAction && renderAction("cropEditor")}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</PropertiesPopover>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShapesSwitcher = ({
|
||||
activeTool,
|
||||
appState,
|
||||
|
||||
@@ -41,9 +41,6 @@ import {
|
||||
LINE_CONFIRM_THRESHOLD,
|
||||
MAX_ALLOWED_FILE_BYTES,
|
||||
MIME_TYPES,
|
||||
MQ_MAX_HEIGHT_LANDSCAPE,
|
||||
MQ_MAX_WIDTH_LANDSCAPE,
|
||||
MQ_MAX_WIDTH_PORTRAIT,
|
||||
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
||||
POINTER_BUTTON,
|
||||
ROUNDNESS,
|
||||
@@ -100,9 +97,14 @@ import {
|
||||
randomInteger,
|
||||
CLASSES,
|
||||
Emitter,
|
||||
isMobile,
|
||||
MINIMUM_ARROW_SIZE,
|
||||
DOUBLE_TAP_POSITION_THRESHOLD,
|
||||
isMobileOrTablet,
|
||||
MQ_MAX_WIDTH_MOBILE,
|
||||
MQ_MAX_HEIGHT_LANDSCAPE,
|
||||
MQ_MAX_WIDTH_LANDSCAPE,
|
||||
MQ_MIN_TABLET,
|
||||
MQ_MAX_TABLET,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -667,7 +669,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
this.defaultSelectionTool = this.isMobileOrTablet()
|
||||
this.defaultSelectionTool = isMobileOrTablet()
|
||||
? ("lasso" as const)
|
||||
: ("selection" as const);
|
||||
const {
|
||||
@@ -2420,23 +2422,20 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
private isMobileOrTablet = (): boolean => {
|
||||
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||
const hasCoarsePointer =
|
||||
"matchMedia" in window &&
|
||||
window?.matchMedia("(pointer: coarse)")?.matches;
|
||||
const isTouchMobile = hasTouch && hasCoarsePointer;
|
||||
|
||||
return isMobile || isTouchMobile;
|
||||
};
|
||||
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
return (
|
||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
||||
width <= MQ_MAX_WIDTH_MOBILE ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||
);
|
||||
};
|
||||
|
||||
private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => {
|
||||
const minSide = Math.min(editorWidth, editorHeight);
|
||||
const maxSide = Math.max(editorWidth, editorHeight);
|
||||
|
||||
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||
};
|
||||
|
||||
private refreshViewportBreakpoints = () => {
|
||||
const container = this.excalidrawContainerRef.current;
|
||||
if (!container) {
|
||||
@@ -2481,6 +2480,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
canFitSidebar: editorWidth > sidebarBreakpoint,
|
||||
});
|
||||
|
||||
// also check if we need to update the app state
|
||||
this.setState({
|
||||
stylesPanelMode:
|
||||
// NOTE: we could also remove the isMobileOrTablet check here and
|
||||
// always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP)
|
||||
// but not too narrow (> MQ_MAX_WIDTH_MOBILE)
|
||||
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
|
||||
? "compact"
|
||||
: "full",
|
||||
});
|
||||
|
||||
if (prevEditorState !== nextEditorState) {
|
||||
this.device = { ...this.device, editor: nextEditorState };
|
||||
return true;
|
||||
@@ -3147,7 +3157,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files: data.files || null,
|
||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||
position: isMobileOrTablet() ? "center" : "cursor",
|
||||
retainSeed: isPlainPaste,
|
||||
});
|
||||
return;
|
||||
@@ -3172,7 +3182,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements,
|
||||
files,
|
||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
||||
position: isMobileOrTablet() ? "center" : "cursor",
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -6668,8 +6678,6 @@ class App extends React.Component<AppProps, AppState> {
|
||||
pointerDownState.hit.element &&
|
||||
this.isASelectedElement(pointerDownState.hit.element);
|
||||
|
||||
const isMobileOrTablet = this.isMobileOrTablet();
|
||||
|
||||
if (
|
||||
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
|
||||
!pointerDownState.resize.handleType &&
|
||||
@@ -6683,12 +6691,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// block dragging after lasso selection on PCs until the next pointer down
|
||||
// (on mobile or tablet, we want to allow user to drag immediately)
|
||||
pointerDownState.drag.blockDragging = !isMobileOrTablet;
|
||||
pointerDownState.drag.blockDragging = !isMobileOrTablet();
|
||||
}
|
||||
|
||||
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
|
||||
if (
|
||||
isMobileOrTablet &&
|
||||
isMobileOrTablet() &&
|
||||
pointerDownState.hit.element &&
|
||||
!hitSelectedElement
|
||||
) {
|
||||
@@ -8489,7 +8497,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
this.state.activeTool.type === "lasso" &&
|
||||
this.lassoTrail.hasCurrentTrail &&
|
||||
!(this.isMobileOrTablet() && pointerDownState.hit.element) &&
|
||||
!(isMobileOrTablet() && pointerDownState.hit.element) &&
|
||||
!this.state.activeTool.fromSelection
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
@include isMobile {
|
||||
max-width: 11rem;
|
||||
}
|
||||
|
||||
&.color-picker-container--no-top-picks {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
grid-template-columns: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__top-picks {
|
||||
@@ -80,6 +86,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__button-background {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
.color-picker__button-outline {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import clsx from "clsx";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useEffect } from "react";
|
||||
|
||||
import {
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
@@ -18,7 +18,12 @@ import { useExcalidrawContainer } from "../App";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { slashIcon } from "../icons";
|
||||
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
|
||||
import {
|
||||
saveCaretPosition,
|
||||
restoreCaretPosition,
|
||||
temporarilyDisableTextEditorBlur,
|
||||
} from "../../hooks/useTextEditorFocus";
|
||||
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { Picker } from "./Picker";
|
||||
@@ -67,6 +72,7 @@ interface ColorPickerProps {
|
||||
palette?: ColorPaletteCustom | null;
|
||||
topPicks?: ColorTuple;
|
||||
updateData: (formData?: any) => void;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
const ColorPickerPopupContent = ({
|
||||
@@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
|
||||
elements,
|
||||
palette = COLOR_PALETTE,
|
||||
updateData,
|
||||
getOpenPopup,
|
||||
appState,
|
||||
}: Pick<
|
||||
ColorPickerProps,
|
||||
| "type"
|
||||
@@ -86,7 +94,10 @@ const ColorPickerPopupContent = ({
|
||||
| "elements"
|
||||
| "palette"
|
||||
| "updateData"
|
||||
>) => {
|
||||
| "appState"
|
||||
> & {
|
||||
getOpenPopup: () => AppState["openPopup"];
|
||||
}) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
@@ -117,6 +128,8 @@ const ColorPickerPopupContent = ({
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "13rem" }}
|
||||
// Improve focus handling for text editing scenarios
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
@@ -131,8 +144,23 @@ const ColorPickerPopupContent = ({
|
||||
}
|
||||
}}
|
||||
onClose={() => {
|
||||
updateData({ openPopup: null });
|
||||
// only clear if we're still the active popup (avoid racing with switch)
|
||||
if (getOpenPopup() === type) {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
setActiveColorPickerSection(null);
|
||||
|
||||
// Refocus text editor when popover closes if we were editing text
|
||||
if (appState.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
@@ -141,7 +169,17 @@ const ColorPickerPopupContent = ({
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
// Save caret position before color change if editing text
|
||||
const savedSelection = appState.editingTextElement
|
||||
? saveCaretPosition()
|
||||
: null;
|
||||
|
||||
onChange(changedColor);
|
||||
|
||||
// Restore caret position after color change if editing text
|
||||
if (appState.editingTextElement && savedSelection) {
|
||||
restoreCaretPosition(savedSelection);
|
||||
}
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
@@ -168,6 +206,7 @@ const ColorPickerPopupContent = ({
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else {
|
||||
// close explicitly on Escape
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
@@ -188,11 +227,32 @@ const ColorPickerTrigger = ({
|
||||
label,
|
||||
color,
|
||||
type,
|
||||
compactMode = false,
|
||||
mode = "background",
|
||||
onToggle,
|
||||
editingTextElement,
|
||||
}: {
|
||||
color: string | null;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
compactMode?: boolean;
|
||||
mode?: "background" | "stroke";
|
||||
onToggle: () => void;
|
||||
editingTextElement?: boolean;
|
||||
}) => {
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
// use pointerdown so we run before outside-close logic
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// If editing text, temporarily disable the wysiwyg blur event
|
||||
if (editingTextElement) {
|
||||
temporarilyDisableTextEditorBlur();
|
||||
}
|
||||
|
||||
onToggle();
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
@@ -208,8 +268,37 @@ const ColorPickerTrigger = ({
|
||||
? t("labels.showStroke")
|
||||
: t("labels.showBackground")
|
||||
}
|
||||
data-openpopup={type}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||
{compactMode && color && (
|
||||
<div className="color-picker__button-background">
|
||||
{mode === "background" ? (
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
? "#fff"
|
||||
: "#111",
|
||||
}}
|
||||
>
|
||||
{backgroundIcon}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||
? "#fff"
|
||||
: "#111",
|
||||
}}
|
||||
>
|
||||
{strokeIcon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
@@ -224,25 +313,59 @@ export const ColorPicker = ({
|
||||
topPicks,
|
||||
updateData,
|
||||
appState,
|
||||
compactMode = false,
|
||||
}: ColorPickerProps) => {
|
||||
const openRef = useRef(appState.openPopup);
|
||||
useEffect(() => {
|
||||
openRef.current = appState.openPopup;
|
||||
}, [appState.openPopup]);
|
||||
return (
|
||||
<div>
|
||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
||||
<TopPicks
|
||||
activeColor={color}
|
||||
onChange={onChange}
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("color-picker-container", {
|
||||
"color-picker-container--no-top-picks": compactMode,
|
||||
})}
|
||||
>
|
||||
{!compactMode && (
|
||||
<TopPicks
|
||||
activeColor={color}
|
||||
onChange={onChange}
|
||||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<Popover.Root
|
||||
open={appState.openPopup === type}
|
||||
onOpenChange={(open) => {
|
||||
updateData({ openPopup: open ? type : null });
|
||||
if (open) {
|
||||
updateData({ openPopup: type });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* serves as an active color indicator as well */}
|
||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
||||
<ColorPickerTrigger
|
||||
color={color}
|
||||
label={label}
|
||||
type={type}
|
||||
compactMode={compactMode}
|
||||
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||
editingTextElement={!!appState.editingTextElement}
|
||||
onToggle={() => {
|
||||
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||
if (appState.openPopup === type) {
|
||||
// toggle off on same trigger
|
||||
updateData({ openPopup: null });
|
||||
} else if (appState.openPopup) {
|
||||
updateData({ openPopup: type });
|
||||
} else {
|
||||
// open this one
|
||||
updateData({ openPopup: type });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* popup content */}
|
||||
{appState.openPopup === type && (
|
||||
<ColorPickerPopupContent
|
||||
@@ -253,6 +376,8 @@ export const ColorPicker = ({
|
||||
elements={elements}
|
||||
palette={palette}
|
||||
updateData={updateData}
|
||||
getOpenPopup={() => openRef.current}
|
||||
appState={appState}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
2rem + 4 * var(--default-button-size)
|
||||
); // 4 gaps + 4 buttons
|
||||
}
|
||||
|
||||
&--compact {
|
||||
display: block;
|
||||
grid-template-columns: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import clsx from "clsx";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
|
||||
import { FONT_FAMILY } from "@excalidraw/common";
|
||||
@@ -58,6 +59,7 @@ interface FontPickerProps {
|
||||
onHover: (fontFamily: FontFamilyValues) => void;
|
||||
onLeave: () => void;
|
||||
onPopupChange: (open: boolean) => void;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const FontPicker = React.memo(
|
||||
@@ -69,6 +71,7 @@ export const FontPicker = React.memo(
|
||||
onHover,
|
||||
onLeave,
|
||||
onPopupChange,
|
||||
compactMode = false,
|
||||
}: FontPickerProps) => {
|
||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||
const onSelectCallback = useCallback(
|
||||
@@ -81,18 +84,29 @@ export const FontPicker = React.memo(
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||
<div className="buttonList">
|
||||
<RadioSelection<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
</div>
|
||||
<ButtonSeparator />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={clsx("FontPicker__container", {
|
||||
"FontPicker__container--compact": compactMode,
|
||||
})}
|
||||
>
|
||||
{!compactMode && (
|
||||
<div className="buttonList">
|
||||
<RadioSelection<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!compactMode && <ButtonSeparator />}
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
/>
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
|
||||
@@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
|
||||
onClose,
|
||||
}: FontPickerListProps) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const { fonts } = useApp();
|
||||
const app = useApp();
|
||||
const { fonts } = app;
|
||||
const { showDeprecatedFonts } = useAppProps();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
@@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
|
||||
onLeave,
|
||||
]);
|
||||
|
||||
// Create a wrapped onSelect function that preserves caret position
|
||||
const wrappedOnSelect = useCallback(
|
||||
(fontFamily: FontFamilyValues) => {
|
||||
// Save caret position before font selection if editing text
|
||||
let savedSelection: { start: number; end: number } | null = null;
|
||||
if (app.state.editingTextElement) {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
savedSelection = {
|
||||
start: textEditor.selectionStart,
|
||||
end: textEditor.selectionEnd,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onSelect(fontFamily);
|
||||
|
||||
// Restore caret position after font selection if editing text
|
||||
if (app.state.editingTextElement && savedSelection) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor && savedSelection) {
|
||||
textEditor.focus();
|
||||
textEditor.selectionStart = savedSelection.start;
|
||||
textEditor.selectionEnd = savedSelection.end;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
[onSelect, app.state.editingTextElement],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
const handled = fontPickerKeyHandler({
|
||||
@@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onSelect,
|
||||
onSelect: wrappedOnSelect,
|
||||
onHover,
|
||||
onClose,
|
||||
});
|
||||
@@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
||||
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
|
||||
// allow to tab between search and selected font
|
||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
onSelect(Number(e.currentTarget.value));
|
||||
wrappedOnSelect(Number(e.currentTarget.value));
|
||||
}}
|
||||
onMouseMove={() => {
|
||||
if (hoveredFont?.value !== font.value) {
|
||||
@@ -282,9 +319,24 @@ export const FontPickerList = React.memo(
|
||||
className="properties-content"
|
||||
container={container}
|
||||
style={{ width: "15rem" }}
|
||||
onClose={onClose}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (app.state.editingTextElement) {
|
||||
setTimeout(() => {
|
||||
const textEditor = document.querySelector(
|
||||
".excalidraw-wysiwyg",
|
||||
) as HTMLTextAreaElement;
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
onPointerLeave={onLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||
>
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
@@ -7,33 +6,38 @@ import { t } from "../../i18n";
|
||||
import { ButtonIcon } from "../ButtonIcon";
|
||||
import { TextIcon } from "../icons";
|
||||
|
||||
import { isDefaultFont } from "./FontPicker";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
isOpened?: boolean;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
isOpened = false,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const isTriggerActive = useMemo(
|
||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
||||
[selectedFontFamily],
|
||||
);
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||
<div>
|
||||
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||
<ButtonIcon
|
||||
standalone
|
||||
icon={TextIcon}
|
||||
title={t("labels.showFonts")}
|
||||
className="properties-trigger"
|
||||
testId={"font-family-show-fonts"}
|
||||
active={isTriggerActive}
|
||||
// no-op
|
||||
onClick={() => {}}
|
||||
active={isOpened}
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
openPopup:
|
||||
appState.openPopup === "fontFamily" ? null : appState.openPopup,
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
gap: 0.75rem;
|
||||
pointer-events: none !important;
|
||||
|
||||
&--compact {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import React from "react";
|
||||
import {
|
||||
CLASSES,
|
||||
DEFAULT_SIDEBAR,
|
||||
MQ_MIN_WIDTH_DESKTOP,
|
||||
TOOL_TYPE,
|
||||
arrayToMap,
|
||||
capitalizeString,
|
||||
@@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import {
|
||||
SelectedShapeActions,
|
||||
ShapesSwitcher,
|
||||
CompactShapeActions,
|
||||
} from "./Actions";
|
||||
import { LoadingMessage } from "./LoadingMessage";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { MobileMenu } from "./MobileMenu";
|
||||
@@ -157,6 +162,25 @@ const LayerUI = ({
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
|
||||
const spacing =
|
||||
appState.stylesPanelMode === "compact"
|
||||
? {
|
||||
menuTopGap: 4,
|
||||
toolbarColGap: 4,
|
||||
toolbarRowGap: 1,
|
||||
toolbarInnerRowGap: 0.5,
|
||||
islandPadding: 1,
|
||||
collabMarginLeft: 8,
|
||||
}
|
||||
: {
|
||||
menuTopGap: 6,
|
||||
toolbarColGap: 4,
|
||||
toolbarRowGap: 1,
|
||||
toolbarInnerRowGap: 1,
|
||||
islandPadding: 1,
|
||||
collabMarginLeft: 8,
|
||||
};
|
||||
|
||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||
@@ -209,31 +233,55 @@ const LayerUI = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectedShapeActions = () => (
|
||||
<Section
|
||||
heading="selectedShapeActions"
|
||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Island
|
||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||
padding={2}
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so subtracting the
|
||||
// approximate height of hamburgerMenu + footer
|
||||
maxHeight: `${appState.height - 166}px`,
|
||||
}}
|
||||
const renderSelectedShapeActions = () => {
|
||||
const isCompactMode = appState.stylesPanelMode === "compact";
|
||||
|
||||
return (
|
||||
<Section
|
||||
heading="selectedShapeActions"
|
||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
</Section>
|
||||
);
|
||||
{isCompactMode ? (
|
||||
<Island
|
||||
className={clsx("compact-shape-actions-island")}
|
||||
padding={0}
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so subtracting the
|
||||
// approximate height of hamburgerMenu + footer
|
||||
maxHeight: `${appState.height - 166}px`,
|
||||
}}
|
||||
>
|
||||
<CompactShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
</Island>
|
||||
) : (
|
||||
<Island
|
||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||
padding={2}
|
||||
style={{
|
||||
// we want to make sure this doesn't overflow so subtracting the
|
||||
// approximate height of hamburgerMenu + footer
|
||||
maxHeight: `${appState.height - 166}px`,
|
||||
}}
|
||||
>
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Island>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFixedSideContainer = () => {
|
||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||
@@ -250,9 +298,19 @@ const LayerUI = ({
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||
<Stack.Col
|
||||
gap={spacing.menuTopGap}
|
||||
className={clsx("App-menu_top__left")}
|
||||
>
|
||||
{renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
<div
|
||||
className={clsx("selected-shape-actions-container", {
|
||||
"selected-shape-actions-container--compact":
|
||||
appState.stylesPanelMode === "compact",
|
||||
})}
|
||||
>
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</div>
|
||||
</Stack.Col>
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
@@ -262,17 +320,19 @@ const LayerUI = ({
|
||||
{renderWelcomeScreen && (
|
||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||
)}
|
||||
<Stack.Col gap={4} align="start">
|
||||
<Stack.Col gap={spacing.toolbarColGap} align="start">
|
||||
<Stack.Row
|
||||
gap={1}
|
||||
gap={spacing.toolbarRowGap}
|
||||
className={clsx("App-toolbar-container", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Island
|
||||
padding={1}
|
||||
padding={spacing.islandPadding}
|
||||
className={clsx("App-toolbar", {
|
||||
"zen-mode": appState.zenModeEnabled,
|
||||
"App-toolbar--compact":
|
||||
appState.stylesPanelMode === "compact",
|
||||
})}
|
||||
>
|
||||
<HintViewer
|
||||
@@ -282,7 +342,7 @@ const LayerUI = ({
|
||||
app={app}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<Stack.Row gap={spacing.toolbarInnerRowGap}>
|
||||
<PenModeButton
|
||||
zenModeEnabled={appState.zenModeEnabled}
|
||||
checked={appState.penMode}
|
||||
@@ -316,7 +376,7 @@ const LayerUI = ({
|
||||
{isCollaborating && (
|
||||
<Island
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
marginLeft: spacing.collabMarginLeft,
|
||||
alignSelf: "center",
|
||||
height: "fit-content",
|
||||
}}
|
||||
@@ -344,6 +404,8 @@ const LayerUI = ({
|
||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||
{
|
||||
"transition-right": appState.zenModeEnabled,
|
||||
"layer-ui__wrapper__top-right--compact":
|
||||
appState.stylesPanelMode === "compact",
|
||||
},
|
||||
)}
|
||||
>
|
||||
@@ -418,7 +480,9 @@ const LayerUI = ({
|
||||
}}
|
||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||
>
|
||||
{t("toolBar.library")}
|
||||
{appState.stylesPanelMode === "full" &&
|
||||
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
|
||||
t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||
|
||||
@@ -17,6 +17,7 @@ interface PropertiesPopoverProps {
|
||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
|
||||
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
|
||||
preventAutoFocusOnTouch?: boolean;
|
||||
}
|
||||
|
||||
export const PropertiesPopover = React.forwardRef<
|
||||
@@ -34,6 +35,7 @@ export const PropertiesPopover = React.forwardRef<
|
||||
onFocusOutside,
|
||||
onPointerLeave,
|
||||
onPointerDownOutside,
|
||||
preventAutoFocusOnTouch = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
@@ -64,6 +66,12 @@ export const PropertiesPopover = React.forwardRef<
|
||||
onKeyDown={onKeyDown}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onOpenAutoFocus={(e) => {
|
||||
// prevent auto-focus on touch devices to avoid keyboard popup
|
||||
if (preventAutoFocusOnTouch && device.isTouchScreen) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
&--compact {
|
||||
.ToolIcon__keybinding {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.App-toolbar__divider {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__divider {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
|
||||
@@ -118,6 +118,17 @@ export const DotsIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
|
||||
export const DotsHorizontalIcon = createIcon(
|
||||
<g strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
<path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: pinned
|
||||
export const PinIcon = createIcon(
|
||||
<svg strokeWidth="1.5">
|
||||
@@ -396,6 +407,19 @@ export const TextIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const TextSizeIcon = createIcon(
|
||||
<g stroke="currentColor" strokeWidth="1.5">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M3 7v-2h13v2" />
|
||||
<path d="M10 5v14" />
|
||||
<path d="M12 19h-4" />
|
||||
<path d="M15 13v-1h6v1" />
|
||||
<path d="M18 12v7" />
|
||||
<path d="M17 19h2" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// modified tabler-icons: photo
|
||||
export const ImageIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
@@ -2269,3 +2293,48 @@ export const elementLinkIcon = createIcon(
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const resizeIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
|
||||
<path d="M4 12h7a1 1 0 0 1 1 1v7" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const adjustmentsIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 6l8 0" />
|
||||
<path d="M16 6l4 0" />
|
||||
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 12l2 0" />
|
||||
<path d="M10 12l10 0" />
|
||||
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M4 18l11 0" />
|
||||
<path d="M19 18l1 0" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const backgroundIcon = createIcon(
|
||||
<g strokeWidth={1}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 10l4 -4" />
|
||||
<path d="M6 14l8 -8" />
|
||||
<path d="M6 18l12 -12" />
|
||||
<path d="M10 18l8 -8" />
|
||||
<path d="M14 18l4 -4" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const strokeIcon = createIcon(
|
||||
<g strokeWidth={1}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<rect x="6" y="6" width="12" height="12" fill="none" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
import { Button } from "../Button";
|
||||
import { share } from "../icons";
|
||||
@@ -17,7 +19,8 @@ const LiveCollaborationTrigger = ({
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const appState = useUIAppState();
|
||||
|
||||
const showIconOnly = appState.width < 830;
|
||||
const showIconOnly =
|
||||
isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
|
||||
|
||||
.App-menu_top {
|
||||
grid-template-columns: 1fr 2fr 1fr;
|
||||
grid-gap: 2rem;
|
||||
grid-gap: 1rem;
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
@@ -336,6 +336,14 @@ body.excalidraw-cursor-resize * {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.selected-shape-actions-container {
|
||||
width: fit-content;
|
||||
|
||||
&--compact {
|
||||
min-width: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.App-menu_top > *:last-child {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
112
packages/excalidraw/hooks/useTextEditorFocus.ts
Normal file
112
packages/excalidraw/hooks/useTextEditorFocus.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
// Utility type for caret position
|
||||
export type CaretPosition = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
// Utility function to get text editor element
|
||||
const getTextEditor = (): HTMLTextAreaElement | null => {
|
||||
return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
// Utility functions for caret position management
|
||||
export const saveCaretPosition = (): CaretPosition | null => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
return {
|
||||
start: textEditor.selectionStart,
|
||||
end: textEditor.selectionEnd,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const restoreCaretPosition = (position: CaretPosition | null): void => {
|
||||
setTimeout(() => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
if (position) {
|
||||
textEditor.selectionStart = position.start;
|
||||
textEditor.selectionEnd = position.end;
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
export const withCaretPositionPreservation = (
|
||||
callback: () => void,
|
||||
isCompactMode: boolean,
|
||||
isEditingText: boolean,
|
||||
onPreventClose?: () => void,
|
||||
): void => {
|
||||
// Prevent popover from closing in compact mode
|
||||
if (isCompactMode && onPreventClose) {
|
||||
onPreventClose();
|
||||
}
|
||||
|
||||
// Save caret position if editing text
|
||||
const savedPosition =
|
||||
isCompactMode && isEditingText ? saveCaretPosition() : null;
|
||||
|
||||
// Execute the callback
|
||||
callback();
|
||||
|
||||
// Restore caret position if needed
|
||||
if (isCompactMode && isEditingText) {
|
||||
restoreCaretPosition(savedPosition);
|
||||
}
|
||||
};
|
||||
|
||||
// Hook for managing text editor caret position with state
|
||||
export const useTextEditorFocus = () => {
|
||||
const [savedCaretPosition, setSavedCaretPosition] =
|
||||
useState<CaretPosition | null>(null);
|
||||
|
||||
const saveCaretPositionToState = useCallback(() => {
|
||||
const position = saveCaretPosition();
|
||||
setSavedCaretPosition(position);
|
||||
}, []);
|
||||
|
||||
const restoreCaretPositionFromState = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
textEditor.focus();
|
||||
if (savedCaretPosition) {
|
||||
textEditor.selectionStart = savedCaretPosition.start;
|
||||
textEditor.selectionEnd = savedCaretPosition.end;
|
||||
setSavedCaretPosition(null);
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, [savedCaretPosition]);
|
||||
|
||||
const clearSavedPosition = useCallback(() => {
|
||||
setSavedCaretPosition(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
saveCaretPosition: saveCaretPositionToState,
|
||||
restoreCaretPosition: restoreCaretPositionFromState,
|
||||
clearSavedPosition,
|
||||
hasSavedPosition: !!savedCaretPosition,
|
||||
};
|
||||
};
|
||||
|
||||
// Utility function to temporarily disable text editor blur
|
||||
export const temporarilyDisableTextEditorBlur = (
|
||||
duration: number = 100,
|
||||
): void => {
|
||||
const textEditor = getTextEditor();
|
||||
if (textEditor) {
|
||||
const originalOnBlur = textEditor.onblur;
|
||||
textEditor.onblur = null;
|
||||
|
||||
setTimeout(() => {
|
||||
textEditor.onblur = originalOnBlur;
|
||||
}, duration);
|
||||
}
|
||||
};
|
||||
@@ -981,6 +981,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1172,6 +1173,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": {
|
||||
@@ -1384,6 +1386,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1713,6 +1716,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2042,6 +2046,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": {
|
||||
@@ -2252,6 +2257,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2493,6 +2499,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2794,6 +2801,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3159,6 +3167,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": {
|
||||
@@ -3650,6 +3659,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3971,6 +3981,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4295,6 +4306,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5578,6 +5590,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -6795,6 +6808,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7724,6 +7738,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8721,6 +8736,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9713,6 +9729,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
@@ -688,6 +688,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
||||
aria-haspopup="dialog"
|
||||
aria-label="Canvas background"
|
||||
class="color-picker__button active-color properties-trigger has-outline"
|
||||
data-openpopup="canvasBackground"
|
||||
data-state="closed"
|
||||
style="--swatch-color: #ffffff;"
|
||||
title="Show background color picker"
|
||||
|
||||
@@ -100,6 +100,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -714,6 +715,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1197,6 +1199,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1559,6 +1562,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1924,6 +1928,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2184,6 +2189,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2625,6 +2631,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2926,6 +2933,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3243,6 +3251,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3535,6 +3544,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3819,6 +3829,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4052,6 +4063,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4307,6 +4319,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4576,6 +4589,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4803,6 +4817,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5030,6 +5045,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5275,6 +5291,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5529,6 +5546,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5784,6 +5802,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -6111,6 +6130,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -6539,6 +6559,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -6917,6 +6938,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7216,6 +7238,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7530,6 +7553,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7758,6 +7782,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8108,6 +8133,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8464,6 +8490,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8862,6 +8889,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9149,6 +9177,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9411,6 +9440,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9674,6 +9704,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9905,6 +9936,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -10199,6 +10231,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -10544,6 +10577,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -10781,6 +10815,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11226,6 +11261,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11482,6 +11518,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11717,6 +11754,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11950,6 +11988,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -12356,6 +12395,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -12561,6 +12601,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -12770,6 +12811,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13066,6 +13108,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13362,6 +13405,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13604,6 +13648,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13839,6 +13884,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14074,6 +14120,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14319,6 +14366,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14649,6 +14697,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14819,6 +14868,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -15099,6 +15149,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -15360,6 +15411,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -15512,6 +15564,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -15791,6 +15844,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -15952,6 +16006,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -16655,6 +16710,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -17288,6 +17344,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -17919,6 +17976,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -18639,6 +18697,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -19388,6 +19447,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -19868,6 +19928,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -20372,6 +20433,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -20831,6 +20893,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
@@ -108,6 +108,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -534,6 +535,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -939,6 +941,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1503,6 +1506,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -1713,6 +1717,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2092,6 +2097,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2333,6 +2339,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2513,6 +2520,7 @@ exports[`regression tests > can drag element that covers another element, while
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -2834,6 +2842,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3089,6 +3098,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3328,6 +3338,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3562,6 +3573,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -3819,6 +3831,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4130,6 +4143,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4591,6 +4605,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -4844,6 +4859,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5145,6 +5161,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5323,6 +5340,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5521,6 +5539,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -5916,6 +5935,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -6205,6 +6225,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7064,6 +7085,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7396,6 +7418,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7672,6 +7695,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -7905,6 +7929,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8141,6 +8166,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8319,6 +8345,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8497,6 +8524,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8702,6 +8730,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -8930,6 +8959,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9127,6 +9157,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9350,6 +9381,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9551,6 +9583,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9756,6 +9789,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -9955,6 +9989,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -10131,6 +10166,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -10327,6 +10363,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -10513,6 +10550,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11036,6 +11074,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11310,6 +11349,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11433,6 +11473,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11635,6 +11676,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -11954,6 +11996,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -12385,6 +12428,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13014,6 +13058,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13139,6 +13184,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -13797,6 +13843,7 @@ exports[`regression tests > switches from group of selected elements to another
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14133,6 +14180,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14363,6 +14411,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14486,6 +14535,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14874,6 +14924,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
@@ -14998,6 +15049,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
@@ -352,6 +352,10 @@ export interface AppState {
|
||||
| "elementBackground"
|
||||
| "elementStroke"
|
||||
| "fontFamily"
|
||||
| "compactTextProperties"
|
||||
| "compactStrokeStyles"
|
||||
| "compactOtherProperties"
|
||||
| "compactArrowProperties"
|
||||
| null;
|
||||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||||
openDialog:
|
||||
@@ -442,6 +446,9 @@ export interface AppState {
|
||||
// as elements are unlocked, we remove the groupId from the elements
|
||||
// and also remove groupId from this map
|
||||
lockedMultiSelections: { [groupId: string]: true };
|
||||
|
||||
/** properties sidebar mode - determines whether to show compact or complete sidebar */
|
||||
stylesPanelMode: "compact" | "full";
|
||||
}
|
||||
|
||||
export type SearchMatch = {
|
||||
|
||||
@@ -542,6 +542,7 @@ export const textWysiwyg = ({
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDestroyed = true;
|
||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||
@@ -625,14 +626,24 @@ export const textWysiwyg = ({
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
const isPropertiesContent =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
!!(target as Element).closest(".properties-content");
|
||||
const inShapeActionsMenu =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
(!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
|
||||
!!(target as Element).closest(".compact-shape-actions-island"));
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isPropertiesTrigger) {
|
||||
editable.focus();
|
||||
// If we interacted within shape actions menu or its popovers/triggers,
|
||||
// keep submit disabled and don't steal focus back to textarea.
|
||||
if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, re-enable submit on blur and refocus the editor.
|
||||
editable.onblur = handleSubmit;
|
||||
editable.focus();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -655,6 +666,7 @@ export const textWysiwyg = ({
|
||||
event.preventDefault();
|
||||
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
|
||||
}
|
||||
|
||||
temporarilyDisableSubmit();
|
||||
return;
|
||||
}
|
||||
@@ -662,15 +674,20 @@ export const textWysiwyg = ({
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
const isPropertiesContent =
|
||||
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||
!!(target as Element).closest(".properties-content");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(
|
||||
(event.target.closest(
|
||||
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
||||
) &&
|
||||
) ||
|
||||
event.target.closest(".compact-shape-actions-island")) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isPropertiesTrigger
|
||||
isPropertiesTrigger ||
|
||||
isPropertiesContent
|
||||
) {
|
||||
temporarilyDisableSubmit();
|
||||
} else if (
|
||||
|
||||
@@ -100,6 +100,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
||||
"open": false,
|
||||
"panels": 3,
|
||||
},
|
||||
"stylesPanelMode": "full",
|
||||
"suggestedBindings": [],
|
||||
"theme": "light",
|
||||
"toast": null,
|
||||
|
||||
Reference in New Issue
Block a user