mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
feat: multiplayer undo / redo (#7348)
This commit is contained in:
parent
5211b003b8
commit
530617be90
71 changed files with 34885 additions and 14877 deletions
|
@ -12,6 +12,7 @@
|
|||
font-size: 0.875rem !important;
|
||||
width: var(--lg-button-size);
|
||||
height: var(--lg-button-size);
|
||||
|
||||
svg {
|
||||
width: var(--lg-icon-size) !important;
|
||||
height: var(--lg-icon-size) !important;
|
||||
|
|
|
@ -183,6 +183,7 @@ import {
|
|||
ExcalidrawIframeElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
Ordered,
|
||||
OrderedExcalidrawElement,
|
||||
} from "../element/types";
|
||||
import { getCenter, getDistance } from "../gesture";
|
||||
import {
|
||||
|
@ -194,7 +195,7 @@ import {
|
|||
isSelectedViaGroup,
|
||||
selectGroupsForSelectedElements,
|
||||
} from "../groups";
|
||||
import History from "../history";
|
||||
import { History } from "../history";
|
||||
import { defaultLang, getLanguage, languages, setLanguage, t } from "../i18n";
|
||||
import {
|
||||
CODES,
|
||||
|
@ -278,11 +279,12 @@ import {
|
|||
muteFSAbortError,
|
||||
isTestEnv,
|
||||
easeOut,
|
||||
arrayToMap,
|
||||
updateStable,
|
||||
addEventListener,
|
||||
normalizeEOL,
|
||||
getDateTime,
|
||||
isShallowEqual,
|
||||
arrayToMap,
|
||||
} from "../utils";
|
||||
import {
|
||||
createSrcDoc,
|
||||
|
@ -410,6 +412,7 @@ import { ElementCanvasButton } from "./MagicButton";
|
|||
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
|
||||
import { EditorLocalStorage } from "../data/EditorLocalStorage";
|
||||
import FollowMode from "./FollowMode/FollowMode";
|
||||
import { IStore, Store, StoreAction } from "../store";
|
||||
import { AnimationFrameHandler } from "../animation-frame-handler";
|
||||
import { AnimatedTrail } from "../animated-trail";
|
||||
import { LaserTrails } from "../laser-trails";
|
||||
|
@ -540,6 +543,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
public library: AppClassProperties["library"];
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
public id: string;
|
||||
private store: IStore;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
|
@ -665,6 +669,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.canvas = document.createElement("canvas");
|
||||
this.rc = rough.canvas(this.canvas);
|
||||
this.renderer = new Renderer(this.scene);
|
||||
|
||||
this.store = new Store();
|
||||
this.history = new History();
|
||||
|
||||
if (excalidrawAPI) {
|
||||
const api: ExcalidrawImperativeAPI = {
|
||||
updateScene: this.updateScene,
|
||||
|
@ -714,10 +722,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
onSceneUpdated: this.onSceneUpdated,
|
||||
});
|
||||
this.history = new History();
|
||||
this.actionManager.registerAll(actions);
|
||||
|
||||
this.actionManager.registerAction(createUndoAction(this.history));
|
||||
this.actionManager.registerAction(createRedoAction(this.history));
|
||||
this.actionManager.registerAll(actions);
|
||||
this.actionManager.registerAction(
|
||||
createUndoAction(this.history, this.store),
|
||||
);
|
||||
this.actionManager.registerAction(
|
||||
createRedoAction(this.history, this.store),
|
||||
);
|
||||
}
|
||||
|
||||
private onWindowMessage(event: MessageEvent) {
|
||||
|
@ -2092,12 +2104,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||
if (shouldUpdateStrokeColor) {
|
||||
this.syncActionResult({
|
||||
appState: { ...this.state, currentItemStrokeColor: color },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
} else {
|
||||
this.syncActionResult({
|
||||
appState: { ...this.state, currentItemBackgroundColor: color },
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
|
@ -2111,6 +2123,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
return el;
|
||||
}),
|
||||
commitToStore: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
@ -2135,10 +2148,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
editingElement = element;
|
||||
}
|
||||
});
|
||||
this.scene.replaceAllElements(actionResult.elements);
|
||||
if (actionResult.commitToHistory) {
|
||||
this.history.resumeRecording();
|
||||
|
||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
||||
this.store.shouldUpdateSnapshot();
|
||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
this.scene.replaceAllElements(actionResult.elements);
|
||||
}
|
||||
|
||||
if (actionResult.files) {
|
||||
|
@ -2149,8 +2166,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
if (actionResult.appState || editingElement || this.state.contextMenu) {
|
||||
if (actionResult.commitToHistory) {
|
||||
this.history.resumeRecording();
|
||||
if (actionResult.storeAction === StoreAction.UPDATE) {
|
||||
this.store.shouldUpdateSnapshot();
|
||||
} else if (actionResult.storeAction === StoreAction.CAPTURE) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
let viewModeEnabled = actionResult?.appState?.viewModeEnabled || false;
|
||||
|
@ -2180,34 +2199,24 @@ class App extends React.Component<AppProps, AppState> {
|
|||
editingElement = null;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
(state) => {
|
||||
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
||||
// regarding the resulting type as not containing undefined
|
||||
// (which the following expression will never contain)
|
||||
return Object.assign(actionResult.appState || {}, {
|
||||
// NOTE this will prevent opening context menu using an action
|
||||
// or programmatically from the host, so it will need to be
|
||||
// rewritten later
|
||||
contextMenu: null,
|
||||
editingElement,
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize,
|
||||
theme,
|
||||
name,
|
||||
errorMessage,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
if (actionResult.syncHistory) {
|
||||
this.history.setCurrentState(
|
||||
this.state,
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
this.setState((state) => {
|
||||
// using Object.assign instead of spread to fool TS 4.2.2+ into
|
||||
// regarding the resulting type as not containing undefined
|
||||
// (which the following expression will never contain)
|
||||
return Object.assign(actionResult.appState || {}, {
|
||||
// NOTE this will prevent opening context menu using an action
|
||||
// or programmatically from the host, so it will need to be
|
||||
// rewritten later
|
||||
contextMenu: null,
|
||||
editingElement,
|
||||
viewModeEnabled,
|
||||
zenModeEnabled,
|
||||
gridSize,
|
||||
theme,
|
||||
name,
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
@ -2231,6 +2240,10 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.history.clear();
|
||||
};
|
||||
|
||||
private resetStore = () => {
|
||||
this.store.clear();
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets scene & history.
|
||||
* ! Do not use to clear scene user action !
|
||||
|
@ -2243,6 +2256,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
isLoading: opts?.resetLoadingState ? false : state.isLoading,
|
||||
theme: this.state.theme,
|
||||
}));
|
||||
this.resetStore();
|
||||
this.resetHistory();
|
||||
},
|
||||
);
|
||||
|
@ -2327,10 +2341,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||
// seems faster even in browsers that do fire the loadingdone event.
|
||||
this.fonts.loadFontsForElements(scene.elements);
|
||||
|
||||
this.resetStore();
|
||||
this.resetHistory();
|
||||
this.syncActionResult({
|
||||
...scene,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.UPDATE,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -2420,9 +2435,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||
configurable: true,
|
||||
value: this.history,
|
||||
},
|
||||
store: {
|
||||
configurable: true,
|
||||
value: this.store,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.store.onStoreIncrementEmitter.on((increment) => {
|
||||
this.history.record(increment.elementsChange, increment.appStateChange);
|
||||
});
|
||||
|
||||
this.scene.addCallback(this.onSceneUpdated);
|
||||
this.addEventListeners();
|
||||
|
||||
|
@ -2479,6 +2502,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.laserTrails.stop();
|
||||
this.eraserTrail.stop();
|
||||
this.onChangeEmitter.clear();
|
||||
this.store.onStoreIncrementEmitter.clear();
|
||||
ShapeCache.destroy();
|
||||
SnapCache.destroy();
|
||||
clearTimeout(touchTimeout);
|
||||
|
@ -2623,7 +2647,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||
componentDidUpdate(prevProps: AppProps, prevState: AppState) {
|
||||
this.updateEmbeddables();
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
const elementsMap = this.scene.getNonDeletedElementsMap();
|
||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
if (!this.state.showWelcomeScreen && !elements.length) {
|
||||
this.setState({ showWelcomeScreen: true });
|
||||
|
@ -2739,7 +2764,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.state.editingLinearElement &&
|
||||
!this.state.selectedElementIds[this.state.editingLinearElement.elementId]
|
||||
) {
|
||||
// defer so that the commitToHistory flag isn't reset via current update
|
||||
// defer so that the storeAction flag isn't reset via current update
|
||||
setTimeout(() => {
|
||||
// execute only if the condition still holds when the deferred callback
|
||||
// executes (it can be scheduled multiple times depending on how
|
||||
|
@ -2778,13 +2803,14 @@ class App extends React.Component<AppProps, AppState> {
|
|||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiElement,
|
||||
-1,
|
||||
elementsMap,
|
||||
nonDeletedElementsMap,
|
||||
),
|
||||
),
|
||||
this,
|
||||
);
|
||||
}
|
||||
this.history.record(this.state, elements);
|
||||
|
||||
this.store.capture(elementsMap, this.state);
|
||||
|
||||
// Do not notify consumers if we're still loading the scene. Among other
|
||||
// potential issues, this fixes a case where the tab isn't focused during
|
||||
|
@ -3154,7 +3180,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.files = { ...this.files, ...opts.files };
|
||||
}
|
||||
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
@ -3389,7 +3415,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
PLAIN_PASTE_TOAST_SHOWN = true;
|
||||
}
|
||||
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
setAppState: React.Component<any, AppState>["setState"] = (
|
||||
|
@ -3657,10 +3683,51 @@ class App extends React.Component<AppProps, AppState> {
|
|||
elements?: SceneData["elements"];
|
||||
appState?: Pick<AppState, K> | null;
|
||||
collaborators?: SceneData["collaborators"];
|
||||
commitToHistory?: SceneData["commitToHistory"];
|
||||
commitToStore?: SceneData["commitToStore"];
|
||||
}) => {
|
||||
if (sceneData.commitToHistory) {
|
||||
this.history.resumeRecording();
|
||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
||||
|
||||
if (sceneData.commitToStore) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
if (sceneData.elements || sceneData.appState) {
|
||||
let nextCommittedAppState = this.state;
|
||||
let nextCommittedElements: Map<string, OrderedExcalidrawElement>;
|
||||
|
||||
if (sceneData.appState) {
|
||||
nextCommittedAppState = {
|
||||
...this.state,
|
||||
...sceneData.appState, // Here we expect just partial appState
|
||||
};
|
||||
}
|
||||
|
||||
const prevElements = this.scene.getElementsIncludingDeleted();
|
||||
|
||||
if (sceneData.elements) {
|
||||
/**
|
||||
* We need to schedule a snapshot update, as in case `commitToStore` is false (i.e. remote update),
|
||||
* as it's essential for computing local changes after the async action is completed (i.e. not to include remote changes in the diff).
|
||||
*
|
||||
* This is also a breaking change for all local `updateScene` calls without set `commitToStore` to true,
|
||||
* as it makes such updates impossible to undo (previously they were undone coincidentally with the switch to the whole snapshot captured by the history).
|
||||
*
|
||||
* WARN: be careful here as moving it elsewhere could break the history for remote client without noticing
|
||||
* - we need to find a way to test two concurrent client updates simultaneously, while having access to both stores & histories.
|
||||
*/
|
||||
this.store.shouldUpdateSnapshot();
|
||||
|
||||
// TODO#7348: deprecate once exchanging just store increments between clients
|
||||
nextCommittedElements = this.store.ignoreUncomittedElements(
|
||||
arrayToMap(prevElements),
|
||||
arrayToMap(nextElements),
|
||||
);
|
||||
} else {
|
||||
nextCommittedElements = arrayToMap(prevElements);
|
||||
}
|
||||
|
||||
// WARN: Performs deep clone of changed elements, for ephemeral remote updates (i.e. remote dragging, resizing, drawing) we might consider doing something smarter
|
||||
this.store.capture(nextCommittedElements, nextCommittedAppState);
|
||||
}
|
||||
|
||||
if (sceneData.appState) {
|
||||
|
@ -3668,7 +3735,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
if (sceneData.elements) {
|
||||
this.scene.replaceAllElements(sceneData.elements);
|
||||
this.scene.replaceAllElements(nextElements);
|
||||
}
|
||||
|
||||
if (sceneData.collaborators) {
|
||||
|
@ -3896,7 +3963,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.state.editingLinearElement.elementId !==
|
||||
selectedElements[0].id
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(
|
||||
selectedElement,
|
||||
|
@ -4308,7 +4375,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
]);
|
||||
}
|
||||
if (!isDeleted || isExistingElement) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
this.setState({
|
||||
|
@ -4793,7 +4860,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
(!this.state.editingLinearElement ||
|
||||
this.state.editingLinearElement.elementId !== selectedElements[0].id)
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState({
|
||||
editingLinearElement: new LinearElementEditor(selectedElements[0]),
|
||||
});
|
||||
|
@ -4818,6 +4885,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
getSelectedGroupIdForElement(hitElement, this.state.selectedGroupIds);
|
||||
|
||||
if (selectedGroupId) {
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.setState((prevState) => ({
|
||||
...prevState,
|
||||
...selectGroupsForSelectedElements(
|
||||
|
@ -6300,7 +6368,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
const ret = LinearElementEditor.handlePointerDown(
|
||||
event,
|
||||
this.state,
|
||||
this.history,
|
||||
this.store,
|
||||
pointerDownState.origin,
|
||||
linearElementEditor,
|
||||
this,
|
||||
|
@ -7848,7 +7916,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
if (isLinearElement(draggingElement)) {
|
||||
if (draggingElement!.points.length > 1) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
const pointerCoords = viewportCoordsToSceneCoords(
|
||||
childEvent,
|
||||
|
@ -7917,14 +7985,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||
isInvisiblySmallElement(draggingElement)
|
||||
) {
|
||||
// remove invisible element which was added in onPointerDown
|
||||
this.scene.replaceAllElements(
|
||||
this.scene
|
||||
// update the store snapshot, so that invisible elements are not captured by the store
|
||||
this.updateScene({
|
||||
elements: this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((el) => el.id !== draggingElement.id),
|
||||
);
|
||||
this.setState({
|
||||
draggingElement: null,
|
||||
appState: {
|
||||
draggingElement: null,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -8086,15 +8156,16 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
if (resizingElement) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
if (resizingElement && isInvisiblySmallElement(resizingElement)) {
|
||||
this.scene.replaceAllElements(
|
||||
this.scene
|
||||
// update the store snapshot, so that invisible elements are not captured by the store
|
||||
this.updateScene({
|
||||
elements: this.scene
|
||||
.getElementsIncludingDeleted()
|
||||
.filter((el) => el.id !== resizingElement.id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// handle frame membership for resizing frames and/or selected elements
|
||||
|
@ -8395,9 +8466,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||
|
||||
if (
|
||||
activeTool.type !== "selection" ||
|
||||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state)
|
||||
isSomeElementSelected(this.scene.getNonDeletedElements(), this.state) ||
|
||||
!isShallowEqual(
|
||||
this.state.previousSelectedElementIds,
|
||||
this.state.selectedElementIds,
|
||||
)
|
||||
) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
}
|
||||
|
||||
if (pointerDownState.drag.hasOccurred || isResizing || isRotating) {
|
||||
|
@ -8475,7 +8550,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
this.elementsPendingErasure = new Set();
|
||||
|
||||
if (didChange) {
|
||||
this.history.resumeRecording();
|
||||
this.store.shouldCaptureIncrement();
|
||||
this.scene.replaceAllElements(elements);
|
||||
}
|
||||
};
|
||||
|
@ -9038,7 +9113,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
isLoading: false,
|
||||
},
|
||||
replaceFiles: true,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
return;
|
||||
} catch (error: any) {
|
||||
|
@ -9118,12 +9193,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||
) => {
|
||||
file = await normalizeFile(file);
|
||||
try {
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
let ret;
|
||||
try {
|
||||
ret = await loadSceneOrLibraryFromBlob(
|
||||
file,
|
||||
this.state,
|
||||
this.scene.getElementsIncludingDeleted(),
|
||||
elements,
|
||||
fileHandle,
|
||||
);
|
||||
} catch (error: any) {
|
||||
|
@ -9152,6 +9228,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||
}
|
||||
|
||||
if (ret.type === MIME_TYPES.excalidraw) {
|
||||
// Restore the fractional indices by mutating elements and update the
|
||||
// store snapshot, otherwise we would end up with duplicate indices
|
||||
syncInvalidIndices(elements.concat(ret.data.elements));
|
||||
this.store.snapshot = this.store.snapshot.clone(
|
||||
arrayToMap(elements),
|
||||
this.state,
|
||||
);
|
||||
this.setState({ isLoading: true });
|
||||
this.syncActionResult({
|
||||
...ret.data,
|
||||
|
@ -9160,7 +9243,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||
isLoading: false,
|
||||
},
|
||||
replaceFiles: true,
|
||||
commitToHistory: true,
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
});
|
||||
} else if (ret.type === MIME_TYPES.excalidrawlib) {
|
||||
await this.library
|
||||
|
@ -9770,6 +9853,7 @@ declare global {
|
|||
setState: React.Component<any, AppState>["setState"];
|
||||
app: InstanceType<typeof App>;
|
||||
history: History;
|
||||
store: Store;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
|||
hidden?: boolean;
|
||||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
isLoading?: boolean;
|
||||
|
@ -124,10 +125,14 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||
type={type}
|
||||
onClick={onClick}
|
||||
ref={innerRef}
|
||||
disabled={isLoading || props.isLoading}
|
||||
disabled={isLoading || props.isLoading || !!props.disabled}
|
||||
>
|
||||
{(props.icon || props.label) && (
|
||||
<div className="ToolIcon__icon" aria-hidden="true">
|
||||
<div
|
||||
className="ToolIcon__icon"
|
||||
aria-hidden="true"
|
||||
aria-disabled={!!props.disabled}
|
||||
>
|
||||
{props.icon || props.label}
|
||||
{props.keyBindingLabel && (
|
||||
<span className="ToolIcon__keybinding">
|
||||
|
|
|
@ -77,8 +77,7 @@
|
|||
}
|
||||
|
||||
.ToolIcon_type_button,
|
||||
.Modal .ToolIcon_type_button,
|
||||
.ToolIcon_type_button {
|
||||
.Modal .ToolIcon_type_button {
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 0;
|
||||
|
@ -101,6 +100,22 @@
|
|||
background-color: var(--button-gray-3);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
|
||||
&:active,
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
background-color: initial;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&--show {
|
||||
visibility: visible;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue