Merge remote-tracking branch 'origin/master' into zsviczian-stickynote
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
excalidraw:
|
||||
build:
|
||||
|
||||
@@ -119,6 +119,7 @@ import {
|
||||
LibraryIndexedDBAdapter,
|
||||
LibraryLocalStorageMigrationAdapter,
|
||||
LocalData,
|
||||
localStorageQuotaExceededAtom,
|
||||
} from "./data/LocalData";
|
||||
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
||||
import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
||||
@@ -727,6 +728,8 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
const localStorageQuotaExceeded = useAtomValue(localStorageQuotaExceededAtom);
|
||||
|
||||
const onCollabDialogOpen = useCallback(
|
||||
() => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
|
||||
[setShareDialogState],
|
||||
@@ -901,10 +904,15 @@ const ExcalidrawWrapper = () => {
|
||||
|
||||
<TTDDialogTrigger />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
<div className="alertalert--warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{localStorageQuotaExceeded && (
|
||||
<div className="alert alert--danger">
|
||||
{t("alerts.localStorageQuotaExceeded")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
|
||||
@@ -530,7 +530,10 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!existingRoomLinkData) {
|
||||
if (existingRoomLinkData) {
|
||||
// when joining existing room, don't merge it with current scene data
|
||||
this.excalidrawAPI.resetScene();
|
||||
} else {
|
||||
const elements = this.excalidrawAPI.getSceneElements().map((element) => {
|
||||
if (isImageElement(element) && element.status === "saved") {
|
||||
return newElementWith(element, { status: "pending" });
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
get,
|
||||
} from "idb-keyval";
|
||||
|
||||
import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
|
||||
|
||||
import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
|
||||
import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
|
||||
import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
|
||||
@@ -45,6 +47,8 @@ import { updateBrowserStateVersion } from "./tabSync";
|
||||
|
||||
const filesStore = createStore("files-db", "files-store");
|
||||
|
||||
export const localStorageQuotaExceededAtom = atom(false);
|
||||
|
||||
class LocalFileManager extends FileManager {
|
||||
clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
|
||||
await entries(filesStore).then((entries) => {
|
||||
@@ -69,6 +73,9 @@ const saveDataStateToLocalStorage = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const localStorageQuotaExceeded = appJotaiStore.get(
|
||||
localStorageQuotaExceededAtom,
|
||||
);
|
||||
try {
|
||||
const _appState = clearAppStateForLocalStorage(appState);
|
||||
|
||||
@@ -88,12 +95,22 @@ const saveDataStateToLocalStorage = (
|
||||
JSON.stringify(_appState),
|
||||
);
|
||||
updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
|
||||
if (localStorageQuotaExceeded) {
|
||||
appJotaiStore.set(localStorageQuotaExceededAtom, false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Unable to access window.localStorage
|
||||
console.error(error);
|
||||
if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
|
||||
appJotaiStore.set(localStorageQuotaExceededAtom, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isQuotaExceededError = (error: any) => {
|
||||
return error instanceof DOMException && error.name === "QuotaExceededError";
|
||||
};
|
||||
|
||||
type SavingLockTypes = "collaboration";
|
||||
|
||||
export class LocalData {
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
.collab-offline-warning {
|
||||
.alert {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 6.5rem;
|
||||
@@ -69,10 +69,18 @@
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
border-radius: var(--border-radius-md);
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
z-index: 6;
|
||||
white-space: pre;
|
||||
|
||||
&--warning {
|
||||
background-color: var(--color-warning);
|
||||
color: var(--color-text-warning);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: var(--color-danger-dark);
|
||||
color: var(--color-danger-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,18 @@ export class BinaryHeap<T> {
|
||||
|
||||
sinkDown(idx: number) {
|
||||
const node = this.content[idx];
|
||||
const nodeScore = this.scoreFunction(node);
|
||||
while (idx > 0) {
|
||||
const parentN = ((idx + 1) >> 1) - 1;
|
||||
const parent = this.content[parentN];
|
||||
if (this.scoreFunction(node) < this.scoreFunction(parent)) {
|
||||
this.content[parentN] = node;
|
||||
if (nodeScore < this.scoreFunction(parent)) {
|
||||
this.content[idx] = parent;
|
||||
idx = parentN; // TODO: Optimize
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
bubbleUp(idx: number) {
|
||||
@@ -24,35 +25,39 @@ export class BinaryHeap<T> {
|
||||
const score = this.scoreFunction(node);
|
||||
|
||||
while (true) {
|
||||
const child2N = (idx + 1) << 1;
|
||||
const child1N = child2N - 1;
|
||||
let swap = null;
|
||||
let child1Score = 0;
|
||||
const child1N = ((idx + 1) << 1) - 1;
|
||||
const child2N = child1N + 1;
|
||||
let smallestIdx = idx;
|
||||
let smallestScore = score;
|
||||
|
||||
// Check left child
|
||||
if (child1N < length) {
|
||||
const child1 = this.content[child1N];
|
||||
child1Score = this.scoreFunction(child1);
|
||||
if (child1Score < score) {
|
||||
swap = child1N;
|
||||
const child1Score = this.scoreFunction(this.content[child1N]);
|
||||
if (child1Score < smallestScore) {
|
||||
smallestIdx = child1N;
|
||||
smallestScore = child1Score;
|
||||
}
|
||||
}
|
||||
|
||||
// Check right child
|
||||
if (child2N < length) {
|
||||
const child2 = this.content[child2N];
|
||||
const child2Score = this.scoreFunction(child2);
|
||||
if (child2Score < (swap === null ? score : child1Score)) {
|
||||
swap = child2N;
|
||||
const child2Score = this.scoreFunction(this.content[child2N]);
|
||||
if (child2Score < smallestScore) {
|
||||
smallestIdx = child2N;
|
||||
}
|
||||
}
|
||||
|
||||
if (swap !== null) {
|
||||
this.content[idx] = this.content[swap];
|
||||
this.content[swap] = node;
|
||||
idx = swap; // TODO: Optimize
|
||||
} else {
|
||||
if (smallestIdx === idx) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Move the smaller child up, continue finding position for node
|
||||
this.content[idx] = this.content[smallestIdx];
|
||||
idx = smallestIdx;
|
||||
}
|
||||
|
||||
// Place node in its final position
|
||||
this.content[idx] = node;
|
||||
}
|
||||
|
||||
push(node: T) {
|
||||
|
||||
@@ -125,6 +125,7 @@ export const ENV = {
|
||||
};
|
||||
|
||||
export const CLASSES = {
|
||||
SIDEBAR: "sidebar",
|
||||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
ZOOM_ACTIONS: "zoom-actions",
|
||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||
@@ -266,7 +267,10 @@ export const STRING_MIME_TYPES = {
|
||||
json: "application/json",
|
||||
// excalidraw data
|
||||
excalidraw: "application/vnd.excalidraw+json",
|
||||
// LEGACY: fully-qualified library JSON data
|
||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||
// list of excalidraw library item ids
|
||||
excalidrawlibIds: "application/vnd.excalidrawlib.ids+json",
|
||||
} as const;
|
||||
|
||||
export const MIME_TYPES = {
|
||||
@@ -351,6 +355,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
||||
// mobile: up to 699px
|
||||
export const MQ_MAX_MOBILE = 599;
|
||||
|
||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||
|
||||
// tablets
|
||||
export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones)
|
||||
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||
@@ -541,3 +548,8 @@ export enum UserIdleState {
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
||||
export const DOUBLE_TAP_POSITION_THRESHOLD = 35;
|
||||
|
||||
// glass background for mobile action buttons
|
||||
export const MOBILE_ACTION_BUTTON_BG = {
|
||||
background: "var(--mobile-action-button-bg)",
|
||||
} as const;
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ENV,
|
||||
FONT_FAMILY,
|
||||
getFontFamilyFallbacks,
|
||||
isDarwin,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||
@@ -93,7 +92,8 @@ export const isWritableElement = (
|
||||
(target instanceof HTMLInputElement &&
|
||||
(target.type === "text" ||
|
||||
target.type === "number" ||
|
||||
target.type === "password"));
|
||||
target.type === "password" ||
|
||||
target.type === "search"));
|
||||
|
||||
export const getFontFamilyString = ({
|
||||
fontFamily,
|
||||
@@ -121,6 +121,11 @@ export const getFontString = ({
|
||||
return `${fontSize}px ${getFontFamilyString({ fontFamily })}` as FontString;
|
||||
};
|
||||
|
||||
/** executes callback in the frame that's after the current one */
|
||||
export const nextAnimationFrame = async (cb: () => any) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(cb));
|
||||
};
|
||||
|
||||
export const debounce = <T extends any[]>(
|
||||
fn: (...args: T) => void,
|
||||
timeout: number,
|
||||
@@ -420,19 +425,6 @@ export const allowFullScreen = () =>
|
||||
|
||||
export const exitFullScreen = () => document.exitFullscreen();
|
||||
|
||||
export const getShortcutKey = (shortcut: string): string => {
|
||||
shortcut = shortcut
|
||||
.replace(/\bAlt\b/i, "Alt")
|
||||
.replace(/\bShift\b/i, "Shift")
|
||||
.replace(/\b(Enter|Return)\b/i, "Enter");
|
||||
if (isDarwin) {
|
||||
return shortcut
|
||||
.replace(/\bCtrlOrCmd\b/gi, "Cmd")
|
||||
.replace(/\bAlt\b/i, "Option");
|
||||
}
|
||||
return shortcut.replace(/\bCtrlOrCmd\b/gi, "Ctrl");
|
||||
};
|
||||
|
||||
export const viewportCoordsToSceneCoords = (
|
||||
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||
{
|
||||
|
||||
@@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = (
|
||||
intersector,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
).sort(pointDistanceSq)[0];
|
||||
|
||||
if (!intersection) {
|
||||
const anotherPoint = pointFrom<GlobalPoint>(
|
||||
!isHorizontal ? center[0] : snapPoint[0],
|
||||
isHorizontal ? center[1] : snapPoint[1],
|
||||
);
|
||||
const anotherIntersector = lineSegment(
|
||||
anotherPoint,
|
||||
pointFromVector(
|
||||
vectorScale(
|
||||
vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)),
|
||||
Math.max(bindableElement.width, bindableElement.height) * 2,
|
||||
),
|
||||
anotherPoint,
|
||||
),
|
||||
);
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
elementsMap,
|
||||
anotherIntersector,
|
||||
FIXED_BINDING_DISTANCE,
|
||||
).sort(pointDistanceSq)[0];
|
||||
}
|
||||
} else {
|
||||
intersection = intersectElementWithLineSegment(
|
||||
bindableElement,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
isBoundToContainer,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
|
||||
@@ -321,19 +322,42 @@ export const getElementLineSegments = (
|
||||
|
||||
if (shape.type === "polycurve") {
|
||||
const curves = shape.data;
|
||||
const points = curves
|
||||
.map((curve) => pointsOnBezierCurves(curve, 10))
|
||||
.flat();
|
||||
let i = 0;
|
||||
const pointsOnCurves = curves.map((curve) =>
|
||||
pointsOnBezierCurves(curve, 10),
|
||||
);
|
||||
|
||||
const segments: LineSegment<GlobalPoint>[] = [];
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
|
||||
if (
|
||||
(isLineElement(element) && !element.polygon) ||
|
||||
isArrowElement(element)
|
||||
) {
|
||||
for (const points of pointsOnCurves) {
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const points = pointsOnCurves.flat();
|
||||
let i = 0;
|
||||
|
||||
while (i < points.length - 1) {
|
||||
segments.push(
|
||||
lineSegment(
|
||||
pointFrom(points[i][0], points[i][1]),
|
||||
pointFrom(points[i + 1][0], points[i + 1][1]),
|
||||
),
|
||||
);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
|
||||
@@ -10,7 +10,13 @@ export const hasBackground = (type: ElementOrToolType) =>
|
||||
type === "freedraw";
|
||||
|
||||
export const hasStrokeColor = (type: ElementOrToolType) =>
|
||||
type !== "image" && type !== "frame" && type !== "magicframe";
|
||||
type === "rectangle" ||
|
||||
type === "ellipse" ||
|
||||
type === "diamond" ||
|
||||
type === "freedraw" ||
|
||||
type === "arrow" ||
|
||||
type === "line" ||
|
||||
type === "text";
|
||||
|
||||
export const hasStrokeWidth = (type: ElementOrToolType) =>
|
||||
type === "rectangle" ||
|
||||
|
||||
@@ -29,6 +29,9 @@ export const hashElementsVersion = (elements: ElementsMapOrArray): number => {
|
||||
|
||||
// string hash function (using djb2). Not cryptographically secure, use only
|
||||
// for versioning and such.
|
||||
// note: hashes individual code units (not code points),
|
||||
// but for hashing purposes this is fine as it iterates through every code unit
|
||||
// (as such, no need to encode to byte string first)
|
||||
export const hashString = (s: string): number => {
|
||||
let hash: number = 5381;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
isAndroid,
|
||||
isIOS,
|
||||
isMobileOrTablet,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { pointFrom, pointRotateRads } from "@excalidraw/math";
|
||||
@@ -326,7 +327,7 @@ export const getTransformHandles = (
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldShowBoundingBox = (
|
||||
export const hasBoundingBox = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: InteractiveCanvasAppState,
|
||||
) => {
|
||||
@@ -345,5 +346,7 @@ export const shouldShowBoundingBox = (
|
||||
return true;
|
||||
}
|
||||
|
||||
return element.points.length > 2;
|
||||
// on mobile/tablet we currently don't show bbox because of resize issues
|
||||
// (also prob best for simplicity's sake)
|
||||
return element.points.length > 2 && !isMobileOrTablet();
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import { API } from "@excalidraw/excalidraw/tests/helpers/api";
|
||||
import { UI, Pointer, Keyboard } from "@excalidraw/excalidraw/tests/helpers/ui";
|
||||
import { fireEvent, render } from "@excalidraw/excalidraw/tests/test-utils";
|
||||
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { getTransformHandles } from "../src/transformHandles";
|
||||
import {
|
||||
getTextEditor,
|
||||
@@ -413,16 +415,12 @@ describe("element binding", () => {
|
||||
expect(arrow.endBinding?.elementId).toBe(rectRight.id);
|
||||
|
||||
// Drag arrow off of bound rectangle range
|
||||
const handles = getTransformHandles(
|
||||
const [elX, elY] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
arrow,
|
||||
h.state.zoom,
|
||||
arrayToMap(h.elements),
|
||||
"mouse",
|
||||
).se!;
|
||||
|
||||
-1,
|
||||
h.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
Keyboard.keyDown(KEYS.CTRL_OR_CMD);
|
||||
const elX = handles[0] + handles[2] / 2;
|
||||
const elY = handles[1] + handles[3] / 2;
|
||||
mouse.downAt(elX, elY);
|
||||
mouse.moveTo(300, 400);
|
||||
mouse.up();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
import { KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { alignElements } from "@excalidraw/element";
|
||||
|
||||
@@ -30,6 +30,8 @@ import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, UIAppState } from "../types";
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
getOriginalContainerHeightFromCache,
|
||||
isBoundToContainer,
|
||||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "@excalidraw/element";
|
||||
@@ -225,7 +226,9 @@ export const actionWrapTextInContainer = register({
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const someTextElements = selectedElements.some((el) => isTextElement(el));
|
||||
const someTextElements = selectedElements.some(
|
||||
(el) => isTextElement(el) && !isBoundToContainer(el),
|
||||
);
|
||||
return selectedElements.length > 0 && someTextElements;
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
@@ -234,7 +237,7 @@ export const actionWrapTextInContainer = register({
|
||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
if (isTextElement(textElement) && !isBoundToContainer(textElement)) {
|
||||
const container = newElement({
|
||||
type: "rectangle",
|
||||
backgroundColor: appState.currentItemBackgroundColor,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
MIN_ZOOM,
|
||||
THEME,
|
||||
ZOOM_STEP,
|
||||
getShortcutKey,
|
||||
updateActiveTool,
|
||||
CODES,
|
||||
KEYS,
|
||||
@@ -46,6 +45,7 @@ import { t } from "../i18n";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -122,7 +122,10 @@ export const actionClearCanvas = register({
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
? { ...appState.activeTool, type: app.defaultSelectionTool }
|
||||
? {
|
||||
...appState.activeTool,
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}
|
||||
: appState.activeTool,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
@@ -501,7 +504,7 @@ export const actionToggleEraserTool = register({
|
||||
if (isEraserActive(appState)) {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.defaultSelectionTool,
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
@@ -532,7 +535,7 @@ export const actionToggleLassoTool = register({
|
||||
icon: LassoIcon,
|
||||
trackEvent: { category: "toolbar" },
|
||||
predicate: (elements, appState, props, app) => {
|
||||
return app.defaultSelectionTool !== "lasso";
|
||||
return app.state.preferredSelectionTool.type !== "lasso";
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
let activeTool: AppState["activeTool"];
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { KEYS, updateActiveTool } from "@excalidraw/common";
|
||||
import {
|
||||
KEYS,
|
||||
MOBILE_ACTION_BUTTON_BG,
|
||||
updateActiveTool,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { fixBindingsAfterDeletion } from "@excalidraw/element";
|
||||
@@ -299,7 +303,7 @@ export const actionDeleteSelected = register({
|
||||
appState: {
|
||||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
@@ -323,7 +327,15 @@ export const actionDeleteSelected = register({
|
||||
title={t("labels.delete")}
|
||||
aria-label={t("labels.delete")}
|
||||
onClick={() => updateData(null)}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
disabled={
|
||||
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||
}
|
||||
style={{
|
||||
...(appState.stylesPanelMode === "mobile" &&
|
||||
appState.openPopup !== "compactOtherProperties"
|
||||
? MOBILE_ACTION_BUTTON_BG
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { isFrameLikeElement } from "@excalidraw/element";
|
||||
|
||||
import { CODES, KEYS, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
import { CODES, KEYS, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { updateFrameMembershipOfSelectedElements } from "@excalidraw/element";
|
||||
|
||||
@@ -26,6 +26,8 @@ import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
DEFAULT_GRID_SIZE,
|
||||
KEYS,
|
||||
MOBILE_ACTION_BUTTON_BG,
|
||||
arrayToMap,
|
||||
getShortcutKey,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
@@ -25,6 +25,7 @@ import { DuplicateIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
@@ -115,7 +116,15 @@ export const actionDuplicateSelection = register({
|
||||
)}`}
|
||||
aria-label={t("labels.duplicateSelection")}
|
||||
onClick={() => updateData(null)}
|
||||
visible={isSomeElementSelected(getNonDeletedElements(elements), appState)}
|
||||
disabled={
|
||||
!isSomeElementSelected(getNonDeletedElements(elements), appState)
|
||||
}
|
||||
style={{
|
||||
...(appState.stylesPanelMode === "mobile" &&
|
||||
appState.openPopup !== "compactOtherProperties"
|
||||
? MOBILE_ACTION_BUTTON_BG
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -261,13 +261,13 @@ export const actionFinalize = register({
|
||||
if (appState.activeTool.type === "eraser") {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
...(appState.activeTool.lastActiveTool || {
|
||||
type: app.defaultSelectionTool,
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
}),
|
||||
lastActiveToolBeforeEraser: null,
|
||||
});
|
||||
} else {
|
||||
activeTool = updateActiveTool(appState, {
|
||||
type: app.defaultSelectionTool,
|
||||
type: app.state.preferredSelectionTool.type,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
replaceAllElementsInFrame,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { KEYS, randomId, arrayToMap, getShortcutKey } from "@excalidraw/common";
|
||||
import { KEYS, randomId, arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
@@ -43,6 +43,8 @@ import { t } from "../i18n";
|
||||
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState } from "../types";
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { isWindows, KEYS, matchKey, arrayToMap } from "@excalidraw/common";
|
||||
import {
|
||||
isWindows,
|
||||
KEYS,
|
||||
matchKey,
|
||||
arrayToMap,
|
||||
MOBILE_ACTION_BUTTON_BG,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
@@ -67,7 +73,7 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
),
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && matchKey(event, KEYS.Z) && !event.shiftKey,
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(
|
||||
@@ -85,6 +91,11 @@ export const createUndoAction: ActionCreator = (history) => ({
|
||||
size={data?.size || "medium"}
|
||||
disabled={isUndoStackEmpty}
|
||||
data-testid="button-undo"
|
||||
style={{
|
||||
...(appState.stylesPanelMode === "mobile"
|
||||
? MOBILE_ACTION_BUTTON_BG
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -103,7 +114,7 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
keyTest: (event) =>
|
||||
(event[KEYS.CTRL_OR_CMD] && event.shiftKey && matchKey(event, KEYS.Z)) ||
|
||||
(isWindows && event.ctrlKey && !event.shiftKey && matchKey(event, KEYS.Y)),
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(
|
||||
@@ -121,6 +132,11 @@ export const createRedoAction: ActionCreator = (history) => ({
|
||||
size={data?.size || "medium"}
|
||||
disabled={isRedoStackEmpty}
|
||||
data-testid="button-redo"
|
||||
style={{
|
||||
...(appState.stylesPanelMode === "mobile"
|
||||
? MOBILE_ACTION_BUTTON_BG
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isEmbeddableElement } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ToolButton } from "../components/ToolButton";
|
||||
import { getContextMenuLabel } from "../components/hyperlink/Hyperlink";
|
||||
import { LinkIcon } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -1,65 +1,11 @@
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { HamburgerMenuIcon, HelpIconThin, palette } from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { HelpIconThin } from "../components/icons";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleCanvasMenu = register({
|
||||
name: "toggleCanvasMenu",
|
||||
label: "buttons.menu",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
}),
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={HamburgerMenuIcon}
|
||||
aria-label={t("buttons.menu")}
|
||||
onClick={updateData}
|
||||
selected={appState.openMenu === "canvas"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionToggleEditMenu = register({
|
||||
name: "toggleEditMenu",
|
||||
label: "buttons.edit",
|
||||
trackEvent: { category: "menu" },
|
||||
perform: (_elements, appState) => ({
|
||||
appState: {
|
||||
...appState,
|
||||
openMenu: appState.openMenu === "shape" ? null : "shape",
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
}),
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
visible={showSelectedShapeActions(
|
||||
appState,
|
||||
getNonDeletedElements(elements),
|
||||
)}
|
||||
type="button"
|
||||
icon={palette}
|
||||
aria-label={t("buttons.edit")}
|
||||
onClick={updateData}
|
||||
selected={appState.openMenu === "shape"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
name: "toggleShortcuts",
|
||||
label: "welcomeScreen.defaults.helpHint",
|
||||
@@ -79,6 +25,8 @@ export const actionShortcuts = register({
|
||||
: {
|
||||
name: "help",
|
||||
},
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
randomInteger,
|
||||
arrayToMap,
|
||||
getFontFamilyString,
|
||||
getShortcutKey,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
reduceToCommonValue,
|
||||
@@ -149,6 +148,8 @@ import {
|
||||
restoreCaretPosition,
|
||||
} from "../hooks/useTextEditorFocus";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
@@ -355,7 +356,10 @@ export const actionChangeStrokeColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
compactMode={
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -435,7 +439,10 @@ export const actionChangeBackgroundColor = register({
|
||||
elements={elements}
|
||||
appState={appState}
|
||||
updateData={updateData}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
compactMode={
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
@@ -538,9 +545,7 @@ export const actionChangeStrokeWidth = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
)}
|
||||
<legend>{t("labels.strokeWidth")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="stroke-width"
|
||||
@@ -597,9 +602,7 @@ export const actionChangeSloppiness = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
)}
|
||||
<legend>{t("labels.sloppiness")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="sloppiness"
|
||||
@@ -652,9 +655,7 @@ export const actionChangeStrokeStyle = register({
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||
<fieldset>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
)}
|
||||
<legend>{t("labels.strokeStyle")}</legend>
|
||||
<div className="buttonList">
|
||||
<RadioSelection
|
||||
group="strokeStyle"
|
||||
@@ -783,7 +784,8 @@ export const actionChangeFontSize = register({
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
@@ -1047,7 +1049,7 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, app, updateData, data }) => {
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
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
|
||||
@@ -1124,7 +1126,7 @@ export const actionChangeFontFamily = register({
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<>
|
||||
{appState.stylesPanelMode === "full" && (
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
)}
|
||||
@@ -1132,7 +1134,7 @@ export const actionChangeFontFamily = register({
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
compactMode={appState.stylesPanelMode === "compact"}
|
||||
compactMode={appState.stylesPanelMode !== "full"}
|
||||
onSelect={(fontFamily) => {
|
||||
withCaretPositionPreservation(
|
||||
() => {
|
||||
@@ -1144,7 +1146,8 @@ export const actionChangeFontFamily = register({
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
},
|
||||
appState.stylesPanelMode === "compact",
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile",
|
||||
!!appState.editingTextElement,
|
||||
);
|
||||
}}
|
||||
@@ -1220,7 +1223,8 @@ export const actionChangeFontFamily = register({
|
||||
|
||||
// Refocus text editor when font picker closes if we were editing text
|
||||
if (
|
||||
appState.stylesPanelMode === "compact" &&
|
||||
(appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile") &&
|
||||
appState.editingTextElement
|
||||
) {
|
||||
restoreCaretPosition(null); // Just refocus without saved position
|
||||
@@ -1228,7 +1232,7 @@ export const actionChangeFontFamily = register({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -1321,7 +1325,8 @@ export const actionChangeTextAlign = register({
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
@@ -1420,7 +1425,8 @@ export const actionChangeVerticalAlign = register({
|
||||
onChange={(value) => {
|
||||
withCaretPositionPreservation(
|
||||
() => updateData(value),
|
||||
appState.stylesPanelMode === "compact",
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile",
|
||||
!!appState.editingTextElement,
|
||||
data?.onPreventClose,
|
||||
);
|
||||
@@ -1834,8 +1840,8 @@ export const actionChangeArrowProperties = register({
|
||||
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||
return (
|
||||
<div className="selected-shape-actions">
|
||||
{renderAction("changeArrowType")}
|
||||
{renderAction("changeArrowhead")}
|
||||
{renderAction("changeArrowType")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { KEYS, CODES, getShortcutKey, isDarwin } from "@excalidraw/common";
|
||||
import { KEYS, CODES, isDarwin } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
moveOneLeft,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SendToBackIcon,
|
||||
} from "../components/icons";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
|
||||
@@ -45,11 +45,7 @@ export {
|
||||
} from "./actionExport";
|
||||
|
||||
export { actionCopyStyles, actionPasteStyles } from "./actionStyles";
|
||||
export {
|
||||
actionToggleCanvasMenu,
|
||||
actionToggleEditMenu,
|
||||
actionShortcuts,
|
||||
} from "./actionMenu";
|
||||
export { actionShortcuts } from "./actionMenu";
|
||||
|
||||
export { actionGroup, actionUngroup } from "./actionGroup";
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { isDarwin, getShortcutKey } from "@excalidraw/common";
|
||||
import { isDarwin } from "@excalidraw/common";
|
||||
|
||||
import type { SubtypeOf } from "@excalidraw/common/utility-types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import type { ActionName } from "./types";
|
||||
|
||||
|
||||
@@ -73,8 +73,6 @@ export type ActionName =
|
||||
| "changeArrowProperties"
|
||||
| "changeOpacity"
|
||||
| "changeFontSize"
|
||||
| "toggleCanvasMenu"
|
||||
| "toggleEditMenu"
|
||||
| "undo"
|
||||
| "redo"
|
||||
| "finalize"
|
||||
|
||||
@@ -56,6 +56,10 @@ export const getDefaultAppState = (): Omit<
|
||||
fromSelection: false,
|
||||
lastActiveTool: null,
|
||||
},
|
||||
preferredSelectionTool: {
|
||||
type: "selection",
|
||||
initialized: false,
|
||||
},
|
||||
penMode: false,
|
||||
penDetected: false,
|
||||
errorMessage: null,
|
||||
@@ -178,6 +182,7 @@ const APP_STATE_STORAGE_CONF = (<
|
||||
editingTextElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
activeTool: { browser: true, export: false, server: false },
|
||||
preferredSelectionTool: { browser: true, export: false, server: false },
|
||||
penMode: { browser: true, export: false, server: false },
|
||||
penDetected: { browser: true, export: false, server: false },
|
||||
errorMessage: { browser: false, export: false, server: false },
|
||||
@@ -250,7 +255,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 },
|
||||
stylesPanelMode: { browser: false, export: false, server: false },
|
||||
});
|
||||
|
||||
const _clearAppStateForStorage = <
|
||||
|
||||
@@ -470,13 +470,14 @@ export const parseDataTransferEvent = async (
|
||||
Array.from(items || []).map(
|
||||
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||
if (item.kind === "file") {
|
||||
const file = item.getAsFile();
|
||||
let file = item.getAsFile();
|
||||
if (file) {
|
||||
const fileHandle = await getFileHandle(item);
|
||||
file = await normalizeFile(file);
|
||||
return {
|
||||
type: file.type,
|
||||
kind: "file",
|
||||
file: await normalizeFile(file),
|
||||
file,
|
||||
fileHandle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -106,15 +106,15 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 2.5rem;
|
||||
pointer-events: auto;
|
||||
|
||||
--default-button-size: 2rem;
|
||||
|
||||
.compact-action-button {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: var(--mobile-action-button-size);
|
||||
height: var(--mobile-action-button-size);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-lg);
|
||||
background: transparent;
|
||||
color: var(--color-on-surface);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -122,24 +122,20 @@
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
background: var(--mobile-action-button-bg);
|
||||
|
||||
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(
|
||||
--color-surface-primary-container,
|
||||
var(--mobile-action-button-bg)
|
||||
);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--button-active-bg, var(--island-bg-color));
|
||||
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||
}
|
||||
}
|
||||
|
||||
.compact-popover-content {
|
||||
@@ -167,6 +163,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ToolIcon {
|
||||
.ToolIcon__icon {
|
||||
width: var(--mobile-action-button-size);
|
||||
height: var(--mobile-action-button-size);
|
||||
|
||||
background: var(--mobile-action-button-bg);
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.compact-shape-actions-island {
|
||||
@@ -174,29 +183,18 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
.mobile-shape-actions {
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border-radius: var(--border-radius-lg);
|
||||
box-shadow: none;
|
||||
overflow: none;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.shape-actions-theme-scope {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,7 +80,6 @@ import {
|
||||
wrapEvent,
|
||||
updateObject,
|
||||
updateActiveTool,
|
||||
getShortcutKey,
|
||||
isTransparent,
|
||||
easeToValuesRAF,
|
||||
muteFSAbortError,
|
||||
@@ -103,6 +102,8 @@ import {
|
||||
MQ_MAX_MOBILE,
|
||||
MQ_MIN_TABLET,
|
||||
MQ_MAX_TABLET,
|
||||
MQ_MAX_HEIGHT_LANDSCAPE,
|
||||
MQ_MAX_WIDTH_LANDSCAPE,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
@@ -171,7 +172,7 @@ import {
|
||||
getContainerElement,
|
||||
isValidTextContainer,
|
||||
redrawTextBoundingBox,
|
||||
shouldShowBoundingBox,
|
||||
hasBoundingBox,
|
||||
getFrameChildren,
|
||||
isCursorInFrame,
|
||||
addElementsToFrame,
|
||||
@@ -405,6 +406,8 @@ import { LassoTrail } from "../lasso";
|
||||
|
||||
import { EraserTrail } from "../eraser";
|
||||
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import ConvertElementTypePopup, {
|
||||
getConversionTypeFromElements,
|
||||
convertElementTypePopupAtom,
|
||||
@@ -434,6 +437,8 @@ import { findShapeByKey } from "./shapes";
|
||||
|
||||
import UnlockPopup from "./UnlockPopup";
|
||||
|
||||
import type { ExcalidrawLibraryIds } from "../data/types";
|
||||
|
||||
import type {
|
||||
RenderInteractiveSceneCallback,
|
||||
ScrollBars,
|
||||
@@ -663,14 +668,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
>();
|
||||
onRemoveEventListenersEmitter = new Emitter<[]>();
|
||||
|
||||
defaultSelectionTool: "selection" | "lasso" = "selection";
|
||||
|
||||
constructor(props: AppProps) {
|
||||
super(props);
|
||||
const defaultAppState = getDefaultAppState();
|
||||
this.defaultSelectionTool = isMobileOrTablet()
|
||||
? ("lasso" as const)
|
||||
: ("selection" as const);
|
||||
const {
|
||||
excalidrawAPI,
|
||||
viewModeEnabled = false,
|
||||
@@ -1524,7 +1524,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
public render() {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
const { renderTopRightUI, renderCustomStats } = this.props;
|
||||
const { renderTopRightUI, renderTopLeftUI, renderCustomStats } = this.props;
|
||||
|
||||
const sceneNonce = this.scene.getSceneNonce();
|
||||
const { elementsMap, visibleElements } =
|
||||
@@ -1610,6 +1610,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
onPenModeToggle={this.togglePenMode}
|
||||
onHandToolToggle={this.onHandToolToggle}
|
||||
langCode={getLanguage().code}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
showExitZenModeBtn={
|
||||
@@ -1622,7 +1623,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.isLoading &&
|
||||
this.state.showWelcomeScreen &&
|
||||
this.state.activeTool.type ===
|
||||
this.defaultSelectionTool &&
|
||||
this.state.preferredSelectionTool.type &&
|
||||
!this.state.zenModeEnabled &&
|
||||
!this.scene.getElementsIncludingDeleted().length
|
||||
}
|
||||
@@ -2367,6 +2368,14 @@ class App extends React.Component<AppProps, AppState> {
|
||||
deleteInvisibleElements: true,
|
||||
});
|
||||
const activeTool = scene.appState.activeTool;
|
||||
|
||||
if (!scene.appState.preferredSelectionTool.initialized) {
|
||||
scene.appState.preferredSelectionTool = {
|
||||
type: this.device.editor.isMobile ? "lasso" : "selection",
|
||||
initialized: true,
|
||||
};
|
||||
}
|
||||
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
theme: this.props.theme || scene.appState.theme,
|
||||
@@ -2381,12 +2390,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
activeTool.type === "selection"
|
||||
? {
|
||||
...activeTool,
|
||||
type: this.defaultSelectionTool,
|
||||
type: scene.appState.preferredSelectionTool.type,
|
||||
}
|
||||
: scene.appState.activeTool,
|
||||
isLoading: false,
|
||||
toast: this.state.toast,
|
||||
};
|
||||
|
||||
if (initialData?.scrollToContent) {
|
||||
scene.appState = {
|
||||
...scene.appState,
|
||||
@@ -2422,8 +2432,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
};
|
||||
|
||||
private isMobileBreakpoint = (width: number, height: number) => {
|
||||
const minSide = Math.min(width, height);
|
||||
return minSide <= MQ_MAX_MOBILE;
|
||||
return (
|
||||
width <= MQ_MAX_MOBILE ||
|
||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||
);
|
||||
};
|
||||
|
||||
private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => {
|
||||
@@ -2477,16 +2489,29 @@ class App extends React.Component<AppProps, AppState> {
|
||||
canFitSidebar: editorWidth > sidebarBreakpoint,
|
||||
});
|
||||
|
||||
const 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"
|
||||
: this.isMobileBreakpoint(editorWidth, editorHeight)
|
||||
? "mobile"
|
||||
: "full";
|
||||
|
||||
// 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",
|
||||
});
|
||||
this.setState((prevState) => ({
|
||||
stylesPanelMode,
|
||||
// reset to box selection mode if the UI changes to full
|
||||
// where you'd not be able to change the mode yourself currently
|
||||
preferredSelectionTool:
|
||||
stylesPanelMode === "full"
|
||||
? {
|
||||
type: "selection",
|
||||
initialized: true,
|
||||
}
|
||||
: prevState.preferredSelectionTool,
|
||||
}));
|
||||
|
||||
if (prevEditorState !== nextEditorState) {
|
||||
this.device = { ...this.device, editor: nextEditorState };
|
||||
@@ -3284,7 +3309,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
await this.insertClipboardContent(data, filesList, isPlainPaste);
|
||||
|
||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||
this.setActiveTool(
|
||||
{ type: this.state.preferredSelectionTool.type },
|
||||
true,
|
||||
);
|
||||
event?.preventDefault();
|
||||
},
|
||||
);
|
||||
@@ -3430,7 +3458,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
},
|
||||
);
|
||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||
this.setActiveTool({ type: this.state.preferredSelectionTool.type }, true);
|
||||
|
||||
if (opts.fitToContent) {
|
||||
this.scrollToContent(duplicatedElements, {
|
||||
@@ -3642,7 +3670,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
...updateActiveTool(
|
||||
this.state,
|
||||
prevState.activeTool.locked
|
||||
? { type: this.defaultSelectionTool }
|
||||
? { type: this.state.preferredSelectionTool.type }
|
||||
: prevState.activeTool,
|
||||
),
|
||||
locked: !prevState.activeTool.locked,
|
||||
@@ -3984,7 +4012,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
if (appState) {
|
||||
this.setState(appState);
|
||||
this.setState({
|
||||
...appState,
|
||||
// keep existing stylesPanelMode as it needs to be preserved
|
||||
// or set at startup
|
||||
stylesPanelMode: this.state.stylesPanelMode,
|
||||
} as Pick<AppState, K> | null);
|
||||
}
|
||||
|
||||
if (elements) {
|
||||
@@ -4648,7 +4681,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
if (event.key === KEYS.K && !event.altKey && !event[KEYS.CTRL_OR_CMD]) {
|
||||
if (this.state.activeTool.type === "laser") {
|
||||
this.setActiveTool({ type: this.defaultSelectionTool });
|
||||
this.setActiveTool({ type: this.state.preferredSelectionTool.type });
|
||||
} else {
|
||||
this.setActiveTool({ type: "laser" });
|
||||
}
|
||||
@@ -5231,7 +5264,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (
|
||||
considerBoundingBox &&
|
||||
this.state.selectedElementIds[element.id] &&
|
||||
shouldShowBoundingBox([element], this.state)
|
||||
hasBoundingBox([element], this.state)
|
||||
) {
|
||||
// if hitting the bounding box, return early
|
||||
// but if not, we should check for other cases as well (e.g. frame name)
|
||||
@@ -5493,7 +5526,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return;
|
||||
}
|
||||
// we should only be able to double click when mode is selection
|
||||
if (this.state.activeTool.type !== this.defaultSelectionTool) {
|
||||
if (this.state.activeTool.type !== this.state.preferredSelectionTool.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6134,7 +6167,13 @@ class App extends React.Component<AppProps, AppState> {
|
||||
(!this.state.selectedLinearElement ||
|
||||
this.state.selectedLinearElement.hoverPointIndex === -1) &&
|
||||
this.state.openDialog?.name !== "elementLinkSelector" &&
|
||||
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0]))
|
||||
!(selectedElements.length === 1 && isElbowArrow(selectedElements[0])) &&
|
||||
// HACK: Disable transform handles for linear elements on mobile until a
|
||||
// better way of showing them is found
|
||||
!(
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
(isMobileOrTablet() || selectedElements[0].points.length === 2)
|
||||
)
|
||||
) {
|
||||
const elementWithTransformHandleType =
|
||||
getElementWithTransformHandleType(
|
||||
@@ -6486,6 +6525,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setAppState({ snapLines: [] });
|
||||
}
|
||||
|
||||
if (this.state.openPopup) {
|
||||
this.setState({ openPopup: null });
|
||||
}
|
||||
|
||||
this.updateGestureOnPointerDown(event);
|
||||
|
||||
// if dragging element is freedraw and another pointerdown event occurs
|
||||
@@ -7250,14 +7293,8 @@ class App extends React.Component<AppProps, AppState> {
|
||||
!this.state.selectedLinearElement?.isEditing &&
|
||||
!isElbowArrow(selectedElements[0]) &&
|
||||
!(
|
||||
isLineElement(selectedElements[0]) &&
|
||||
LinearElementEditor.getPointIndexUnderCursor(
|
||||
selectedElements[0],
|
||||
elementsMap,
|
||||
this.state.zoom,
|
||||
pointerDownState.origin.x,
|
||||
pointerDownState.origin.y,
|
||||
) !== -1
|
||||
isLinearElement(selectedElements[0]) &&
|
||||
(isMobileOrTablet() || selectedElements[0].points.length === 2)
|
||||
) &&
|
||||
!(
|
||||
this.state.selectedLinearElement &&
|
||||
@@ -7690,7 +7727,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (!this.state.activeTool.locked) {
|
||||
this.setState({
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -9407,7 +9444,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState((prevState) => ({
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
{
|
||||
@@ -10024,7 +10061,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
newElement: null,
|
||||
suggestedBindings: [],
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
@@ -10254,7 +10291,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
{
|
||||
newElement: null,
|
||||
activeTool: updateActiveTool(this.state, {
|
||||
type: this.defaultSelectionTool,
|
||||
type: this.state.preferredSelectionTool.type,
|
||||
}),
|
||||
},
|
||||
() => {
|
||||
@@ -10459,7 +10496,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const initialized = await Promise.all(
|
||||
placeholders.map(async (placeholder, i) => {
|
||||
try {
|
||||
return await this.initializeImage(placeholder, imageFiles[i]);
|
||||
return await this.initializeImage(
|
||||
placeholder,
|
||||
await normalizeFile(imageFiles[i]),
|
||||
);
|
||||
} catch (error: any) {
|
||||
this.setState({
|
||||
errorMessage: error.message || t("errors.imageInsertError"),
|
||||
@@ -10549,16 +10589,44 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (imageFiles.length > 0 && this.isToolSupported("image")) {
|
||||
return this.insertImages(imageFiles, sceneX, sceneY);
|
||||
}
|
||||
|
||||
const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib);
|
||||
if (libraryJSON && typeof libraryJSON === "string") {
|
||||
const excalidrawLibrary_ids = dataTransferList.getData(
|
||||
MIME_TYPES.excalidrawlibIds,
|
||||
);
|
||||
const excalidrawLibrary_data = dataTransferList.getData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
);
|
||||
if (excalidrawLibrary_ids || excalidrawLibrary_data) {
|
||||
try {
|
||||
const libraryItems = parseLibraryJSON(libraryJSON);
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements: distributeLibraryItemsOnSquareGrid(libraryItems),
|
||||
position: event,
|
||||
files: null,
|
||||
});
|
||||
let libraryItems: LibraryItems | null = null;
|
||||
if (excalidrawLibrary_ids) {
|
||||
const { itemIds } = JSON.parse(
|
||||
excalidrawLibrary_ids,
|
||||
) as ExcalidrawLibraryIds;
|
||||
const allLibraryItems = await this.library.getLatestLibrary();
|
||||
libraryItems = allLibraryItems.filter((item) =>
|
||||
itemIds.includes(item.id),
|
||||
);
|
||||
// legacy library dataTransfer format
|
||||
} else if (excalidrawLibrary_data) {
|
||||
libraryItems = parseLibraryJSON(excalidrawLibrary_data);
|
||||
}
|
||||
if (libraryItems?.length) {
|
||||
libraryItems = libraryItems.map((item) => ({
|
||||
...item,
|
||||
// #6465
|
||||
elements: duplicateElements({
|
||||
type: "everything",
|
||||
elements: item.elements,
|
||||
randomizeSeed: true,
|
||||
}).duplicatedElements,
|
||||
}));
|
||||
|
||||
this.addElementsFromPasteOrLibrary({
|
||||
elements: distributeLibraryItemsOnSquareGrid(libraryItems),
|
||||
position: event,
|
||||
files: null,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.setState({ errorMessage: error.message });
|
||||
}
|
||||
@@ -10687,7 +10755,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
event.nativeEvent.pointerType === "pen" &&
|
||||
// always allow if user uses a pen secondary button
|
||||
event.button !== POINTER_BUTTON.SECONDARY)) &&
|
||||
this.state.activeTool.type !== this.defaultSelectionTool
|
||||
this.state.activeTool.type !== this.state.preferredSelectionTool.type
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -11141,6 +11209,17 @@ class App extends React.Component<AppProps, AppState> {
|
||||
return [actionCopy, ...options];
|
||||
}
|
||||
|
||||
const zIndexActions: ContextMenuItems =
|
||||
this.state.stylesPanelMode === "full"
|
||||
? [
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionSendBackward,
|
||||
actionBringForward,
|
||||
actionSendToBack,
|
||||
actionBringToFront,
|
||||
]
|
||||
: [];
|
||||
|
||||
return [
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionCut,
|
||||
@@ -11166,11 +11245,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionUngroup,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionAddToLibrary,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionSendBackward,
|
||||
actionBringForward,
|
||||
actionSendToBack,
|
||||
actionBringToFront,
|
||||
...zIndexActions,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionFlipHorizontal,
|
||||
actionFlipVertical,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { getShortcutKey } from "../..//shortcut";
|
||||
import { useAtom } from "../../editor-jotai";
|
||||
import { t } from "../../i18n";
|
||||
import { useDevice } from "../App";
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__title {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.color-picker__heading {
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
@@ -157,6 +163,15 @@
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
}
|
||||
|
||||
&.compact-sizing {
|
||||
width: var(--mobile-action-button-size);
|
||||
height: var(--mobile-action-button-size);
|
||||
}
|
||||
|
||||
&.mobile-border {
|
||||
border: 1px solid var(--mobile-color-border);
|
||||
}
|
||||
}
|
||||
|
||||
.color-picker__button__hotkey-label {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||
COLOR_PALETTE,
|
||||
isTransparent,
|
||||
isWritableElement,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type { ColorTuple, ColorPaletteCustom } from "@excalidraw/common";
|
||||
@@ -18,7 +19,7 @@ import { useExcalidrawContainer } from "../App";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
|
||||
import { slashIcon, strokeIcon } from "../icons";
|
||||
import {
|
||||
saveCaretPosition,
|
||||
restoreCaretPosition,
|
||||
@@ -132,7 +133,9 @@ const ColorPickerPopupContent = ({
|
||||
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
if (!isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
}
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
@@ -213,6 +216,11 @@ const ColorPickerPopupContent = ({
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
showTitle={
|
||||
appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile"
|
||||
}
|
||||
showHotKey={appState.stylesPanelMode !== "mobile"}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
@@ -227,7 +235,7 @@ const ColorPickerTrigger = ({
|
||||
label,
|
||||
color,
|
||||
type,
|
||||
compactMode = false,
|
||||
stylesPanelMode,
|
||||
mode = "background",
|
||||
onToggle,
|
||||
editingTextElement,
|
||||
@@ -235,7 +243,7 @@ const ColorPickerTrigger = ({
|
||||
color: string | null;
|
||||
label: string;
|
||||
type: ColorPickerType;
|
||||
compactMode?: boolean;
|
||||
stylesPanelMode?: AppState["stylesPanelMode"];
|
||||
mode?: "background" | "stroke";
|
||||
onToggle: () => void;
|
||||
editingTextElement?: boolean;
|
||||
@@ -260,6 +268,9 @@ const ColorPickerTrigger = ({
|
||||
"is-transparent": !color || color === "transparent",
|
||||
"has-outline":
|
||||
!color || !isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD),
|
||||
"compact-sizing":
|
||||
stylesPanelMode === "compact" || stylesPanelMode === "mobile",
|
||||
"mobile-border": stylesPanelMode === "mobile",
|
||||
})}
|
||||
aria-label={label}
|
||||
style={color ? { "--swatch-color": color } : undefined}
|
||||
@@ -272,20 +283,10 @@ const ColorPickerTrigger = ({
|
||||
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>
|
||||
) : (
|
||||
{(stylesPanelMode === "compact" || stylesPanelMode === "mobile") &&
|
||||
color &&
|
||||
mode === "stroke" && (
|
||||
<div className="color-picker__button-background">
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
@@ -296,9 +297,8 @@ const ColorPickerTrigger = ({
|
||||
>
|
||||
{strokeIcon}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
||||
@@ -313,12 +313,16 @@ export const ColorPicker = ({
|
||||
topPicks,
|
||||
updateData,
|
||||
appState,
|
||||
compactMode = false,
|
||||
}: ColorPickerProps) => {
|
||||
const openRef = useRef(appState.openPopup);
|
||||
useEffect(() => {
|
||||
openRef.current = appState.openPopup;
|
||||
}, [appState.openPopup]);
|
||||
const compactMode =
|
||||
type !== "canvasBackground" &&
|
||||
(appState.stylesPanelMode === "compact" ||
|
||||
appState.stylesPanelMode === "mobile");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -350,7 +354,7 @@ export const ColorPicker = ({
|
||||
color={color}
|
||||
label={label}
|
||||
type={type}
|
||||
compactMode={compactMode}
|
||||
stylesPanelMode={appState.stylesPanelMode}
|
||||
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||
editingTextElement={!!appState.editingTextElement}
|
||||
onToggle={() => {
|
||||
|
||||
@@ -37,8 +37,10 @@ interface PickerProps {
|
||||
palette: ColorPaletteCustom;
|
||||
updateData: (formData?: any) => void;
|
||||
children?: React.ReactNode;
|
||||
showTitle?: boolean;
|
||||
onEyeDropperToggle: (force?: boolean) => void;
|
||||
onEscape: (event: React.KeyboardEvent | KeyboardEvent) => void;
|
||||
showHotKey?: boolean;
|
||||
}
|
||||
|
||||
export const Picker = React.forwardRef(
|
||||
@@ -51,11 +53,21 @@ export const Picker = React.forwardRef(
|
||||
palette,
|
||||
updateData,
|
||||
children,
|
||||
showTitle,
|
||||
onEyeDropperToggle,
|
||||
onEscape,
|
||||
showHotKey = true,
|
||||
}: PickerProps,
|
||||
ref,
|
||||
) => {
|
||||
const title = showTitle
|
||||
? type === "elementStroke"
|
||||
? t("labels.stroke")
|
||||
: type === "elementBackground"
|
||||
? t("labels.background")
|
||||
: null
|
||||
: null;
|
||||
|
||||
const [customColors] = React.useState(() => {
|
||||
if (type === "canvasBackground") {
|
||||
return [];
|
||||
@@ -154,6 +166,8 @@ export const Picker = React.forwardRef(
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
>
|
||||
{title && <div className="color-picker__title">{title}</div>}
|
||||
|
||||
{!!customColors.length && (
|
||||
<div>
|
||||
<PickerHeading>
|
||||
@@ -175,12 +189,18 @@ export const Picker = React.forwardRef(
|
||||
palette={palette}
|
||||
onChange={onChange}
|
||||
activeShade={activeShade}
|
||||
showHotKey={showHotKey}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.shades")}</PickerHeading>
|
||||
<ShadeList color={color} onChange={onChange} palette={palette} />
|
||||
<ShadeList
|
||||
color={color}
|
||||
onChange={onChange}
|
||||
palette={palette}
|
||||
showHotKey={showHotKey}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ interface PickerColorListProps {
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
activeShade: number;
|
||||
showHotKey?: boolean;
|
||||
}
|
||||
|
||||
const PickerColorList = ({
|
||||
@@ -27,6 +28,7 @@ const PickerColorList = ({
|
||||
color,
|
||||
onChange,
|
||||
activeShade,
|
||||
showHotKey = true,
|
||||
}: PickerColorListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color,
|
||||
@@ -82,7 +84,7 @@ const PickerColorList = ({
|
||||
key={key}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={color} keyLabel={keybinding} />
|
||||
{showHotKey && <HotkeyLabel color={color} keyLabel={keybinding} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -16,9 +16,15 @@ interface ShadeListProps {
|
||||
color: string | null;
|
||||
onChange: (color: string) => void;
|
||||
palette: ColorPaletteCustom;
|
||||
showHotKey?: boolean;
|
||||
}
|
||||
|
||||
export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
||||
export const ShadeList = ({
|
||||
color,
|
||||
onChange,
|
||||
palette,
|
||||
showHotKey,
|
||||
}: ShadeListProps) => {
|
||||
const colorObj = getColorNameAndShadeFromColor({
|
||||
color: color || "transparent",
|
||||
palette,
|
||||
@@ -67,7 +73,9 @@ export const ShadeList = ({ color, onChange, palette }: ShadeListProps) => {
|
||||
}}
|
||||
>
|
||||
<div className="color-picker__button-outline" />
|
||||
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
||||
{showHotKey && (
|
||||
<HotkeyLabel color={color} keyLabel={i + 1} isShade />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,19 @@ $verticalBreakpoint: 861px;
|
||||
border-radius: var(--border-radius-lg);
|
||||
cursor: pointer;
|
||||
|
||||
--icon-size: 1rem;
|
||||
|
||||
&.command-item-large {
|
||||
height: 2.75rem;
|
||||
--icon-size: 1.5rem;
|
||||
|
||||
.icon {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
margin-right: 0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: var(--color-surface-low);
|
||||
}
|
||||
@@ -130,9 +143,17 @@ $verticalBreakpoint: 861px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 6px;
|
||||
width: var(--icon-size, 1rem);
|
||||
height: var(--icon-size, 1rem);
|
||||
margin-right: 0.375rem;
|
||||
|
||||
.library-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import clsx from "clsx";
|
||||
import fuzzy from "fuzzy";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
DEFAULT_SIDEBAR,
|
||||
EVENT,
|
||||
KEYS,
|
||||
capitalizeString,
|
||||
getShortcutKey,
|
||||
isWritableElement,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { actionToggleShapeSwitch } from "@excalidraw/excalidraw/actions/actionToggleShapeSwitch";
|
||||
|
||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||
|
||||
import type { MarkRequired } from "@excalidraw/common/utility-types";
|
||||
|
||||
import {
|
||||
@@ -61,12 +62,21 @@ import { useStable } from "../../hooks/useStable";
|
||||
|
||||
import { Ellipsify } from "../Ellipsify";
|
||||
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
import {
|
||||
distributeLibraryItemsOnSquareGrid,
|
||||
libraryItemsAtom,
|
||||
} from "../../data/library";
|
||||
|
||||
import {
|
||||
useLibraryCache,
|
||||
useLibraryItemSvg,
|
||||
} from "../../hooks/useLibraryItemSvg";
|
||||
|
||||
import * as defaultItems from "./defaultCommandPaletteItems";
|
||||
import "./CommandPalette.scss";
|
||||
|
||||
import type { CommandPaletteItem } from "./types";
|
||||
import type { AppProps, AppState, UIAppState } from "../../types";
|
||||
import type { AppProps, AppState, LibraryItem, UIAppState } from "../../types";
|
||||
import type { ShortcutName } from "../../actions/shortcuts";
|
||||
import type { TranslationKeys } from "../../i18n";
|
||||
import type { Action } from "../../actions/types";
|
||||
@@ -80,6 +90,7 @@ export const DEFAULT_CATEGORIES = {
|
||||
editor: "Editor",
|
||||
elements: "Elements",
|
||||
links: "Links",
|
||||
library: "Library",
|
||||
};
|
||||
|
||||
const getCategoryOrder = (category: string) => {
|
||||
@@ -207,6 +218,34 @@ function CommandPaletteInner({
|
||||
appProps,
|
||||
});
|
||||
|
||||
const [libraryItemsData] = useAtom(libraryItemsAtom);
|
||||
const libraryCommands: CommandPaletteItem[] = useMemo(() => {
|
||||
return (
|
||||
libraryItemsData.libraryItems
|
||||
?.filter(
|
||||
(libraryItem): libraryItem is MarkRequired<LibraryItem, "name"> =>
|
||||
!!libraryItem.name,
|
||||
)
|
||||
.map((libraryItem) => ({
|
||||
label: libraryItem.name,
|
||||
icon: (
|
||||
<LibraryItemIcon
|
||||
id={libraryItem.id}
|
||||
elements={libraryItem.elements}
|
||||
/>
|
||||
),
|
||||
category: "Library",
|
||||
order: getCategoryOrder("Library"),
|
||||
haystack: deburr(libraryItem.name),
|
||||
perform: () => {
|
||||
app.onInsertElements(
|
||||
distributeLibraryItemsOnSquareGrid([libraryItem]),
|
||||
);
|
||||
},
|
||||
})) || []
|
||||
);
|
||||
}, [app, libraryItemsData.libraryItems]);
|
||||
|
||||
useEffect(() => {
|
||||
// these props change often and we don't want them to re-run the effect
|
||||
// which would renew `allCommands`, cascading down and resetting state.
|
||||
@@ -438,7 +477,6 @@ function CommandPaletteInner({
|
||||
},
|
||||
perform: () => {
|
||||
setAppState((prevState) => ({
|
||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
||||
openPopup: "elementStroke",
|
||||
}));
|
||||
},
|
||||
@@ -458,7 +496,6 @@ function CommandPaletteInner({
|
||||
},
|
||||
perform: () => {
|
||||
setAppState((prevState) => ({
|
||||
openMenu: prevState.openMenu === "shape" ? null : "shape",
|
||||
openPopup: "elementBackground",
|
||||
}));
|
||||
},
|
||||
@@ -588,8 +625,9 @@ function CommandPaletteInner({
|
||||
|
||||
setAllCommands(allCommands);
|
||||
setLastUsed(
|
||||
allCommands.find((command) => command.label === lastUsed?.label) ??
|
||||
null,
|
||||
[...allCommands, ...libraryCommands].find(
|
||||
(command) => command.label === lastUsed?.label,
|
||||
) ?? null,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
@@ -600,6 +638,7 @@ function CommandPaletteInner({
|
||||
lastUsed?.label,
|
||||
setLastUsed,
|
||||
setAppState,
|
||||
libraryCommands,
|
||||
]);
|
||||
|
||||
const [commandSearch, setCommandSearch] = useState("");
|
||||
@@ -796,9 +835,17 @@ function CommandPaletteInner({
|
||||
return nextCommandsByCategory;
|
||||
};
|
||||
|
||||
let matchingCommands = allCommands
|
||||
.filter(isCommandAvailable)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
let matchingCommands =
|
||||
commandSearch?.length > 1
|
||||
? [
|
||||
...allCommands
|
||||
.filter(isCommandAvailable)
|
||||
.sort((a, b) => a.order - b.order),
|
||||
...libraryCommands,
|
||||
]
|
||||
: allCommands
|
||||
.filter(isCommandAvailable)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const showLastUsed =
|
||||
!commandSearch && lastUsed && isCommandAvailable(lastUsed);
|
||||
@@ -822,14 +869,20 @@ function CommandPaletteInner({
|
||||
);
|
||||
matchingCommands = fuzzy
|
||||
.filter(_query, matchingCommands, {
|
||||
extract: (command) => command.haystack,
|
||||
extract: (command) => command.haystack ?? "",
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((item) => item.original);
|
||||
|
||||
setCommandsByCategory(getNextCommandsByCategory(matchingCommands));
|
||||
setCurrentCommand(matchingCommands[0] ?? null);
|
||||
}, [commandSearch, allCommands, isCommandAvailable, lastUsed]);
|
||||
}, [
|
||||
commandSearch,
|
||||
allCommands,
|
||||
isCommandAvailable,
|
||||
lastUsed,
|
||||
libraryCommands,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -904,6 +957,7 @@ function CommandPaletteInner({
|
||||
onMouseMove={() => setCurrentCommand(command)}
|
||||
showShortcut={!app.device.viewport.isMobile}
|
||||
appState={uiAppState}
|
||||
size={category === "Library" ? "large" : "small"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -919,6 +973,20 @@ function CommandPaletteInner({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
const LibraryItemIcon = ({
|
||||
id,
|
||||
elements,
|
||||
}: {
|
||||
id: LibraryItem["id"] | null;
|
||||
elements: LibraryItem["elements"] | undefined;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { svgCache } = useLibraryCache();
|
||||
|
||||
useLibraryItemSvg(id, elements, svgCache, ref);
|
||||
|
||||
return <div className="library-item-icon" ref={ref} />;
|
||||
};
|
||||
|
||||
const CommandItem = ({
|
||||
command,
|
||||
@@ -928,6 +996,7 @@ const CommandItem = ({
|
||||
onClick,
|
||||
showShortcut,
|
||||
appState,
|
||||
size = "small",
|
||||
}: {
|
||||
command: CommandPaletteItem;
|
||||
isSelected: boolean;
|
||||
@@ -936,6 +1005,7 @@ const CommandItem = ({
|
||||
onClick: (event: React.MouseEvent) => void;
|
||||
showShortcut: boolean;
|
||||
appState: UIAppState;
|
||||
size?: "small" | "large";
|
||||
}) => {
|
||||
const noop = () => {};
|
||||
|
||||
@@ -944,6 +1014,7 @@ const CommandItem = ({
|
||||
className={clsx("command-item", {
|
||||
"item-selected": isSelected,
|
||||
"item-disabled": disabled,
|
||||
"command-item-large": size === "large",
|
||||
})}
|
||||
ref={(ref) => {
|
||||
if (isSelected && !disabled) {
|
||||
@@ -959,6 +1030,8 @@ const CommandItem = ({
|
||||
<div className="name">
|
||||
{command.icon && (
|
||||
<InlineIcon
|
||||
className="icon"
|
||||
size="var(--icon-size, 1rem)"
|
||||
icon={
|
||||
typeof command.icon === "function"
|
||||
? command.icon(appState)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.context-menu-popover {
|
||||
z-index: var(--zIndex-ui-context-menu);
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -64,6 +64,7 @@ export const ContextMenu = React.memo(
|
||||
offsetTop={appState.offsetTop}
|
||||
viewportWidth={appState.width}
|
||||
viewportHeight={appState.height}
|
||||
className="context-menu-popover"
|
||||
>
|
||||
<ul
|
||||
className="context-menu"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.excalidraw {
|
||||
.ExcalidrawLogo {
|
||||
--logo-icon--mobile: 1rem;
|
||||
--logo-text--mobile: 0.75rem;
|
||||
|
||||
--logo-icon--xs: 2rem;
|
||||
--logo-text--xs: 1.5rem;
|
||||
|
||||
@@ -30,6 +33,17 @@
|
||||
color: var(--color-logo-text);
|
||||
}
|
||||
|
||||
&.is-mobile {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--mobile);
|
||||
}
|
||||
|
||||
.ExcalidrawLogo-text {
|
||||
height: var(--logo-text--mobile);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-xs {
|
||||
.ExcalidrawLogo-icon {
|
||||
height: var(--logo-icon--xs);
|
||||
|
||||
@@ -41,7 +41,7 @@ const LogoText = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
type LogoSize = "xs" | "small" | "normal" | "large" | "custom";
|
||||
type LogoSize = "xs" | "small" | "normal" | "large" | "custom" | "mobile";
|
||||
|
||||
interface LogoProps {
|
||||
size?: LogoSize;
|
||||
|
||||
@@ -106,6 +106,7 @@ export const FontPicker = React.memo(
|
||||
<FontPickerTrigger
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
isOpened={isOpened}
|
||||
compactMode={compactMode}
|
||||
/>
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
|
||||
@@ -338,11 +338,13 @@ export const FontPickerList = React.memo(
|
||||
onKeyDown={onKeyDown}
|
||||
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||
>
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={debounce(setSearchTerm, 20)}
|
||||
/>
|
||||
{app.state.stylesPanelMode === "full" && (
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={debounce(setSearchTerm, 20)}
|
||||
/>
|
||||
)}
|
||||
<ScrollableList
|
||||
className="dropdown-menu fonts manual-hover"
|
||||
placeholder={t("fontList.empty")}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { MOBILE_ACTION_BUTTON_BG } from "@excalidraw/common";
|
||||
|
||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../../i18n";
|
||||
@@ -11,14 +13,24 @@ import { useExcalidrawSetAppState } from "../App";
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
isOpened?: boolean;
|
||||
compactMode?: boolean;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
isOpened = false,
|
||||
compactMode = false,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const compactStyle = compactMode
|
||||
? {
|
||||
...MOBILE_ACTION_BUTTON_BG,
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||
@@ -37,6 +49,7 @@ export const FontPickerTrigger = ({
|
||||
}}
|
||||
style={{
|
||||
border: "none",
|
||||
...compactStyle,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@ type LockIconProps = {
|
||||
export const HandButton = (props: LockIconProps) => {
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
className={clsx("Shape", { fillable: false, active: props.checked })}
|
||||
type="radio"
|
||||
icon={handIcon}
|
||||
name="editor-current-shape"
|
||||
|
||||
@@ -2,11 +2,12 @@ import React from "react";
|
||||
|
||||
import { isDarwin, isFirefox, isWindows } from "@excalidraw/common";
|
||||
|
||||
import { KEYS, getShortcutKey } from "@excalidraw/common";
|
||||
import { KEYS } from "@excalidraw/common";
|
||||
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { probablySupportsClipboardBlob } from "../clipboard";
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { ExternalLinkIcon, GithubIcon, youtubeIcon } from "./icons";
|
||||
|
||||
@@ -28,11 +28,24 @@ $wide-viewport-width: 1000px;
|
||||
> span {
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
display: inline-block;
|
||||
margin: 0 1px;
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--color-gray-40);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.HintViewer {
|
||||
color: var(--color-gray-60);
|
||||
kbd {
|
||||
border-color: var(--color-gray-60);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,10 @@ import {
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { getShortcutKey } from "@excalidraw/common";
|
||||
|
||||
import { isNodeInFlowchart } from "@excalidraw/element";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { getShortcutKey } from "../shortcut";
|
||||
import { isEraserActive } from "../appState";
|
||||
import { isGridModeEnabled } from "../snapping";
|
||||
|
||||
@@ -28,6 +27,11 @@ interface HintViewerProps {
|
||||
app: AppClassProperties;
|
||||
}
|
||||
|
||||
const getTaggedShortcutKey = (key: string | string[]) =>
|
||||
Array.isArray(key)
|
||||
? `<kbd>${key.map(getShortcutKey).join(" + ")}</kbd>`
|
||||
: `<kbd>${getShortcutKey(key)}</kbd>`;
|
||||
|
||||
const getHints = ({
|
||||
appState,
|
||||
isMobile,
|
||||
@@ -42,7 +46,9 @@ const getHints = ({
|
||||
appState.openSidebar.tab === CANVAS_SEARCH_TAB &&
|
||||
appState.searchMatches?.matches.length
|
||||
) {
|
||||
return t("hints.dismissSearch");
|
||||
return t("hints.dismissSearch", {
|
||||
shortcut: getTaggedShortcutKey("Escape"),
|
||||
});
|
||||
}
|
||||
|
||||
if (appState.openSidebar && !device.editor.canFitSidebar) {
|
||||
@@ -50,14 +56,21 @@ const getHints = ({
|
||||
}
|
||||
|
||||
if (isEraserActive(appState)) {
|
||||
return t("hints.eraserRevert");
|
||||
return t("hints.eraserRevert", {
|
||||
shortcut: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
if (multiMode) {
|
||||
return t("hints.linearElementMulti");
|
||||
return t("hints.linearElementMulti", {
|
||||
shortcut_1: getTaggedShortcutKey("Escape"),
|
||||
shortcut_2: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
}
|
||||
if (activeTool.type === "arrow") {
|
||||
return t("hints.arrowTool", { arrowShortcut: getShortcutKey("A") });
|
||||
return t("hints.arrowTool", {
|
||||
shortcut: getTaggedShortcutKey("A"),
|
||||
});
|
||||
}
|
||||
return t("hints.linearElement");
|
||||
}
|
||||
@@ -83,31 +96,51 @@ const getHints = ({
|
||||
) {
|
||||
const targetElement = selectedElements[0];
|
||||
if (isLinearElement(targetElement) && targetElement.points.length === 2) {
|
||||
return t("hints.lockAngle");
|
||||
return t("hints.lockAngle", {
|
||||
shortcut: getTaggedShortcutKey("Shift"),
|
||||
});
|
||||
}
|
||||
return isImageElement(targetElement)
|
||||
? t("hints.resizeImage")
|
||||
: t("hints.resize");
|
||||
? t("hints.resizeImage", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
})
|
||||
: t("hints.resize", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
|
||||
if (isRotating && lastPointerDownWith === "mouse") {
|
||||
return t("hints.rotate");
|
||||
return t("hints.rotate", {
|
||||
shortcut: getTaggedShortcutKey("Shift"),
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isTextElement(selectedElements[0])) {
|
||||
return t("hints.text_selected");
|
||||
return t("hints.text_selected", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
}
|
||||
|
||||
if (appState.editingTextElement) {
|
||||
return t("hints.text_editing");
|
||||
return t("hints.text_editing", {
|
||||
shortcut_1: getTaggedShortcutKey("Escape"),
|
||||
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
|
||||
});
|
||||
}
|
||||
|
||||
if (appState.croppingElementId) {
|
||||
return t("hints.leaveCropEditor");
|
||||
return t("hints.leaveCropEditor", {
|
||||
shortcut_1: getTaggedShortcutKey("Enter"),
|
||||
shortcut_2: getTaggedShortcutKey("Escape"),
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1 && isImageElement(selectedElements[0])) {
|
||||
return t("hints.enterCropEditor");
|
||||
return t("hints.enterCropEditor", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
}
|
||||
|
||||
if (activeTool.type === "selection") {
|
||||
@@ -117,33 +150,57 @@ const getHints = ({
|
||||
!appState.editingTextElement &&
|
||||
!appState.selectedLinearElement?.isEditing
|
||||
) {
|
||||
return [t("hints.deepBoxSelect")];
|
||||
return t("hints.deepBoxSelect", {
|
||||
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
|
||||
});
|
||||
}
|
||||
|
||||
if (isGridModeEnabled(app) && appState.selectedElementsAreBeingDragged) {
|
||||
return t("hints.disableSnapping");
|
||||
return t("hints.disableSnapping", {
|
||||
shortcut: getTaggedShortcutKey("CtrlOrCmd"),
|
||||
});
|
||||
}
|
||||
|
||||
if (!selectedElements.length && !isMobile) {
|
||||
return [t("hints.canvasPanning")];
|
||||
return t("hints.canvasPanning", {
|
||||
shortcut_1: getTaggedShortcutKey(t("keys.mmb")),
|
||||
shortcut_2: getTaggedShortcutKey("Space"),
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedElements.length === 1) {
|
||||
if (isLinearElement(selectedElements[0])) {
|
||||
if (appState.selectedLinearElement?.isEditing) {
|
||||
return appState.selectedLinearElement.selectedPointsIndices
|
||||
? t("hints.lineEditor_pointSelected")
|
||||
: t("hints.lineEditor_nothingSelected");
|
||||
? t("hints.lineEditor_pointSelected", {
|
||||
shortcut_1: getTaggedShortcutKey("Delete"),
|
||||
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "D"]),
|
||||
})
|
||||
: t("hints.lineEditor_nothingSelected", {
|
||||
shortcut_1: getTaggedShortcutKey("Shift"),
|
||||
shortcut_2: getTaggedShortcutKey("Alt"),
|
||||
});
|
||||
}
|
||||
return isLineElement(selectedElements[0])
|
||||
? t("hints.lineEditor_line_info")
|
||||
: t("hints.lineEditor_info");
|
||||
? t("hints.lineEditor_line_info", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
})
|
||||
: t("hints.lineEditor_info", {
|
||||
shortcut_1: getTaggedShortcutKey("CtrlOrCmd"),
|
||||
shortcut_2: getTaggedShortcutKey(["CtrlOrCmd", "Enter"]),
|
||||
});
|
||||
}
|
||||
if (
|
||||
!appState.newElement &&
|
||||
!appState.selectedElementsAreBeingDragged &&
|
||||
isTextBindableContainer(selectedElements[0])
|
||||
) {
|
||||
const bindTextToElement = t("hints.bindTextToElement", {
|
||||
shortcut: getTaggedShortcutKey("Enter"),
|
||||
});
|
||||
const createFlowchart = t("hints.createFlowchart", {
|
||||
shortcut: getTaggedShortcutKey(["CtrlOrCmd", "↑↓"]),
|
||||
});
|
||||
if (isFlowchartNodeElement(selectedElements[0])) {
|
||||
if (
|
||||
isNodeInFlowchart(
|
||||
@@ -151,13 +208,13 @@ const getHints = ({
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
)
|
||||
) {
|
||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||
return [bindTextToElement, createFlowchart];
|
||||
}
|
||||
|
||||
return [t("hints.bindTextToElement"), t("hints.createFlowchart")];
|
||||
return [bindTextToElement, createFlowchart];
|
||||
}
|
||||
|
||||
return t("hints.bindTextToElement");
|
||||
return bindTextToElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -183,16 +240,21 @@ export const HintViewer = ({
|
||||
}
|
||||
|
||||
const hint = Array.isArray(hints)
|
||||
? hints
|
||||
.map((hint) => {
|
||||
return getShortcutKey(hint).replace(/\. ?$/, "");
|
||||
})
|
||||
.join(". ")
|
||||
: getShortcutKey(hints);
|
||||
? hints.map((hint) => hint.replace(/\. ?$/, "")).join(", ")
|
||||
: hints;
|
||||
|
||||
const hintJSX = hint.split(/(<kbd>[^<]+<\/kbd>)/g).map((part, index) => {
|
||||
if (index % 2 === 1) {
|
||||
const shortcutMatch =
|
||||
part[0] === "<" && part.match(/^<kbd>([^<]+)<\/kbd>$/);
|
||||
return <kbd key={index}>{shortcutMatch ? shortcutMatch[1] : part}</kbd>;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="HintViewer">
|
||||
<span>{hint}</span>
|
||||
<span>{hintJSX}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
|
||||
import { getLanguage, t } from "../i18n";
|
||||
|
||||
import Collapsible from "./Stats/Collapsible";
|
||||
import { useDevice } from "./App";
|
||||
import { useDevice, useExcalidrawContainer } from "./App";
|
||||
|
||||
import "./IconPicker.scss";
|
||||
|
||||
@@ -39,6 +39,7 @@ function Picker<T>({
|
||||
numberOfOptionsToAlwaysShow?: number;
|
||||
}) {
|
||||
const device = useDevice();
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
const pressedOption = options.find(
|
||||
@@ -152,17 +153,16 @@ function Picker<T>({
|
||||
);
|
||||
};
|
||||
|
||||
const isMobile = device.editor.isMobile;
|
||||
|
||||
return (
|
||||
<Popover.Content
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "top"
|
||||
: "bottom"
|
||||
}
|
||||
side={isMobile ? "right" : "bottom"}
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
style={{ zIndex: "var(--zIndex-popup)" }}
|
||||
sideOffset={isMobile ? 8 : 12}
|
||||
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
|
||||
onKeyDown={handleKeyDown}
|
||||
collisionBoundary={container ?? undefined}
|
||||
>
|
||||
<div
|
||||
className={`picker`}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
export const InlineIcon = ({ icon }: { icon: React.ReactNode }) => {
|
||||
export const InlineIcon = ({
|
||||
className,
|
||||
icon,
|
||||
size = "1em",
|
||||
}: {
|
||||
className?: string;
|
||||
icon: React.ReactNode;
|
||||
size?: string;
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
width: "1em",
|
||||
width: size,
|
||||
height: "100%",
|
||||
margin: "0 0.5ex 0 0.5ex",
|
||||
display: "inline-block",
|
||||
display: "inline-flex",
|
||||
lineHeight: 0,
|
||||
verticalAlign: "middle",
|
||||
flex: "0 0 auto",
|
||||
|
||||
@@ -91,6 +91,7 @@ interface LayerUIProps {
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
showExitZenModeBtn: boolean;
|
||||
langCode: Language["code"];
|
||||
renderTopLeftUI?: ExcalidrawProps["renderTopLeftUI"];
|
||||
renderTopRightUI?: ExcalidrawProps["renderTopRightUI"];
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
@@ -149,6 +150,7 @@ const LayerUI = ({
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
showExitZenModeBtn,
|
||||
renderTopLeftUI,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
UIOptions,
|
||||
@@ -366,7 +368,7 @@ const LayerUI = ({
|
||||
/>
|
||||
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
activeTool={appState.activeTool}
|
||||
UIOptions={UIOptions}
|
||||
app={app}
|
||||
@@ -582,13 +584,11 @@ const LayerUI = ({
|
||||
renderJSONExportDialog={renderJSONExportDialog}
|
||||
renderImageExportDialog={renderImageExportDialog}
|
||||
setAppState={setAppState}
|
||||
onLockToggle={onLockToggle}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
onPenModeToggle={onPenModeToggle}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
renderCustomStats={renderCustomStats}
|
||||
renderSidebars={renderSidebars}
|
||||
device={device}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
UIOptions={UIOptions}
|
||||
/>
|
||||
|
||||
@@ -133,15 +133,10 @@
|
||||
}
|
||||
|
||||
.layer-ui__library .library-menu-dropdown-container {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
|
||||
&--in-heading {
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 0.75rem;
|
||||
z-index: 1;
|
||||
|
||||
margin-left: auto;
|
||||
.dropdown-menu {
|
||||
top: 100%;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
LIBRARY_DISABLED_TYPES,
|
||||
randomId,
|
||||
isShallowEqual,
|
||||
KEYS,
|
||||
isWritableElement,
|
||||
addEventListener,
|
||||
EVENT,
|
||||
CLASSES,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import type {
|
||||
@@ -266,11 +271,52 @@ export const LibraryMenu = memo(() => {
|
||||
const memoizedLibrary = useMemo(() => app.library, [app.library]);
|
||||
const pendingElements = usePendingElementsMemo(appState, app);
|
||||
|
||||
useEffect(() => {
|
||||
return addEventListener(
|
||||
document,
|
||||
EVENT.KEYDOWN,
|
||||
(event) => {
|
||||
if (event.key === KEYS.ESCAPE && event.target instanceof HTMLElement) {
|
||||
const target = event.target;
|
||||
if (target.closest(`.${CLASSES.SIDEBAR}`)) {
|
||||
// stop propagation so that we don't prevent it downstream
|
||||
// (default browser behavior is to clear search input on ESC)
|
||||
if (selectedItems.length > 0) {
|
||||
event.stopPropagation();
|
||||
setSelectedItems([]);
|
||||
} else if (
|
||||
isWritableElement(target) &&
|
||||
target instanceof HTMLInputElement &&
|
||||
!target.value
|
||||
) {
|
||||
event.stopPropagation();
|
||||
// if search input empty -> close library
|
||||
// (maybe not a good idea?)
|
||||
setAppState({ openSidebar: null });
|
||||
app.focusContainer();
|
||||
}
|
||||
} else if (selectedItems.length > 0) {
|
||||
const { x, y } = app.lastViewportPosition;
|
||||
const elementUnderCursor = document.elementFromPoint(x, y);
|
||||
// also deselect elements if sidebar doesn't have focus but the
|
||||
// cursor is over it
|
||||
if (elementUnderCursor?.closest(`.${CLASSES.SIDEBAR}`)) {
|
||||
event.stopPropagation();
|
||||
setSelectedItems([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
}, [selectedItems, setAppState, app]);
|
||||
|
||||
const onInsertLibraryItems = useCallback(
|
||||
(libraryItems: LibraryItems) => {
|
||||
onInsertElements(distributeLibraryItemsOnSquareGrid(libraryItems));
|
||||
app.focusContainer();
|
||||
},
|
||||
[onInsertElements],
|
||||
[onInsertElements, app],
|
||||
);
|
||||
|
||||
const deselectItems = useCallback(() => {
|
||||
|
||||
@@ -220,14 +220,6 @@ export const LibraryDropdownMenuButton: React.FC<{
|
||||
{t("buttons.export")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => setShowRemoveLibAlert(true)}
|
||||
icon={TrashIcon}
|
||||
>
|
||||
{resetLabel}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{itemsSelected && (
|
||||
<DropdownMenu.Item
|
||||
icon={publishIcon}
|
||||
@@ -237,6 +229,14 @@ export const LibraryDropdownMenuButton: React.FC<{
|
||||
{t("buttons.publishLibrary")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!!items.length && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => setShowRemoveLibAlert(true)}
|
||||
icon={TrashIcon}
|
||||
>
|
||||
{resetLabel}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@@ -1,24 +1,42 @@
|
||||
@import "open-color/open-color";
|
||||
|
||||
.excalidraw {
|
||||
--container-padding-y: 1.5rem;
|
||||
--container-padding-y: 1rem;
|
||||
--container-padding-x: 0.75rem;
|
||||
|
||||
.library-menu-items-header {
|
||||
display: flex;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.library-menu-items__no-items {
|
||||
text-align: center;
|
||||
color: var(--color-gray-70);
|
||||
line-height: 1.5;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
min-height: 55px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&__label {
|
||||
color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.library-menu-items__no-items__hint {
|
||||
color: var(--color-border-outline);
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
.library-menu-items__no-items {
|
||||
color: var(--color-gray-40);
|
||||
@@ -34,7 +52,7 @@
|
||||
overflow-y: auto;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
justify-content: flex-start;
|
||||
margin: 0;
|
||||
|
||||
position: relative;
|
||||
@@ -51,26 +69,45 @@
|
||||
}
|
||||
|
||||
&__items {
|
||||
// so that spinner is relative-positioned to this container
|
||||
position: relative;
|
||||
|
||||
row-gap: 0.5rem;
|
||||
padding: var(--container-padding-y) 0;
|
||||
padding: 1rem 0 var(--container-padding-y) 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1 1 auto;
|
||||
|
||||
color: var(--color-primary);
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
padding-right: 4rem; // due to dropdown button
|
||||
box-sizing: border-box;
|
||||
|
||||
&--excal {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
&__hint {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--color-border-outline);
|
||||
font-weight: 400;
|
||||
|
||||
kbd {
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--color-border-outline);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
@@ -79,6 +116,24 @@
|
||||
grid-gap: 1rem;
|
||||
}
|
||||
|
||||
&__search {
|
||||
flex: 1 1 auto;
|
||||
margin: 0;
|
||||
|
||||
.ExcTextField__input {
|
||||
height: var(--lg-button-size);
|
||||
input {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.hideCancelButton input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -6,11 +6,14 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { MIME_TYPES, arrayToMap } from "@excalidraw/common";
|
||||
import { MIME_TYPES, arrayToMap, nextAnimationFrame } from "@excalidraw/common";
|
||||
|
||||
import { duplicateElements } from "@excalidraw/element";
|
||||
|
||||
import { serializeLibraryAsJSON } from "../data/json";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { deburr } from "../deburr";
|
||||
|
||||
import { useLibraryCache } from "../hooks/useLibraryItemSvg";
|
||||
import { useScrollPosition } from "../hooks/useScrollPosition";
|
||||
import { t } from "../i18n";
|
||||
@@ -27,6 +30,14 @@ import Stack from "./Stack";
|
||||
|
||||
import "./LibraryMenuItems.scss";
|
||||
|
||||
import { TextField } from "./TextField";
|
||||
|
||||
import { useDevice } from "./App";
|
||||
|
||||
import { Button } from "./Button";
|
||||
|
||||
import type { ExcalidrawLibraryIds } from "../data/types";
|
||||
|
||||
import type {
|
||||
ExcalidrawProps,
|
||||
LibraryItem,
|
||||
@@ -64,6 +75,7 @@ export default function LibraryMenuItems({
|
||||
selectedItems: LibraryItem["id"][];
|
||||
onSelectItems: (id: LibraryItem["id"][]) => void;
|
||||
}) {
|
||||
const device = useDevice();
|
||||
const libraryContainerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollPosition = useScrollPosition<HTMLDivElement>(libraryContainerRef);
|
||||
|
||||
@@ -75,6 +87,30 @@ export default function LibraryMenuItems({
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const { svgCache } = useLibraryCache();
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
const [searchInputValue, setSearchInputValue] = useState("");
|
||||
|
||||
const IS_LIBRARY_EMPTY = !libraryItems.length && !pendingElements.length;
|
||||
|
||||
const IS_SEARCHING = !IS_LIBRARY_EMPTY && !!searchInputValue.trim();
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
const searchQuery = deburr(searchInputValue.trim().toLowerCase());
|
||||
if (!searchQuery) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return libraryItems.filter((item) => {
|
||||
const itemName = item.name || "";
|
||||
return (
|
||||
itemName.trim() && deburr(itemName.toLowerCase()).includes(searchQuery)
|
||||
);
|
||||
});
|
||||
}, [libraryItems, searchInputValue]);
|
||||
|
||||
const unpublishedItems = useMemo(
|
||||
() => libraryItems.filter((item) => item.status !== "published"),
|
||||
[libraryItems],
|
||||
@@ -85,23 +121,10 @@ export default function LibraryMenuItems({
|
||||
[libraryItems],
|
||||
);
|
||||
|
||||
const showBtn = !libraryItems.length && !pendingElements.length;
|
||||
|
||||
const isLibraryEmpty =
|
||||
!pendingElements.length &&
|
||||
!unpublishedItems.length &&
|
||||
!publishedItems.length;
|
||||
|
||||
const [lastSelectedItem, setLastSelectedItem] = useState<
|
||||
LibraryItem["id"] | null
|
||||
>(null);
|
||||
|
||||
const onItemSelectToggle = useCallback(
|
||||
(id: LibraryItem["id"], event: React.MouseEvent) => {
|
||||
const shouldSelect = !selectedItems.includes(id);
|
||||
|
||||
const orderedItems = [...unpublishedItems, ...publishedItems];
|
||||
|
||||
if (shouldSelect) {
|
||||
if (event.shiftKey && lastSelectedItem) {
|
||||
const rangeStart = orderedItems.findIndex(
|
||||
@@ -115,10 +138,13 @@ export default function LibraryMenuItems({
|
||||
}
|
||||
|
||||
const selectedItemsMap = arrayToMap(selectedItems);
|
||||
// Support both top-down and bottom-up selection by using min/max
|
||||
const minRange = Math.min(rangeStart, rangeEnd);
|
||||
const maxRange = Math.max(rangeStart, rangeEnd);
|
||||
const nextSelectedIds = orderedItems.reduce(
|
||||
(acc: LibraryItem["id"][], item, idx) => {
|
||||
if (
|
||||
(idx >= rangeStart && idx <= rangeEnd) ||
|
||||
(idx >= minRange && idx <= maxRange) ||
|
||||
selectedItemsMap.has(item.id)
|
||||
) {
|
||||
acc.push(item.id);
|
||||
@@ -127,7 +153,6 @@ export default function LibraryMenuItems({
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
onSelectItems(nextSelectedIds);
|
||||
} else {
|
||||
onSelectItems([...selectedItems, id]);
|
||||
@@ -147,6 +172,14 @@ export default function LibraryMenuItems({
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// if selection is removed (e.g. via esc), reset last selected item
|
||||
// so that subsequent shift+clicks don't select a large range
|
||||
if (!selectedItems.length) {
|
||||
setLastSelectedItem(null);
|
||||
}
|
||||
}, [selectedItems]);
|
||||
|
||||
const getInsertedElements = useCallback(
|
||||
(id: string) => {
|
||||
let targetElements;
|
||||
@@ -175,12 +208,17 @@ export default function LibraryMenuItems({
|
||||
|
||||
const onItemDrag = useCallback(
|
||||
(id: LibraryItem["id"], event: React.DragEvent) => {
|
||||
// we want to serialize just the ids so the operation is fast and there's
|
||||
// no race condition if people drop the library items on canvas too fast
|
||||
const data: ExcalidrawLibraryIds = {
|
||||
itemIds: selectedItems.includes(id) ? selectedItems : [id],
|
||||
};
|
||||
event.dataTransfer.setData(
|
||||
MIME_TYPES.excalidrawlib,
|
||||
serializeLibraryAsJSON(getInsertedElements(id)),
|
||||
MIME_TYPES.excalidrawlibIds,
|
||||
JSON.stringify(data),
|
||||
);
|
||||
},
|
||||
[getInsertedElements],
|
||||
[selectedItems],
|
||||
);
|
||||
|
||||
const isItemSelected = useCallback(
|
||||
@@ -188,7 +226,6 @@ export default function LibraryMenuItems({
|
||||
if (!id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return selectedItems.includes(id);
|
||||
},
|
||||
[selectedItems],
|
||||
@@ -208,10 +245,136 @@ export default function LibraryMenuItems({
|
||||
);
|
||||
|
||||
const itemsRenderedPerBatch =
|
||||
svgCache.size >= libraryItems.length
|
||||
svgCache.size >=
|
||||
(filteredItems.length ? filteredItems : libraryItems).length
|
||||
? CACHED_ITEMS_RENDERED_PER_BATCH
|
||||
: ITEMS_RENDERED_PER_BATCH;
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
// focus could be stolen by tab trigger button
|
||||
nextAnimationFrame(() => {
|
||||
searchInputRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const JSX_whenNotSearching = !IS_SEARCHING && (
|
||||
<>
|
||||
{!IS_LIBRARY_EMPTY && (
|
||||
<div className="library-menu-items-container__header">
|
||||
{t("labels.personalLib")}
|
||||
</div>
|
||||
)}
|
||||
{!pendingElements.length && !unpublishedItems.length ? (
|
||||
<div className="library-menu-items__no-items">
|
||||
{!publishedItems.length && (
|
||||
<div className="library-menu-items__no-items__label">
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
)}
|
||||
<div className="library-menu-items__no-items__hint">
|
||||
{publishedItems.length > 0
|
||||
? t("library.hint_emptyPrivateLibrary")
|
||||
: t("library.hint_emptyLibrary")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuSectionGrid>
|
||||
{pendingElements.length > 0 && (
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={[{ id: null, elements: pendingElements }]}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onAddToLibraryClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
)}
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={unpublishedItems}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onItemClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
</LibraryMenuSectionGrid>
|
||||
)}
|
||||
|
||||
{publishedItems.length > 0 && (
|
||||
<div
|
||||
className="library-menu-items-container__header"
|
||||
style={{ marginTop: "0.75rem" }}
|
||||
>
|
||||
{t("labels.excalidrawLib")}
|
||||
</div>
|
||||
)}
|
||||
{publishedItems.length > 0 && (
|
||||
<LibraryMenuSectionGrid>
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={publishedItems}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onItemClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
</LibraryMenuSectionGrid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const JSX_whenSearching = IS_SEARCHING && (
|
||||
<>
|
||||
<div className="library-menu-items-container__header">
|
||||
{t("library.search.heading")}
|
||||
{!isLoading && (
|
||||
<div
|
||||
className="library-menu-items-container__header__hint"
|
||||
style={{ cursor: "pointer" }}
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onClick={(event) => {
|
||||
setSearchInputValue("");
|
||||
}}
|
||||
>
|
||||
<kbd>esc</kbd> to clear
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filteredItems.length > 0 ? (
|
||||
<LibraryMenuSectionGrid>
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={filteredItems}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onItemClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
</LibraryMenuSectionGrid>
|
||||
) : (
|
||||
<div className="library-menu-items__no-items">
|
||||
<div className="library-menu-items__no-items__hint">
|
||||
{t("library.search.noResults")}
|
||||
</div>
|
||||
<Button
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onSelect={() => {
|
||||
setSearchInputValue("");
|
||||
}}
|
||||
style={{ width: "auto", marginTop: "1rem" }}
|
||||
>
|
||||
{t("library.search.clearSearch")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="library-menu-items-container"
|
||||
@@ -223,127 +386,58 @@ export default function LibraryMenuItems({
|
||||
: { borderBottom: 0 }
|
||||
}
|
||||
>
|
||||
{!isLibraryEmpty && (
|
||||
<div className="library-menu-items-header">
|
||||
{!IS_LIBRARY_EMPTY && (
|
||||
<TextField
|
||||
ref={searchInputRef}
|
||||
type="search"
|
||||
className={clsx("library-menu-items-container__search", {
|
||||
hideCancelButton: !device.editor.isMobile,
|
||||
})}
|
||||
placeholder={t("library.search.inputPlaceholder")}
|
||||
value={searchInputValue}
|
||||
onChange={(value) => setSearchInputValue(value)}
|
||||
/>
|
||||
)}
|
||||
<LibraryDropdownMenu
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
className="library-menu-dropdown-container--in-heading"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Stack.Col
|
||||
className="library-menu-items-container__items"
|
||||
align="start"
|
||||
gap={1}
|
||||
style={{
|
||||
flex: publishedItems.length > 0 ? 1 : "0 1 auto",
|
||||
marginBottom: 0,
|
||||
margin: IS_LIBRARY_EMPTY ? "auto" : 0,
|
||||
}}
|
||||
ref={libraryContainerRef}
|
||||
>
|
||||
<>
|
||||
{!isLibraryEmpty && (
|
||||
<div className="library-menu-items-container__header">
|
||||
{t("labels.personalLib")}
|
||||
</div>
|
||||
)}
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "var(--container-padding-y)",
|
||||
right: "var(--container-padding-x)",
|
||||
transform: "translateY(50%)",
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{!pendingElements.length && !unpublishedItems.length ? (
|
||||
<div className="library-menu-items__no-items">
|
||||
<div className="library-menu-items__no-items__label">
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
<div className="library-menu-items__no-items__hint">
|
||||
{publishedItems.length > 0
|
||||
? t("library.hint_emptyPrivateLibrary")
|
||||
: t("library.hint_emptyLibrary")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<LibraryMenuSectionGrid>
|
||||
{pendingElements.length > 0 && (
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={[{ id: null, elements: pendingElements }]}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onAddToLibraryClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
)}
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={unpublishedItems}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onItemClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
</LibraryMenuSectionGrid>
|
||||
)}
|
||||
</>
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "var(--container-padding-y)",
|
||||
right: "var(--container-padding-x)",
|
||||
transform: "translateY(50%)",
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<>
|
||||
{(publishedItems.length > 0 ||
|
||||
pendingElements.length > 0 ||
|
||||
unpublishedItems.length > 0) && (
|
||||
<div className="library-menu-items-container__header library-menu-items-container__header--excal">
|
||||
{t("labels.excalidrawLib")}
|
||||
</div>
|
||||
)}
|
||||
{publishedItems.length > 0 ? (
|
||||
<LibraryMenuSectionGrid>
|
||||
<LibraryMenuSection
|
||||
itemsRenderedPerBatch={itemsRenderedPerBatch}
|
||||
items={publishedItems}
|
||||
onItemSelectToggle={onItemSelectToggle}
|
||||
onItemDrag={onItemDrag}
|
||||
onClick={onItemClick}
|
||||
isItemSelected={isItemSelected}
|
||||
svgCache={svgCache}
|
||||
/>
|
||||
</LibraryMenuSectionGrid>
|
||||
) : unpublishedItems.length > 0 ? (
|
||||
<div
|
||||
style={{
|
||||
margin: "1rem 0",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "100%",
|
||||
fontSize: ".9rem",
|
||||
}}
|
||||
>
|
||||
{t("library.noItems")}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
{JSX_whenNotSearching}
|
||||
{JSX_whenSearching}
|
||||
|
||||
{showBtn && (
|
||||
{IS_LIBRARY_EMPTY && (
|
||||
<LibraryMenuControlButtons
|
||||
style={{ padding: "16px 0", width: "100%" }}
|
||||
id={id}
|
||||
libraryReturnUrl={libraryReturnUrl}
|
||||
theme={theme}
|
||||
>
|
||||
<LibraryDropdownMenu
|
||||
selectedItems={selectedItems}
|
||||
onSelectItems={onSelectItems}
|
||||
/>
|
||||
</LibraryMenuControlButtons>
|
||||
/>
|
||||
)}
|
||||
</Stack.Col>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { SvgCache } from "../hooks/useLibraryItemSvg";
|
||||
import type { LibraryItem } from "../types";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type LibraryOrPendingItem = (
|
||||
type LibraryOrPendingItem = readonly (
|
||||
| LibraryItem
|
||||
| /* pending library item */ {
|
||||
id: null;
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
}
|
||||
|
||||
&--hover {
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--color-surface-mid);
|
||||
}
|
||||
|
||||
&:active:not(:has(.library-unit__checkbox:hover)),
|
||||
&--selected {
|
||||
border-color: var(--color-primary);
|
||||
border-width: 1px;
|
||||
background-color: var(--color-surface-high);
|
||||
}
|
||||
|
||||
&--skeleton {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import clsx from "clsx";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { memo, useRef, useState } from "react";
|
||||
|
||||
import { useLibraryItemSvg } from "../hooks/useLibraryItemSvg";
|
||||
|
||||
@@ -33,23 +33,7 @@ export const LibraryUnit = memo(
|
||||
svgCache: SvgCache;
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const svg = useLibraryItemSvg(id, elements, svgCache);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (svg) {
|
||||
node.innerHTML = svg.outerHTML;
|
||||
}
|
||||
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [svg]);
|
||||
const svg = useLibraryItemSvg(id, elements, svgCache, ref);
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useDevice().editor.isMobile;
|
||||
|
||||
@@ -1,32 +1,23 @@
|
||||
import React from "react";
|
||||
|
||||
import { showSelectedShapeActions } from "@excalidraw/element";
|
||||
|
||||
import type { NonDeletedExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
import { t } from "../i18n";
|
||||
import { calculateScrollCenter } from "../scene";
|
||||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
|
||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
||||
import { MobileShapeActions } from "./Actions";
|
||||
import { MobileToolBar } from "./MobileToolBar";
|
||||
import { FixedSideContainer } from "./FixedSideContainer";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { HintViewer } from "./HintViewer";
|
||||
|
||||
import { Island } from "./Island";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Section } from "./Section";
|
||||
import Stack from "./Stack";
|
||||
|
||||
import type { ActionManager } from "../actions/manager";
|
||||
import type {
|
||||
AppClassProperties,
|
||||
AppProps,
|
||||
AppState,
|
||||
Device,
|
||||
ExcalidrawProps,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import type { JSX } from "react";
|
||||
@@ -38,7 +29,6 @@ type MobileMenuProps = {
|
||||
renderImageExportDialog: () => React.ReactNode;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onLockToggle: () => void;
|
||||
onHandToolToggle: () => void;
|
||||
onPenModeToggle: AppClassProperties["togglePenMode"];
|
||||
|
||||
@@ -46,9 +36,11 @@ type MobileMenuProps = {
|
||||
isMobile: boolean,
|
||||
appState: UIAppState,
|
||||
) => JSX.Element | null;
|
||||
renderCustomStats?: ExcalidrawProps["renderCustomStats"];
|
||||
renderTopLeftUI?: (
|
||||
isMobile: boolean,
|
||||
appState: UIAppState,
|
||||
) => JSX.Element | null;
|
||||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderWelcomeScreen: boolean;
|
||||
UIOptions: AppProps["UIOptions"];
|
||||
app: AppClassProperties;
|
||||
@@ -59,14 +51,10 @@ export const MobileMenu = ({
|
||||
elements,
|
||||
actionManager,
|
||||
setAppState,
|
||||
onLockToggle,
|
||||
onHandToolToggle,
|
||||
onPenModeToggle,
|
||||
|
||||
renderTopLeftUI,
|
||||
renderTopRightUI,
|
||||
renderCustomStats,
|
||||
renderSidebars,
|
||||
device,
|
||||
renderWelcomeScreen,
|
||||
UIOptions,
|
||||
app,
|
||||
@@ -76,141 +64,98 @@ export const MobileMenu = ({
|
||||
MainMenuTunnel,
|
||||
DefaultSidebarTriggerTunnel,
|
||||
} = useTunnels();
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
||||
<Section heading="shapes">
|
||||
{(heading: React.ReactNode) => (
|
||||
<Stack.Col gap={4} align="center">
|
||||
<Stack.Row gap={1} className="App-toolbar-container">
|
||||
<Island padding={1} className="App-toolbar App-toolbar--mobile">
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
<ShapesSwitcher
|
||||
appState={appState}
|
||||
activeTool={appState.activeTool}
|
||||
UIOptions={UIOptions}
|
||||
app={app}
|
||||
/>
|
||||
</Stack.Row>
|
||||
</Island>
|
||||
{renderTopRightUI && renderTopRightUI(true, appState)}
|
||||
<div className="mobile-misc-tools-container">
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||
<DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
<PenModeButton
|
||||
checked={appState.penMode}
|
||||
onChange={() => onPenModeToggle(null)}
|
||||
title={t("toolBar.penMode")}
|
||||
isMobile
|
||||
penDetected={appState.penDetected}
|
||||
/>
|
||||
<LockButton
|
||||
checked={appState.activeTool.locked}
|
||||
onChange={onLockToggle}
|
||||
title={t("toolBar.lock")}
|
||||
isMobile
|
||||
/>
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
onChange={() => onHandToolToggle()}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
</div>
|
||||
</Stack.Row>
|
||||
</Stack.Col>
|
||||
)}
|
||||
</Section>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
isMobile={true}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
</FixedSideContainer>
|
||||
const renderAppTopBar = () => {
|
||||
const topRightUI = renderTopRightUI?.(true, appState) ?? (
|
||||
<DefaultSidebarTriggerTunnel.Out />
|
||||
);
|
||||
|
||||
const topLeftUI = (
|
||||
<div className="excalidraw-ui-top-left">
|
||||
{renderTopLeftUI?.(true, appState)}
|
||||
<MainMenuTunnel.Out />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAppToolbar = () => {
|
||||
if (
|
||||
appState.viewModeEnabled ||
|
||||
appState.openDialog?.name === "elementLinkSelector"
|
||||
) {
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<MainMenuTunnel.Out />
|
||||
</div>
|
||||
);
|
||||
return <div className="App-toolbar-content">{topLeftUI}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="App-toolbar-content">
|
||||
<MainMenuTunnel.Out />
|
||||
{actionManager.renderAction("toggleEditMenu")}
|
||||
{actionManager.renderAction(
|
||||
appState.multiElement ? "finalize" : "duplicateSelection",
|
||||
)}
|
||||
{actionManager.renderAction("deleteSelectedElements")}
|
||||
<div>
|
||||
{actionManager.renderAction("undo")}
|
||||
{actionManager.renderAction("redo")}
|
||||
</div>
|
||||
<div
|
||||
className="App-toolbar-content"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
{topLeftUI}
|
||||
{topRightUI}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderToolbar = () => {
|
||||
return (
|
||||
<MobileToolBar
|
||||
app={app}
|
||||
onHandToolToggle={onHandToolToggle}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderSidebars()}
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
renderToolbar()}
|
||||
{/* welcome screen, bottom bar, and top bar all have the same z-index */}
|
||||
{/* ordered in this reverse order so that top bar is on top */}
|
||||
<div className="App-welcome-screen">
|
||||
{renderWelcomeScreen && <WelcomeScreenCenterTunnel.Out />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginLeft: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginRight: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN * 2,
|
||||
marginBottom: SCROLLBAR_WIDTH + SCROLLBAR_MARGIN,
|
||||
}}
|
||||
>
|
||||
<Island padding={0}>
|
||||
{appState.openMenu === "shape" &&
|
||||
!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
showSelectedShapeActions(appState, elements) ? (
|
||||
<Section className="App-mobile-menu" heading="selectedShapeActions">
|
||||
<SelectedShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
/>
|
||||
</Section>
|
||||
) : null}
|
||||
<footer className="App-toolbar">
|
||||
{renderAppToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
!appState.openSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
<MobileShapeActions
|
||||
appState={appState}
|
||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||
renderAction={actionManager.renderAction}
|
||||
app={app}
|
||||
setAppState={setAppState}
|
||||
/>
|
||||
|
||||
<Island className="App-toolbar">
|
||||
{!appState.viewModeEnabled &&
|
||||
appState.openDialog?.name !== "elementLinkSelector" &&
|
||||
renderToolbar()}
|
||||
{appState.scrolledOutside &&
|
||||
!appState.openMenu &&
|
||||
!appState.openSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
...calculateScrollCenter(elements, appState),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t("buttons.scrollBackToContent")}
|
||||
</button>
|
||||
)}
|
||||
</Island>
|
||||
</div>
|
||||
|
||||
<FixedSideContainer side="top" className="App-top-bar">
|
||||
{renderAppTopBar()}
|
||||
</FixedSideContainer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
@import "open-color/open-color.scss";
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.mobile-toolbar {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
padding: 0px;
|
||||
gap: 4px;
|
||||
border-radius: var(--space-factor);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mobile-toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-toolbar .ToolIcon {
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.ToolIcon__icon {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(
|
||||
--color-surface-primary-container,
|
||||
var(--island-bg-color)
|
||||
);
|
||||
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-toolbar .App-toolbar__extra-tools-dropdown {
|
||||
min-width: 160px;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
}
|
||||
|
||||
.mobile-toolbar-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--default-border-color);
|
||||
margin: 0 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mobile-toolbar-undo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mobile-toolbar-undo .ToolIcon {
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { KEYS, capitalizeString } from "@excalidraw/common";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { isHandToolActive } from "../appState";
|
||||
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
||||
import { HandButton } from "./HandButton";
|
||||
import { ToolButton } from "./ToolButton";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { ToolPopover } from "./ToolPopover";
|
||||
|
||||
import {
|
||||
SelectionIcon,
|
||||
FreedrawIcon,
|
||||
EraserIcon,
|
||||
RectangleIcon,
|
||||
ArrowIcon,
|
||||
extraToolsIcon,
|
||||
DiamondIcon,
|
||||
EllipseIcon,
|
||||
LineIcon,
|
||||
TextIcon,
|
||||
ImageIcon,
|
||||
frameToolIcon,
|
||||
EmbedIcon,
|
||||
laserPointerToolIcon,
|
||||
LassoIcon,
|
||||
mermaidLogoIcon,
|
||||
MagicIcon,
|
||||
} from "./icons";
|
||||
|
||||
import "./ToolIcon.scss";
|
||||
import "./MobileToolBar.scss";
|
||||
|
||||
import type { AppClassProperties, ToolType, UIAppState } from "../types";
|
||||
|
||||
const SHAPE_TOOLS = [
|
||||
{
|
||||
type: "rectangle",
|
||||
icon: RectangleIcon,
|
||||
title: capitalizeString(t("toolBar.rectangle")),
|
||||
},
|
||||
{
|
||||
type: "diamond",
|
||||
icon: DiamondIcon,
|
||||
title: capitalizeString(t("toolBar.diamond")),
|
||||
},
|
||||
{
|
||||
type: "ellipse",
|
||||
icon: EllipseIcon,
|
||||
title: capitalizeString(t("toolBar.ellipse")),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SELECTION_TOOLS = [
|
||||
{
|
||||
type: "selection",
|
||||
icon: SelectionIcon,
|
||||
title: capitalizeString(t("toolBar.selection")),
|
||||
},
|
||||
{
|
||||
type: "lasso",
|
||||
icon: LassoIcon,
|
||||
title: capitalizeString(t("toolBar.lasso")),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const LINEAR_ELEMENT_TOOLS = [
|
||||
{
|
||||
type: "arrow",
|
||||
icon: ArrowIcon,
|
||||
title: capitalizeString(t("toolBar.arrow")),
|
||||
},
|
||||
{ type: "line", icon: LineIcon, title: capitalizeString(t("toolBar.line")) },
|
||||
] as const;
|
||||
|
||||
type MobileToolBarProps = {
|
||||
app: AppClassProperties;
|
||||
onHandToolToggle: () => void;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
};
|
||||
|
||||
export const MobileToolBar = ({
|
||||
app,
|
||||
onHandToolToggle,
|
||||
setAppState,
|
||||
}: MobileToolBarProps) => {
|
||||
const activeTool = app.state.activeTool;
|
||||
const [isOtherShapesMenuOpen, setIsOtherShapesMenuOpen] = useState(false);
|
||||
const [lastActiveGenericShape, setLastActiveGenericShape] = useState<
|
||||
"rectangle" | "diamond" | "ellipse"
|
||||
>("rectangle");
|
||||
const [lastActiveLinearElement, setLastActiveLinearElement] = useState<
|
||||
"arrow" | "line"
|
||||
>("arrow");
|
||||
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// keep lastActiveGenericShape in sync with active tool if user switches via other UI
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTool.type === "rectangle" ||
|
||||
activeTool.type === "diamond" ||
|
||||
activeTool.type === "ellipse"
|
||||
) {
|
||||
setLastActiveGenericShape(activeTool.type);
|
||||
}
|
||||
}, [activeTool.type]);
|
||||
|
||||
// keep lastActiveLinearElement in sync with active tool if user switches via other UI
|
||||
useEffect(() => {
|
||||
if (activeTool.type === "arrow" || activeTool.type === "line") {
|
||||
setLastActiveLinearElement(activeTool.type);
|
||||
}
|
||||
}, [activeTool.type]);
|
||||
|
||||
const frameToolSelected = activeTool.type === "frame";
|
||||
const laserToolSelected = activeTool.type === "laser";
|
||||
const embeddableToolSelected = activeTool.type === "embeddable";
|
||||
|
||||
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||
|
||||
const handleToolChange = (toolType: string, pointerType?: string) => {
|
||||
if (app.state.activeTool.type !== toolType) {
|
||||
trackEvent("toolbar", toolType, "ui");
|
||||
}
|
||||
|
||||
if (toolType === "selection") {
|
||||
if (app.state.activeTool.type === "selection") {
|
||||
// Toggle selection tool behavior if needed
|
||||
} else {
|
||||
app.setActiveTool({ type: "selection" });
|
||||
}
|
||||
} else {
|
||||
app.setActiveTool({ type: toolType as ToolType });
|
||||
}
|
||||
};
|
||||
|
||||
const toolbarWidth =
|
||||
toolbarRef.current?.getBoundingClientRect()?.width ?? 0 - 8;
|
||||
const WIDTH = 36;
|
||||
const GAP = 4;
|
||||
|
||||
// hand, selection, freedraw, eraser, rectangle, arrow, others
|
||||
const MIN_TOOLS = 7;
|
||||
const MIN_WIDTH = MIN_TOOLS * WIDTH + (MIN_TOOLS - 1) * GAP;
|
||||
const ADDITIONAL_WIDTH = WIDTH + GAP;
|
||||
|
||||
const showTextToolOutside = toolbarWidth >= MIN_WIDTH + 1 * ADDITIONAL_WIDTH;
|
||||
const showImageToolOutside = toolbarWidth >= MIN_WIDTH + 2 * ADDITIONAL_WIDTH;
|
||||
const showFrameToolOutside = toolbarWidth >= MIN_WIDTH + 3 * ADDITIONAL_WIDTH;
|
||||
|
||||
const extraTools = [
|
||||
"text",
|
||||
"frame",
|
||||
"embeddable",
|
||||
"laser",
|
||||
"magicframe",
|
||||
].filter((tool) => {
|
||||
if (showImageToolOutside && tool === "image") {
|
||||
return false;
|
||||
}
|
||||
if (showFrameToolOutside && tool === "frame") {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const extraToolSelected = extraTools.includes(activeTool.type);
|
||||
const extraIcon = extraToolSelected
|
||||
? activeTool.type === "frame"
|
||||
? frameToolIcon
|
||||
: activeTool.type === "embeddable"
|
||||
? EmbedIcon
|
||||
: activeTool.type === "laser"
|
||||
? laserPointerToolIcon
|
||||
: activeTool.type === "text"
|
||||
? TextIcon
|
||||
: activeTool.type === "magicframe"
|
||||
? MagicIcon
|
||||
: extraToolsIcon
|
||||
: extraToolsIcon;
|
||||
|
||||
return (
|
||||
<div className="mobile-toolbar" ref={toolbarRef}>
|
||||
{/* Hand Tool */}
|
||||
<HandButton
|
||||
checked={isHandToolActive(app.state)}
|
||||
onChange={onHandToolToggle}
|
||||
title={t("toolBar.hand")}
|
||||
isMobile
|
||||
/>
|
||||
|
||||
{/* Selection Tool */}
|
||||
<ToolPopover
|
||||
app={app}
|
||||
options={SELECTION_TOOLS}
|
||||
activeTool={activeTool}
|
||||
defaultOption={app.state.preferredSelectionTool.type}
|
||||
namePrefix="selectionType"
|
||||
title={capitalizeString(t("toolBar.selection"))}
|
||||
data-testid="toolbar-selection"
|
||||
onToolChange={(type: string) => {
|
||||
if (type === "selection" || type === "lasso") {
|
||||
app.setActiveTool({ type });
|
||||
setAppState({
|
||||
preferredSelectionTool: { type, initialized: true },
|
||||
});
|
||||
}
|
||||
}}
|
||||
displayedOption={
|
||||
SELECTION_TOOLS.find(
|
||||
(tool) => tool.type === app.state.preferredSelectionTool.type,
|
||||
) || SELECTION_TOOLS[0]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Free Draw */}
|
||||
<ToolButton
|
||||
className={clsx({
|
||||
active: activeTool.type === "freedraw",
|
||||
})}
|
||||
type="radio"
|
||||
icon={FreedrawIcon}
|
||||
checked={activeTool.type === "freedraw"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(t("toolBar.freedraw"))}`}
|
||||
aria-label={capitalizeString(t("toolBar.freedraw"))}
|
||||
data-testid="toolbar-freedraw"
|
||||
onChange={() => handleToolChange("freedraw")}
|
||||
/>
|
||||
|
||||
{/* Eraser */}
|
||||
<ToolButton
|
||||
className={clsx({
|
||||
active: activeTool.type === "eraser",
|
||||
})}
|
||||
type="radio"
|
||||
icon={EraserIcon}
|
||||
checked={activeTool.type === "eraser"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(t("toolBar.eraser"))}`}
|
||||
aria-label={capitalizeString(t("toolBar.eraser"))}
|
||||
data-testid="toolbar-eraser"
|
||||
onChange={() => handleToolChange("eraser")}
|
||||
/>
|
||||
|
||||
{/* Rectangle */}
|
||||
<ToolPopover
|
||||
app={app}
|
||||
options={SHAPE_TOOLS}
|
||||
activeTool={activeTool}
|
||||
defaultOption={lastActiveGenericShape}
|
||||
namePrefix="shapeType"
|
||||
title={capitalizeString(
|
||||
t(
|
||||
lastActiveGenericShape === "rectangle"
|
||||
? "toolBar.rectangle"
|
||||
: lastActiveGenericShape === "diamond"
|
||||
? "toolBar.diamond"
|
||||
: lastActiveGenericShape === "ellipse"
|
||||
? "toolBar.ellipse"
|
||||
: "toolBar.rectangle",
|
||||
),
|
||||
)}
|
||||
data-testid="toolbar-rectangle"
|
||||
onToolChange={(type: string) => {
|
||||
if (
|
||||
type === "rectangle" ||
|
||||
type === "diamond" ||
|
||||
type === "ellipse"
|
||||
) {
|
||||
setLastActiveGenericShape(type);
|
||||
app.setActiveTool({ type });
|
||||
}
|
||||
}}
|
||||
displayedOption={
|
||||
SHAPE_TOOLS.find((tool) => tool.type === lastActiveGenericShape) ||
|
||||
SHAPE_TOOLS[0]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Arrow/Line */}
|
||||
<ToolPopover
|
||||
app={app}
|
||||
options={LINEAR_ELEMENT_TOOLS}
|
||||
activeTool={activeTool}
|
||||
defaultOption={lastActiveLinearElement}
|
||||
namePrefix="linearElementType"
|
||||
title={capitalizeString(
|
||||
t(
|
||||
lastActiveLinearElement === "arrow"
|
||||
? "toolBar.arrow"
|
||||
: "toolBar.line",
|
||||
),
|
||||
)}
|
||||
data-testid="toolbar-arrow"
|
||||
fillable={true}
|
||||
onToolChange={(type: string) => {
|
||||
if (type === "arrow" || type === "line") {
|
||||
setLastActiveLinearElement(type);
|
||||
app.setActiveTool({ type });
|
||||
}
|
||||
}}
|
||||
displayedOption={
|
||||
LINEAR_ELEMENT_TOOLS.find(
|
||||
(tool) => tool.type === lastActiveLinearElement,
|
||||
) || LINEAR_ELEMENT_TOOLS[0]
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Text Tool */}
|
||||
{showTextToolOutside && (
|
||||
<ToolButton
|
||||
className={clsx({
|
||||
active: activeTool.type === "text",
|
||||
})}
|
||||
type="radio"
|
||||
icon={TextIcon}
|
||||
checked={activeTool.type === "text"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(t("toolBar.text"))}`}
|
||||
aria-label={capitalizeString(t("toolBar.text"))}
|
||||
data-testid="toolbar-text"
|
||||
onChange={() => handleToolChange("text")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
{showImageToolOutside && (
|
||||
<ToolButton
|
||||
className={clsx({
|
||||
active: activeTool.type === "image",
|
||||
})}
|
||||
type="radio"
|
||||
icon={ImageIcon}
|
||||
checked={activeTool.type === "image"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(t("toolBar.image"))}`}
|
||||
aria-label={capitalizeString(t("toolBar.image"))}
|
||||
data-testid="toolbar-image"
|
||||
onChange={() => handleToolChange("image")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Frame Tool */}
|
||||
{showFrameToolOutside && (
|
||||
<ToolButton
|
||||
className={clsx({ active: frameToolSelected })}
|
||||
type="radio"
|
||||
icon={frameToolIcon}
|
||||
checked={frameToolSelected}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(t("toolBar.frame"))}`}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
data-testid="toolbar-frame"
|
||||
onChange={() => handleToolChange("frame")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Other Shapes */}
|
||||
<DropdownMenu open={isOtherShapesMenuOpen} placement="top">
|
||||
<DropdownMenu.Trigger
|
||||
className={clsx(
|
||||
"App-toolbar__extra-tools-trigger App-toolbar__extra-tools-trigger--mobile",
|
||||
{
|
||||
"App-toolbar__extra-tools-trigger--selected":
|
||||
extraToolSelected || isOtherShapesMenuOpen,
|
||||
},
|
||||
)}
|
||||
onToggle={() => {
|
||||
setIsOtherShapesMenuOpen(!isOtherShapesMenuOpen);
|
||||
setAppState({ openMenu: null, openPopup: null });
|
||||
}}
|
||||
title={t("toolBar.extraTools")}
|
||||
style={{
|
||||
width: WIDTH,
|
||||
height: WIDTH,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{extraIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsOtherShapesMenuOpen(false)}
|
||||
onSelect={() => setIsOtherShapesMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
>
|
||||
{!showTextToolOutside && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "text" })}
|
||||
icon={TextIcon}
|
||||
shortcut={KEYS.T.toLocaleUpperCase()}
|
||||
data-testid="toolbar-text"
|
||||
selected={activeTool.type === "text"}
|
||||
>
|
||||
{t("toolBar.text")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{!showImageToolOutside && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "image" })}
|
||||
icon={ImageIcon}
|
||||
data-testid="toolbar-image"
|
||||
selected={activeTool.type === "image"}
|
||||
>
|
||||
{t("toolBar.image")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
{!showFrameToolOutside && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "frame" })}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
selected={frameToolSelected}
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "embeddable" })}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
selected={embeddableToolSelected}
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setActiveTool({ type: "laser" })}
|
||||
icon={laserPointerToolIcon}
|
||||
data-testid="toolbar-laser"
|
||||
selected={laserToolSelected}
|
||||
shortcut={KEYS.K.toLocaleUpperCase()}
|
||||
>
|
||||
{t("toolBar.laser")}
|
||||
</DropdownMenu.Item>
|
||||
<div style={{ margin: "6px 0", fontSize: 14, fontWeight: 600 }}>
|
||||
Generate
|
||||
</div>
|
||||
{app.props.aiEnabled !== false && <TTDDialogTriggerTunnel.Out />}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.setOpenDialog({ name: "ttd", tab: "mermaid" })}
|
||||
icon={mermaidLogoIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.mermaidToExcalidraw")}
|
||||
</DropdownMenu.Item>
|
||||
{app.props.aiEnabled !== false && app.plugins.diagramToCode && (
|
||||
<>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => app.onMagicframeToolSelect()}
|
||||
icon={MagicIcon}
|
||||
data-testid="toolbar-magicframe"
|
||||
>
|
||||
{t("toolBar.magicframe")}
|
||||
<DropdownMenu.Item.Badge>AI</DropdownMenu.Item.Badge>
|
||||
</DropdownMenu.Item>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import { unstable_batchedUpdates } from "react-dom";
|
||||
|
||||
import { KEYS, queryFocusableElements } from "@excalidraw/common";
|
||||
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./Popover.scss";
|
||||
|
||||
type Props = {
|
||||
@@ -15,6 +17,7 @@ type Props = {
|
||||
offsetTop?: number;
|
||||
viewportWidth?: number;
|
||||
viewportHeight?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Popover = ({
|
||||
@@ -27,6 +30,7 @@ export const Popover = ({
|
||||
offsetTop = 0,
|
||||
viewportWidth = window.innerWidth,
|
||||
viewportHeight = window.innerHeight,
|
||||
className,
|
||||
}: Props) => {
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -146,7 +150,7 @@ export const Popover = ({
|
||||
}, [onCloseRequest]);
|
||||
|
||||
return (
|
||||
<div className="popover" ref={popoverRef} tabIndex={-1}>
|
||||
<div className={clsx("popover", className)} ref={popoverRef} tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ export const PropertiesPopover = React.forwardRef<
|
||||
ref,
|
||||
) => {
|
||||
const device = useDevice();
|
||||
const isMobilePortrait =
|
||||
device.editor.isMobile && !device.viewport.isLandscape;
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
@@ -47,20 +49,14 @@ export const PropertiesPopover = React.forwardRef<
|
||||
ref={ref}
|
||||
className={clsx("focus-visible-none", className)}
|
||||
data-prevent-outside-click
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
side={isMobilePortrait ? "bottom" : "right"}
|
||||
align={isMobilePortrait ? "center" : "start"}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
collisionBoundary={container ?? undefined}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-popup)",
|
||||
zIndex: "var(--zIndex-ui-styles-popup)",
|
||||
marginLeft: device.editor.isMobile ? "0.5rem" : undefined,
|
||||
}}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
|
||||
@@ -518,7 +518,7 @@ const PublishLibrary = ({
|
||||
</div>
|
||||
<div className="publish-library__buttons">
|
||||
<DialogActionButton
|
||||
label={t("buttons.cancel")}
|
||||
label={t("buttons.saveLibNames")}
|
||||
onClick={onDialogClose}
|
||||
data-testid="cancel-clear-canvas-button"
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
z-index: var(--zIndex-ui-library);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -9,7 +9,13 @@ import React, {
|
||||
useCallback,
|
||||
} from "react";
|
||||
|
||||
import { EVENT, isDevEnv, KEYS, updateObject } from "@excalidraw/common";
|
||||
import {
|
||||
CLASSES,
|
||||
EVENT,
|
||||
isDevEnv,
|
||||
KEYS,
|
||||
updateObject,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { atom, useSetAtom } from "../../editor-jotai";
|
||||
@@ -137,7 +143,11 @@ export const SidebarInner = forwardRef(
|
||||
return (
|
||||
<Island
|
||||
{...rest}
|
||||
className={clsx("sidebar", { "sidebar--docked": docked }, className)}
|
||||
className={clsx(
|
||||
CLASSES.SIDEBAR,
|
||||
{ "sidebar--docked": docked },
|
||||
className,
|
||||
)}
|
||||
ref={islandRef}
|
||||
>
|
||||
<SidebarPropsContext.Provider value={headerPropsRef.current}>
|
||||
|
||||
@@ -30,7 +30,11 @@ export const SidebarTrigger = ({
|
||||
.querySelector(".layer-ui__wrapper")
|
||||
?.classList.remove("animate");
|
||||
const isOpen = event.target.checked;
|
||||
setAppState({ openSidebar: isOpen ? { name, tab } : null });
|
||||
setAppState({
|
||||
openSidebar: isOpen ? { name, tab } : null,
|
||||
openMenu: null,
|
||||
openPopup: null,
|
||||
});
|
||||
onToggle?.(isOpen);
|
||||
}}
|
||||
checked={appState.openSidebar?.name === name}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getShortcutKey } from "@excalidraw/common";
|
||||
import { getShortcutKey } from "@excalidraw/excalidraw/shortcut";
|
||||
|
||||
export const TTDDialogSubmitShortcut = () => {
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { trackEvent } from "../../analytics";
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { t } from "../../i18n";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useExcalidrawSetAppState } from "../App";
|
||||
import DropdownMenu from "../dropdownMenu/DropdownMenu";
|
||||
import { brainIcon } from "../icons";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import type { JSX } from "react";
|
||||
import type { JSX, ReactNode } from "react";
|
||||
|
||||
export const TTDDialogTrigger = ({
|
||||
children,
|
||||
@@ -15,6 +14,7 @@ export const TTDDialogTrigger = ({
|
||||
children?: ReactNode;
|
||||
icon?: JSX.Element;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { TTDDialogTriggerTunnel } = useTunnels();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
--ExcTextField--border-active: var(--color-brand-active);
|
||||
--ExcTextField--placeholder: var(--color-border-outline-variant);
|
||||
|
||||
&.theme--dark {
|
||||
--ExcTextField--border: var(--color-border-outline-variant);
|
||||
}
|
||||
|
||||
.ExcTextField {
|
||||
position: relative;
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ type TextFieldProps = {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
isRedacted?: boolean;
|
||||
type?: "text" | "search";
|
||||
} & ({ value: string } | { defaultValue: string });
|
||||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
@@ -43,6 +44,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
isRedacted = false,
|
||||
icon,
|
||||
className,
|
||||
type,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
@@ -96,6 +98,7 @@ export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
ref={innerRef}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
type={type}
|
||||
/>
|
||||
{isRedacted && (
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.tool-popover-content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--island-bg-color);
|
||||
box-shadow: var(--shadow-island);
|
||||
padding: 0.5rem;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { capitalizeString } from "@excalidraw/common";
|
||||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { trackEvent } from "../analytics";
|
||||
|
||||
import { ToolButton } from "./ToolButton";
|
||||
|
||||
import "./ToolPopover.scss";
|
||||
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
import type { AppClassProperties } from "../types";
|
||||
|
||||
type ToolOption = {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ToolPopoverProps = {
|
||||
app: AppClassProperties;
|
||||
options: readonly ToolOption[];
|
||||
activeTool: { type: string };
|
||||
defaultOption: string;
|
||||
className?: string;
|
||||
namePrefix: string;
|
||||
title: string;
|
||||
"data-testid": string;
|
||||
onToolChange: (type: string) => void;
|
||||
displayedOption: ToolOption;
|
||||
fillable?: boolean;
|
||||
};
|
||||
|
||||
export const ToolPopover = ({
|
||||
app,
|
||||
options,
|
||||
activeTool,
|
||||
defaultOption,
|
||||
className = "Shape",
|
||||
namePrefix,
|
||||
title,
|
||||
"data-testid": dataTestId,
|
||||
onToolChange,
|
||||
displayedOption,
|
||||
fillable = false,
|
||||
}: ToolPopoverProps) => {
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
const currentType = activeTool.type;
|
||||
const isActive = displayedOption.type === currentType;
|
||||
const SIDE_OFFSET = 32 / 2 + 10;
|
||||
const { container } = useExcalidrawContainer();
|
||||
|
||||
// if currentType is not in options, close popup
|
||||
if (!options.some((o) => o.type === currentType) && isPopupOpen) {
|
||||
setIsPopupOpen(false);
|
||||
}
|
||||
|
||||
// Close popover when user starts interacting with the canvas (pointer down)
|
||||
useEffect(() => {
|
||||
// app.onPointerDownEmitter emits when pointer down happens on canvas area
|
||||
const unsubscribe = app.onPointerDownEmitter.on(() => {
|
||||
setIsPopupOpen(false);
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [app]);
|
||||
|
||||
return (
|
||||
<Popover.Root open={isPopupOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<ToolButton
|
||||
className={clsx(className, {
|
||||
fillable,
|
||||
active: options.some((o) => o.type === activeTool.type),
|
||||
})}
|
||||
type="radio"
|
||||
icon={displayedOption.icon}
|
||||
checked={isActive}
|
||||
name="editor-current-shape"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
data-testid={dataTestId}
|
||||
onPointerDown={() => {
|
||||
setIsPopupOpen((v) => !v);
|
||||
onToolChange(defaultOption);
|
||||
}}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
className="tool-popover-content"
|
||||
sideOffset={SIDE_OFFSET}
|
||||
collisionBoundary={container ?? undefined}
|
||||
>
|
||||
{options.map(({ type, icon, title }) => (
|
||||
<ToolButton
|
||||
className={clsx(className, {
|
||||
active: currentType === type,
|
||||
})}
|
||||
key={type}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={currentType === type}
|
||||
name={`${namePrefix}-option`}
|
||||
title={title || capitalizeString(type)}
|
||||
keyBindingLabel=""
|
||||
aria-label={title || capitalizeString(type)}
|
||||
data-testid={`toolbar-${type}`}
|
||||
onChange={() => {
|
||||
if (app.state.activeTool.type !== type) {
|
||||
trackEvent("toolbar", type, "ui");
|
||||
}
|
||||
app.setActiveTool({ type: type as any });
|
||||
onToolChange?.(type);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
@@ -44,6 +44,10 @@
|
||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&--selected,
|
||||
&--selected:hover {
|
||||
background: var(--color-primary-light);
|
||||
|
||||
@@ -3,24 +3,45 @@
|
||||
.excalidraw {
|
||||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
top: 2.5rem;
|
||||
margin-top: 0.5rem;
|
||||
max-width: 16rem;
|
||||
|
||||
&--placement-top {
|
||||
top: auto;
|
||||
bottom: 100%;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
&--mobile {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
row-gap: 0.75rem;
|
||||
|
||||
// When main menu is in the top toolbar, position relative to trigger
|
||||
&.main-menu-dropdown {
|
||||
min-width: 232px;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
@media screen and (orientation: landscape) {
|
||||
max-width: 232px;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-container {
|
||||
padding: 8px 8px;
|
||||
box-sizing: border-box;
|
||||
// background-color: var(--island-bg-color);
|
||||
max-height: calc(
|
||||
100svh - var(--editor-container-padding) * 2 - 2.25rem
|
||||
);
|
||||
box-shadow: var(--shadow-island);
|
||||
border-radius: var(--border-radius-lg);
|
||||
position: relative;
|
||||
transition: box-shadow 0.5s ease-in-out;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
|
||||
&.zen-mode {
|
||||
box-shadow: none;
|
||||
@@ -30,7 +51,7 @@
|
||||
|
||||
.dropdown-menu-container {
|
||||
background-color: var(--island-bg-color);
|
||||
max-height: calc(100vh - 150px);
|
||||
|
||||
overflow-y: auto;
|
||||
--gap: 2;
|
||||
}
|
||||
|
||||
@@ -17,16 +17,27 @@ import "./DropdownMenu.scss";
|
||||
const DropdownMenu = ({
|
||||
children,
|
||||
open,
|
||||
placement,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
open: boolean;
|
||||
placement?: "top" | "bottom";
|
||||
}) => {
|
||||
const MenuTriggerComp = getMenuTriggerComponent(children);
|
||||
const MenuContentComp = getMenuContentComponent(children);
|
||||
|
||||
// clone the MenuContentComp to pass the placement prop
|
||||
const MenuContentCompWithPlacement =
|
||||
MenuContentComp && React.isValidElement(MenuContentComp)
|
||||
? React.cloneElement(MenuContentComp as React.ReactElement<any>, {
|
||||
placement,
|
||||
})
|
||||
: MenuContentComp;
|
||||
|
||||
return (
|
||||
<>
|
||||
{MenuTriggerComp}
|
||||
{open && MenuContentComp}
|
||||
{open && MenuContentCompWithPlacement}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ const MenuContent = ({
|
||||
className = "",
|
||||
onSelect,
|
||||
style,
|
||||
placement = "bottom",
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
onClickOutside?: () => void;
|
||||
@@ -26,6 +27,7 @@ const MenuContent = ({
|
||||
*/
|
||||
onSelect?: (event: Event) => void;
|
||||
style?: React.CSSProperties;
|
||||
placement?: "top" | "bottom";
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -58,6 +60,7 @@ const MenuContent = ({
|
||||
|
||||
const classNames = clsx(`dropdown-menu ${className}`, {
|
||||
"dropdown-menu--mobile": device.editor.isMobile,
|
||||
"dropdown-menu--placement-top": placement === "top",
|
||||
}).trim();
|
||||
|
||||
return (
|
||||
@@ -71,7 +74,12 @@ const MenuContent = ({
|
||||
{/* the zIndex ensures this menu has higher stacking order,
|
||||
see https://github.com/excalidraw/excalidraw/pull/1445 */}
|
||||
{device.editor.isMobile ? (
|
||||
<Stack.Col className="dropdown-menu-container">{children}</Stack.Col>
|
||||
<Stack.Col
|
||||
className="dropdown-menu-container"
|
||||
style={{ ["--gap" as any]: 1.25 }}
|
||||
>
|
||||
{children}
|
||||
</Stack.Col>
|
||||
) : (
|
||||
<Island
|
||||
className="dropdown-menu-container"
|
||||
|
||||
@@ -6,6 +6,7 @@ const MenuSeparator = () => (
|
||||
height: "1px",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: ".5rem 0",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -2319,22 +2319,10 @@ export const adjustmentsIcon = createIcon(
|
||||
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" />
|
||||
<path d="M6 10l4 -4 L6 14l8 -8 L6 18l12 -12 L10 18l8 -8 L14 18l4 -4" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
@@ -30,9 +30,6 @@ const MainMenu = Object.assign(
|
||||
const device = useDevice();
|
||||
const appState = useUIAppState();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
const onClickOutside = device.editor.isMobile
|
||||
? undefined
|
||||
: () => setAppState({ openMenu: null });
|
||||
|
||||
return (
|
||||
<MainMenuTunnel.In>
|
||||
@@ -41,6 +38,8 @@ const MainMenu = Object.assign(
|
||||
onToggle={() => {
|
||||
setAppState({
|
||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
openPopup: null,
|
||||
openDialog: null,
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
@@ -49,10 +48,12 @@ const MainMenu = Object.assign(
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={onClickOutside}
|
||||
onClickOutside={() => setAppState({ openMenu: null })}
|
||||
onSelect={composeEventHandlers(onSelect, () => {
|
||||
setAppState({ openMenu: null });
|
||||
})}
|
||||
placement="bottom"
|
||||
className={device.editor.isMobile ? "main-menu-dropdown" : ""}
|
||||
>
|
||||
{children}
|
||||
{device.editor.isMobile && appState.collaborators.size > 0 && (
|
||||
|
||||
@@ -89,7 +89,7 @@ export const SHAPES = [
|
||||
] as const;
|
||||
|
||||
export const getToolbarTools = (app: AppClassProperties) => {
|
||||
return app.defaultSelectionTool === "lasso"
|
||||
return app.state.preferredSelectionTool.type === "lasso"
|
||||
? ([
|
||||
{
|
||||
value: "lasso",
|
||||
|
||||
@@ -252,16 +252,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 599px) {
|
||||
&.excalidraw--mobile {
|
||||
.welcome-screen-center {
|
||||
margin-top: 4rem;
|
||||
}
|
||||
}
|
||||
@media (min-height: 600px) and (max-height: 900px) {
|
||||
.welcome-screen-center {
|
||||
margin-top: 8rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 500px), (max-width: 320px) {
|
||||
.welcome-screen-center {
|
||||
display: none;
|
||||
|
||||
@@ -12,6 +12,12 @@
|
||||
--zIndex-eyeDropperPreview: 6;
|
||||
--zIndex-hyperlinkContainer: 7;
|
||||
|
||||
--zIndex-ui-bottom: 60;
|
||||
--zIndex-ui-library: 80;
|
||||
--zIndex-ui-context-menu: 90;
|
||||
--zIndex-ui-styles-popup: 100;
|
||||
--zIndex-ui-top: 100;
|
||||
|
||||
--zIndex-modal: 1000;
|
||||
--zIndex-popup: 1001;
|
||||
--zIndex-toast: 999999;
|
||||
@@ -44,6 +50,11 @@ body.excalidraw-cursor-resize * {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
button,
|
||||
label {
|
||||
@include buttonNoHighlight;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
@@ -232,30 +243,35 @@ body.excalidraw-cursor-resize * {
|
||||
}
|
||||
|
||||
.App-top-bar {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
z-index: var(--zIndex-ui-top);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.App-welcome-screen {
|
||||
z-index: var(--zIndex-layerUI);
|
||||
}
|
||||
|
||||
.App-bottom-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
// account for margins
|
||||
width: calc(100% - 28px);
|
||||
max-width: 450px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
--bar-padding: calc(4 * var(--space-factor));
|
||||
z-index: 4;
|
||||
z-index: var(--zIndex-ui-bottom);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
|
||||
pointer-events: none;
|
||||
justify-content: center;
|
||||
|
||||
> .Island {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-height: 100%;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
@@ -263,7 +279,8 @@ body.excalidraw-cursor-resize * {
|
||||
}
|
||||
|
||||
.App-toolbar {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.eraser {
|
||||
&.ToolIcon:hover {
|
||||
@@ -276,15 +293,20 @@ body.excalidraw-cursor-resize * {
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar-content {
|
||||
.excalidraw-ui-top-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dropdown-menu--mobile {
|
||||
bottom: 55px;
|
||||
top: auto;
|
||||
.App-toolbar-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +528,7 @@ body.excalidraw-cursor-resize * {
|
||||
display: none;
|
||||
}
|
||||
.scroll-back-to-content {
|
||||
bottom: calc(80px + var(--sab, 0));
|
||||
bottom: calc(100px + var(--sab, 0));
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
--button-gray-1: #{$oc-gray-2};
|
||||
--button-gray-2: #{$oc-gray-4};
|
||||
--button-gray-3: #{$oc-gray-5};
|
||||
--mobile-action-button-bg: rgba(255, 255, 255, 0.35);
|
||||
--mobile-color-border: var(--default-border-color);
|
||||
--button-special-active-bg-color: #{$oc-green-0};
|
||||
--dialog-border-color: var(--color-gray-20);
|
||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||
@@ -42,6 +44,11 @@
|
||||
--lg-button-size: 2.25rem;
|
||||
--lg-icon-size: 1rem;
|
||||
--editor-container-padding: 1rem;
|
||||
--mobile-action-button-size: 2rem;
|
||||
|
||||
@include isMobile {
|
||||
--editor-container-padding: 0.75rem;
|
||||
}
|
||||
|
||||
@media screen and (min-device-width: 1921px) {
|
||||
--lg-button-size: 2.5rem;
|
||||
@@ -177,6 +184,8 @@
|
||||
--button-gray-1: #363636;
|
||||
--button-gray-2: #272727;
|
||||
--button-gray-3: #222;
|
||||
--mobile-action-button-bg: var(--island-bg-color);
|
||||
--mobile-color-border: rgba(255, 255, 255, 0.85);
|
||||
--button-special-active-bg-color: #204624;
|
||||
--dialog-border-color: var(--color-gray-80);
|
||||
--dropdown-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="292.4" height="292.4" viewBox="0 0 292 292"><path fill="%23ced4da" d="M287 197L159 69c-4-3-8-5-13-5s-9 2-13 5L5 197c-3 4-5 8-5 13s2 9 5 13c4 4 8 5 13 5h256c5 0 9-1 13-5s5-8 5-13-1-9-5-13z"/></svg>');
|
||||
|
||||
@@ -122,6 +122,17 @@
|
||||
color: var(--button-color, var(--color-on-primary-container));
|
||||
}
|
||||
}
|
||||
|
||||
@include isMobile() {
|
||||
width: var(--mobile-action-button-size, var(--default-button-size));
|
||||
height: var(--mobile-action-button-size, var(--default-button-size));
|
||||
}
|
||||
}
|
||||
|
||||
@mixin buttonNoHighlight {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@mixin outlineButtonIconStyles {
|
||||
@@ -187,4 +198,9 @@
|
||||
&:active {
|
||||
box-shadow: 0 0 0 1px var(--color-brand-active);
|
||||
}
|
||||
|
||||
@include isMobile() {
|
||||
width: var(--mobile-action-button-size, 2rem);
|
||||
height: var(--mobile-action-button-size, 2rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { restore, restoreLibraryItems } from "./restore";
|
||||
|
||||
import type { AppState, DataURL, LibraryItem } from "../types";
|
||||
|
||||
import type { FileSystemHandle } from "./filesystem";
|
||||
import type { FileSystemHandle } from "browser-fs-access";
|
||||
import type { ImportedLibraryData } from "./types";
|
||||
|
||||
const parseFileContents = async (blob: Blob | File): Promise<string> => {
|
||||
@@ -416,37 +416,42 @@ export const getFileHandle = async (
|
||||
/**
|
||||
* attempts to detect if a buffer is a valid image by checking its leading bytes
|
||||
*/
|
||||
const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
|
||||
let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
|
||||
null;
|
||||
const getActualMimeTypeFromImage = async (file: Blob | File) => {
|
||||
let mimeType: ValueOf<
|
||||
Pick<typeof MIME_TYPES, "png" | "jpg" | "gif" | "webp">
|
||||
> | null = null;
|
||||
|
||||
const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
|
||||
const leadingBytes = [
|
||||
...new Uint8Array(await blobToArrayBuffer(file.slice(0, 15))),
|
||||
].join(" ");
|
||||
|
||||
// uint8 leading bytes
|
||||
const headerBytes = {
|
||||
const bytes = {
|
||||
// https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
|
||||
png: "137 80 78 71 13 10 26 10 ",
|
||||
png: /^137 80 78 71 13 10 26 10\b/,
|
||||
// https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
|
||||
// jpg is a bit wonky. Checking the first three bytes should be enough,
|
||||
// but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
|
||||
jpg: "255 216 255 ",
|
||||
jpg: /^255 216 255\b/,
|
||||
// https://en.wikipedia.org/wiki/GIF#Example_GIF_file
|
||||
gif: "71 73 70 56 57 97 ",
|
||||
gif: /^71 73 70 56 57 97\b/,
|
||||
// 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier
|
||||
webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56\b/,
|
||||
};
|
||||
|
||||
if (first8Bytes === headerBytes.png) {
|
||||
mimeType = MIME_TYPES.png;
|
||||
} else if (first8Bytes.startsWith(headerBytes.jpg)) {
|
||||
mimeType = MIME_TYPES.jpg;
|
||||
} else if (first8Bytes.startsWith(headerBytes.gif)) {
|
||||
mimeType = MIME_TYPES.gif;
|
||||
for (const type of Object.keys(bytes) as (keyof typeof bytes)[]) {
|
||||
if (leadingBytes.match(bytes[type])) {
|
||||
mimeType = MIME_TYPES[type];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return mimeType;
|
||||
|
||||
return mimeType || file.type || null;
|
||||
};
|
||||
|
||||
export const createFile = (
|
||||
blob: File | Blob | ArrayBuffer,
|
||||
mimeType: ValueOf<typeof MIME_TYPES>,
|
||||
mimeType: string,
|
||||
name: string | undefined,
|
||||
) => {
|
||||
return new File([blob], name || "", {
|
||||
@@ -454,40 +459,33 @@ export const createFile = (
|
||||
});
|
||||
};
|
||||
|
||||
const normalizedFileSymbol = Symbol("fileNormalized");
|
||||
|
||||
/** attempts to detect correct mimeType if none is set, or if an image
|
||||
* has an incorrect extension.
|
||||
* Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
|
||||
export const normalizeFile = async (file: File) => {
|
||||
if (!file.type) {
|
||||
if (file?.name?.endsWith(".excalidrawlib")) {
|
||||
file = createFile(
|
||||
await blobToArrayBuffer(file),
|
||||
MIME_TYPES.excalidrawlib,
|
||||
file.name,
|
||||
);
|
||||
} else if (file?.name?.endsWith(".excalidraw")) {
|
||||
file = createFile(
|
||||
await blobToArrayBuffer(file),
|
||||
MIME_TYPES.excalidraw,
|
||||
file.name,
|
||||
);
|
||||
} else {
|
||||
const buffer = await blobToArrayBuffer(file);
|
||||
const mimeType = getActualMimeTypeFromImage(buffer);
|
||||
if (mimeType) {
|
||||
file = createFile(buffer, mimeType, file.name);
|
||||
}
|
||||
}
|
||||
// to prevent double normalization (perf optim)
|
||||
if ((file as any)[normalizedFileSymbol]) {
|
||||
return file;
|
||||
}
|
||||
|
||||
if (file?.name?.endsWith(".excalidrawlib")) {
|
||||
file = createFile(file, MIME_TYPES.excalidrawlib, file.name);
|
||||
} else if (file?.name?.endsWith(".excalidraw")) {
|
||||
file = createFile(file, MIME_TYPES.excalidraw, file.name);
|
||||
} else if (!file.type || file.type?.startsWith("image/")) {
|
||||
// when the file is an image, make sure the extension corresponds to the
|
||||
// actual mimeType (this is an edge case, but happens sometime)
|
||||
} else if (isSupportedImageFile(file)) {
|
||||
const buffer = await blobToArrayBuffer(file);
|
||||
const mimeType = getActualMimeTypeFromImage(buffer);
|
||||
// actual mimeType (this is an edge case, but happens - especially
|
||||
// with AI generated images)
|
||||
const mimeType = await getActualMimeTypeFromImage(file);
|
||||
if (mimeType && mimeType !== file.type) {
|
||||
file = createFile(buffer, mimeType, file.name);
|
||||
file = createFile(file, mimeType, file.name);
|
||||
}
|
||||
}
|
||||
|
||||
(file as any)[normalizedFileSymbol] = true;
|
||||
|
||||
return file;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common";
|
||||
|
||||
import { AbortError } from "../errors";
|
||||
|
||||
import { normalizeFile } from "./blob";
|
||||
|
||||
import type { FileSystemHandle } from "browser-fs-access";
|
||||
|
||||
type FILE_EXTENSION = Exclude<keyof typeof MIME_TYPES, "binary">;
|
||||
|
||||
const INPUT_CHANGE_INTERVAL_MS = 500;
|
||||
const INPUT_CHANGE_INTERVAL_MS = 5000;
|
||||
|
||||
export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
export const fileOpen = async <M extends boolean | undefined = false>(opts: {
|
||||
extensions?: FILE_EXTENSION[];
|
||||
description: string;
|
||||
multiple?: M;
|
||||
@@ -35,7 +37,7 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
return acc.concat(`.${ext}`);
|
||||
}, [] as string[]);
|
||||
|
||||
return _fileOpen({
|
||||
const files = await _fileOpen({
|
||||
description: opts.description,
|
||||
extensions,
|
||||
mimeTypes,
|
||||
@@ -74,7 +76,14 @@ export const fileOpen = <M extends boolean | undefined = false>(opts: {
|
||||
}
|
||||
};
|
||||
},
|
||||
}) as Promise<RetType>;
|
||||
});
|
||||
|
||||
if (Array.isArray(files)) {
|
||||
return (await Promise.all(
|
||||
files.map((file) => normalizeFile(file)),
|
||||
)) as RetType;
|
||||
}
|
||||
return (await normalizeFile(files)) as RetType;
|
||||
};
|
||||
|
||||
export const fileSave = (
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ExcalidrawElement } from "@excalidraw/element/types";
|
||||
|
||||
import { cleanAppStateForExport, clearAppStateForDatabase } from "../appState";
|
||||
|
||||
import { isImageFileHandle, loadFromBlob, normalizeFile } from "./blob";
|
||||
import { isImageFileHandle, loadFromBlob } from "./blob";
|
||||
import { fileOpen, fileSave } from "./filesystem";
|
||||
|
||||
import type { AppState, BinaryFiles, LibraryItems } from "../types";
|
||||
@@ -108,12 +108,7 @@ export const loadFromJSON = async (
|
||||
// gets resolved. Else, iOS users cannot open `.excalidraw` files.
|
||||
// extensions: ["json", "excalidraw", "png", "svg"],
|
||||
});
|
||||
return loadFromBlob(
|
||||
await normalizeFile(file),
|
||||
localAppState,
|
||||
localElements,
|
||||
file.handle,
|
||||
);
|
||||
return loadFromBlob(file, localAppState, localElements, file.handle);
|
||||
};
|
||||
|
||||
export const isValidExcalidrawData = (data?: {
|
||||
|
||||
@@ -62,6 +62,7 @@ type LibraryUpdate = {
|
||||
deletedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||
/** newly added items in the library */
|
||||
addedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||
updatedItems: Map<LibraryItem["id"], LibraryItem>;
|
||||
};
|
||||
|
||||
// an object so that we can later add more properties to it without breaking,
|
||||
@@ -170,6 +171,7 @@ const createLibraryUpdate = (
|
||||
const update: LibraryUpdate = {
|
||||
deletedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||
addedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||
updatedItems: new Map<LibraryItem["id"], LibraryItem>(),
|
||||
};
|
||||
|
||||
for (const item of prevLibraryItems) {
|
||||
@@ -181,8 +183,11 @@ const createLibraryUpdate = (
|
||||
const prevItemsMap = arrayToMap(prevLibraryItems);
|
||||
|
||||
for (const item of nextLibraryItems) {
|
||||
if (!prevItemsMap.has(item.id)) {
|
||||
const prevItem = prevItemsMap.get(item.id);
|
||||
if (!prevItem) {
|
||||
update.addedItems.set(item.id, item);
|
||||
} else if (getLibraryItemHash(prevItem) !== getLibraryItemHash(item)) {
|
||||
update.updatedItems.set(item.id, item);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +197,7 @@ const createLibraryUpdate = (
|
||||
class Library {
|
||||
/** latest libraryItems */
|
||||
private currLibraryItems: LibraryItems = [];
|
||||
|
||||
/** snapshot of library items since last onLibraryChange call */
|
||||
private prevLibraryItems = cloneLibraryItems(this.currLibraryItems);
|
||||
|
||||
@@ -585,12 +591,14 @@ class AdapterTransaction {
|
||||
let lastSavedLibraryItemsHash = 0;
|
||||
let librarySaveCounter = 0;
|
||||
|
||||
const getLibraryItemHash = (item: LibraryItem) => {
|
||||
return `${item.id}:${item.name || ""}:${hashElementsVersion(item.elements)}`;
|
||||
};
|
||||
|
||||
export const getLibraryItemsHash = (items: LibraryItems) => {
|
||||
return hashString(
|
||||
items
|
||||
.map((item) => {
|
||||
return `${item.id}:${hashElementsVersion(item.elements)}`;
|
||||
})
|
||||
.map((item) => getLibraryItemHash(item))
|
||||
.sort()
|
||||
.join(),
|
||||
);
|
||||
@@ -640,6 +648,13 @@ const persistLibraryUpdate = async (
|
||||
}
|
||||
}
|
||||
|
||||
// replace existing items with their updated versions
|
||||
if (update.updatedItems) {
|
||||
for (const [id, item] of update.updatedItems) {
|
||||
nextLibraryItemsMap.set(id, item);
|
||||
}
|
||||
}
|
||||
|
||||
const nextLibraryItems = addedItems.concat(
|
||||
Array.from(nextLibraryItemsMap.values()),
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { cleanAppStateForExport } from "../appState";
|
||||
import type {
|
||||
AppState,
|
||||
BinaryFiles,
|
||||
LibraryItem,
|
||||
LibraryItems,
|
||||
LibraryItems_anyVersion,
|
||||
} from "../types";
|
||||
@@ -59,3 +60,7 @@ export interface ImportedLibraryData extends Partial<ExportedLibraryData> {
|
||||
/** @deprecated v1 */
|
||||
library?: LibraryItems;
|
||||
}
|
||||
|
||||
export type ExcalidrawLibraryIds = {
|
||||
itemIds: LibraryItem["id"][];
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { arrayToMap, easeOut, THEME } from "@excalidraw/common";
|
||||
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
distanceToElement,
|
||||
doBoundsIntersect,
|
||||
getBoundTextElement,
|
||||
getElementBounds,
|
||||
getElementLineSegments,
|
||||
getFreedrawOutlineAsSegments,
|
||||
getFreedrawOutlinePoints,
|
||||
intersectElementWithLineSegment,
|
||||
@@ -265,19 +265,28 @@ const eraserTest = (
|
||||
}
|
||||
|
||||
return false;
|
||||
} else if (
|
||||
isArrowElement(element) ||
|
||||
(isLineElement(element) && !element.polygon)
|
||||
) {
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
|
||||
if (isArrowElement(element) || (isLineElement(element) && !element.polygon)) {
|
||||
const tolerance = Math.max(
|
||||
element.strokeWidth,
|
||||
(element.strokeWidth * 2) / zoom,
|
||||
);
|
||||
|
||||
return distanceToElement(element, elementsMap, lastPoint) <= tolerance;
|
||||
}
|
||||
// If the eraser movement is so fast that a large distance is covered
|
||||
// between the last two points, the distanceToElement miss, so we test
|
||||
// agaist each segment of the linear element
|
||||
const segments = getElementLineSegments(element, elementsMap);
|
||||
for (const seg of segments) {
|
||||
if (lineSegmentsDistance(seg, pathSegment) <= tolerance) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
intersectElementWithLineSegment(element, elementsMap, pathSegment, 0, true)
|
||||
|
||||
@@ -28,6 +28,7 @@ export const useLibraryItemSvg = (
|
||||
id: LibraryItem["id"] | null,
|
||||
elements: LibraryItem["elements"] | undefined,
|
||||
svgCache: SvgCache,
|
||||
ref: React.RefObject<HTMLDivElement | null>,
|
||||
): SVGSVGElement | undefined => {
|
||||
const [svg, setSvg] = useState<SVGSVGElement>();
|
||||
|
||||
@@ -62,6 +63,22 @@ export const useLibraryItemSvg = (
|
||||
}
|
||||
}, [id, elements, svgCache, setSvg]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current;
|
||||
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (svg) {
|
||||
node.innerHTML = svg.outerHTML;
|
||||
}
|
||||
|
||||
return () => {
|
||||
node.innerHTML = "";
|
||||
};
|
||||
}, [svg, ref]);
|
||||
|
||||
return svg;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
excalidrawAPI,
|
||||
isCollaborating = false,
|
||||
onPointerUpdate,
|
||||
renderTopLeftUI,
|
||||
renderTopRightUI,
|
||||
langCode = defaultLang.code,
|
||||
viewModeEnabled,
|
||||
@@ -120,6 +121,7 @@ const ExcalidrawBase = (props: ExcalidrawProps) => {
|
||||
excalidrawAPI={excalidrawAPI}
|
||||
isCollaborating={isCollaborating}
|
||||
onPointerUpdate={onPointerUpdate}
|
||||
renderTopLeftUI={renderTopLeftUI}
|
||||
renderTopRightUI={renderTopRightUI}
|
||||
langCode={langCode}
|
||||
viewModeEnabled={viewModeEnabled}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user