Merge branch 'master' into mtolmacs/feat/fixed-point-simple-arrow-binding
This commit is contained in:
commit
ee6f4d9ce5
@ -129,6 +129,7 @@ export const CLASSES = {
|
|||||||
ZOOM_ACTIONS: "zoom-actions",
|
ZOOM_ACTIONS: "zoom-actions",
|
||||||
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
SEARCH_MENU_INPUT_WRAPPER: "layer-ui__search-inputWrapper",
|
||||||
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
CONVERT_ELEMENT_TYPE_POPUP: "ConvertElementTypePopup",
|
||||||
|
SHAPE_ACTIONS_THEME_SCOPE: "shape-actions-theme-scope",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
export const CJK_HAND_DRAWN_FALLBACK_FONT = "Xiaolai";
|
||||||
@ -259,13 +260,17 @@ export const IMAGE_MIME_TYPES = {
|
|||||||
jfif: "image/jfif",
|
jfif: "image/jfif",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const MIME_TYPES = {
|
export const STRING_MIME_TYPES = {
|
||||||
text: "text/plain",
|
text: "text/plain",
|
||||||
html: "text/html",
|
html: "text/html",
|
||||||
json: "application/json",
|
json: "application/json",
|
||||||
// excalidraw data
|
// excalidraw data
|
||||||
excalidraw: "application/vnd.excalidraw+json",
|
excalidraw: "application/vnd.excalidraw+json",
|
||||||
excalidrawlib: "application/vnd.excalidrawlib+json",
|
excalidrawlib: "application/vnd.excalidrawlib+json",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const MIME_TYPES = {
|
||||||
|
...STRING_MIME_TYPES,
|
||||||
// image-encoded excalidraw data
|
// image-encoded excalidraw data
|
||||||
"excalidraw.svg": "image/svg+xml",
|
"excalidraw.svg": "image/svg+xml",
|
||||||
"excalidraw.png": "image/png",
|
"excalidraw.png": "image/png",
|
||||||
@ -343,9 +348,19 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = {
|
|||||||
// breakpoints
|
// breakpoints
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// md screen
|
// md screen
|
||||||
export const MQ_MAX_WIDTH_PORTRAIT = 730;
|
|
||||||
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
export const MQ_MAX_WIDTH_LANDSCAPE = 1000;
|
||||||
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
export const MQ_MAX_HEIGHT_LANDSCAPE = 500;
|
||||||
|
|
||||||
|
// mobile: up to 699px
|
||||||
|
export const MQ_MAX_WIDTH_MOBILE = 699;
|
||||||
|
|
||||||
|
// tablets
|
||||||
|
export const MQ_MIN_TABLET = 600; // lower bound (excludes phones)
|
||||||
|
export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops)
|
||||||
|
|
||||||
|
// desktop/laptop
|
||||||
|
export const MQ_MIN_WIDTH_DESKTOP = 1440;
|
||||||
|
|
||||||
// sidebar
|
// sidebar
|
||||||
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
export const MQ_RIGHT_SIDEBAR_MIN_WIDTH = 1229;
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -17,6 +17,8 @@ import {
|
|||||||
FONT_FAMILY,
|
FONT_FAMILY,
|
||||||
getFontFamilyFallbacks,
|
getFontFamilyFallbacks,
|
||||||
isDarwin,
|
isDarwin,
|
||||||
|
isAndroid,
|
||||||
|
isIOS,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
|
|
||||||
@ -1271,3 +1273,59 @@ export const reduceToCommonValue = <T, R = T>(
|
|||||||
|
|
||||||
return commonValue;
|
return commonValue;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isMobileOrTablet = (): boolean => {
|
||||||
|
const ua = navigator.userAgent || "";
|
||||||
|
const platform = navigator.platform || "";
|
||||||
|
const uaData = (navigator as any).userAgentData as
|
||||||
|
| { mobile?: boolean; platform?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// --- 1) chromium: prefer ua client hints -------------------------------
|
||||||
|
if (uaData) {
|
||||||
|
const plat = (uaData.platform || "").toLowerCase();
|
||||||
|
const isDesktopOS =
|
||||||
|
plat === "windows" ||
|
||||||
|
plat === "macos" ||
|
||||||
|
plat === "linux" ||
|
||||||
|
plat === "chrome os";
|
||||||
|
if (uaData.mobile === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (uaData.mobile === false && plat === "android") {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
if (isDesktopOS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) ios (includes ipad) --------------------------------------------
|
||||||
|
if (isIOS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3) android legacy ua fallback -------------------------------------
|
||||||
|
if (isAndroid) {
|
||||||
|
const isAndroidPhone = /Mobile/i.test(ua);
|
||||||
|
const isAndroidTablet = !isAndroidPhone;
|
||||||
|
if (isAndroidPhone || isAndroidTablet) {
|
||||||
|
const looksTouchTablet =
|
||||||
|
matchMedia?.("(hover: none)").matches &&
|
||||||
|
matchMedia?.("(pointer: coarse)").matches;
|
||||||
|
return looksTouchTablet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4) last resort desktop exclusion ----------------------------------
|
||||||
|
const looksDesktopPlatform =
|
||||||
|
/Win|Linux|CrOS|Mac/.test(platform) ||
|
||||||
|
/Windows NT|X11|CrOS|Macintosh/.test(ua);
|
||||||
|
if (looksDesktopPlatform) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, appProps }) => {
|
PanelComponent: ({ elements, appState, updateData, appProps, data }) => {
|
||||||
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
// FIXME move me to src/components/mainMenu/DefaultItems.tsx
|
||||||
return (
|
return (
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
@ -83,6 +83,7 @@ export const actionChangeViewBackgroundColor = register<Partial<AppState>>({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -88,6 +88,10 @@ export const actionToggleLinearEditor = register({
|
|||||||
selectedElementIds: appState.selectedElementIds,
|
selectedElementIds: appState.selectedElementIds,
|
||||||
})[0] as ExcalidrawLinearElement;
|
})[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
|
if (!selectedElement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const label = t(
|
const label = t(
|
||||||
selectedElement.type === "arrow"
|
selectedElement.type === "arrow"
|
||||||
? "labels.lineEditor.editArrow"
|
? "labels.lineEditor.editArrow"
|
||||||
|
|||||||
@ -139,6 +139,11 @@ import {
|
|||||||
isSomeElementSelected,
|
isSomeElementSelected,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
|
import {
|
||||||
|
withCaretPositionPreservation,
|
||||||
|
restoreCaretPosition,
|
||||||
|
} from "../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||||
@ -325,9 +330,11 @@ export const actionChangeStrokeColor = register<
|
|||||||
: CaptureUpdateAction.EVENTUALLY,
|
: CaptureUpdateAction.EVENTUALLY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<h3 aria-hidden="true">{t("labels.stroke")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
topPicks={DEFAULT_ELEMENT_STROKE_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_STROKE_COLOR_PALETTE}
|
||||||
@ -345,6 +352,7 @@ export const actionChangeStrokeColor = register<
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -404,9 +412,11 @@ export const actionChangeBackgroundColor = register<
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<>
|
<>
|
||||||
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<h3 aria-hidden="true">{t("labels.background")}</h3>
|
||||||
|
)}
|
||||||
<ColorPicker
|
<ColorPicker
|
||||||
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
topPicks={DEFAULT_ELEMENT_BACKGROUND_PICKS}
|
||||||
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
palette={DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE}
|
||||||
@ -424,6 +434,7 @@ export const actionChangeBackgroundColor = register<
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
appState={appState}
|
appState={appState}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
@ -526,9 +537,11 @@ export const actionChangeStrokeWidth = register<
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeWidth")}</legend>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<legend>{t("labels.strokeWidth")}</legend>
|
||||||
|
)}
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="stroke-width"
|
group="stroke-width"
|
||||||
@ -583,9 +596,11 @@ export const actionChangeSloppiness = register<ExcalidrawElement["roughness"]>({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.sloppiness")}</legend>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<legend>{t("labels.sloppiness")}</legend>
|
||||||
|
)}
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="sloppiness"
|
group="sloppiness"
|
||||||
@ -638,9 +653,11 @@ export const actionChangeStrokeStyle = register<
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
PanelComponent: ({ elements, appState, updateData, app, data }) => (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.strokeStyle")}</legend>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<legend>{t("labels.strokeStyle")}</legend>
|
||||||
|
)}
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<RadioSelection
|
<RadioSelection
|
||||||
group="strokeStyle"
|
group="strokeStyle"
|
||||||
@ -1042,7 +1059,7 @@ export const actionChangeFontFamily = register<{
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
PanelComponent: ({ elements, appState, app, updateData, data }) => {
|
||||||
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
const cachedElementsRef = useRef<ElementsMap>(new Map());
|
||||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
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
|
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||||
@ -1120,20 +1137,28 @@ export const actionChangeFontFamily = register<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.fontFamily")}</legend>
|
{appState.stylesPanelMode === "full" && (
|
||||||
|
<legend>{t("labels.fontFamily")}</legend>
|
||||||
|
)}
|
||||||
<FontPicker
|
<FontPicker
|
||||||
isOpened={appState.openPopup === "fontFamily"}
|
isOpened={appState.openPopup === "fontFamily"}
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||||
|
compactMode={appState.stylesPanelMode === "compact"}
|
||||||
onSelect={(fontFamily) => {
|
onSelect={(fontFamily) => {
|
||||||
setBatchedData({
|
withCaretPositionPreservation(
|
||||||
openPopup: null,
|
() => {
|
||||||
currentHoveredFontFamily: null,
|
setBatchedData({
|
||||||
currentItemFontFamily: fontFamily,
|
openPopup: null,
|
||||||
});
|
currentHoveredFontFamily: null,
|
||||||
|
currentItemFontFamily: fontFamily,
|
||||||
// defensive clear so immediate close won't abuse the cached elements
|
});
|
||||||
cachedElementsRef.current.clear();
|
// defensive clear so immediate close won't abuse the cached elements
|
||||||
|
cachedElementsRef.current.clear();
|
||||||
|
},
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
onHover={(fontFamily) => {
|
onHover={(fontFamily) => {
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
@ -1190,25 +1215,28 @@ export const actionChangeFontFamily = register<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBatchedData({
|
setBatchedData({
|
||||||
|
...batchedData,
|
||||||
openPopup: "fontFamily",
|
openPopup: "fontFamily",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// close, use the cache and clear it afterwards
|
const fontFamilyData = {
|
||||||
const data = {
|
|
||||||
openPopup: null,
|
|
||||||
currentHoveredFontFamily: null,
|
currentHoveredFontFamily: null,
|
||||||
cachedElements: new Map(cachedElementsRef.current),
|
cachedElements: new Map(cachedElementsRef.current),
|
||||||
resetAll: true,
|
resetAll: true,
|
||||||
} as ChangeFontFamilyData;
|
} as ChangeFontFamilyData;
|
||||||
|
|
||||||
if (isUnmounted.current) {
|
setBatchedData({
|
||||||
// in case the component was unmounted by the parent, trigger the update directly
|
...fontFamilyData,
|
||||||
updateData({ ...batchedData, ...data });
|
});
|
||||||
} else {
|
|
||||||
setBatchedData(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
cachedElementsRef.current.clear();
|
cachedElementsRef.current.clear();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (
|
||||||
|
appState.stylesPanelMode === "compact" &&
|
||||||
|
appState.editingTextElement
|
||||||
|
) {
|
||||||
|
restoreCaretPosition(null); // Just refocus without saved position
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@ -1251,8 +1279,9 @@ export const actionChangeTextAlign = register<TextAlign>({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{t("labels.textAlign")}</legend>
|
<legend>{t("labels.textAlign")}</legend>
|
||||||
@ -1301,7 +1330,14 @@ export const actionChangeTextAlign = register<TextAlign>({
|
|||||||
(hasSelection) =>
|
(hasSelection) =>
|
||||||
hasSelection ? null : appState.currentItemTextAlign,
|
hasSelection ? null : appState.currentItemTextAlign,
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1343,7 +1379,7 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
|
|||||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
PanelComponent: ({ elements, appState, updateData, app, data }) => {
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
@ -1393,7 +1429,14 @@ export const actionChangeVerticalAlign = register<VerticalAlign>({
|
|||||||
) !== null,
|
) !== null,
|
||||||
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
(hasSelection) => (hasSelection ? null : VERTICAL_ALIGN.MIDDLE),
|
||||||
)}
|
)}
|
||||||
onChange={(value) => updateData(value)}
|
onChange={(value) => {
|
||||||
|
withCaretPositionPreservation(
|
||||||
|
() => updateData(value),
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
!!appState.editingTextElement,
|
||||||
|
data?.onPreventClose,
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -1643,6 +1686,25 @@ export const actionChangeArrowhead = register<{
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const actionChangeArrowProperties = register({
|
||||||
|
name: "changeArrowProperties",
|
||||||
|
label: "Change arrow properties",
|
||||||
|
trackEvent: false,
|
||||||
|
perform: (elements, appState, value, app) => {
|
||||||
|
// This action doesn't perform any changes directly
|
||||||
|
// It's just a container for the arrow type and arrowhead actions
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||||
|
return (
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{renderAction("changeArrowType")}
|
||||||
|
{renderAction("changeArrowhead")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
export const actionChangeArrowType = register<keyof typeof ARROW_TYPE>({
|
||||||
name: "changeArrowType",
|
name: "changeArrowType",
|
||||||
label: "Change arrow types",
|
label: "Change arrow types",
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export {
|
|||||||
actionChangeFontFamily,
|
actionChangeFontFamily,
|
||||||
actionChangeTextAlign,
|
actionChangeTextAlign,
|
||||||
actionChangeVerticalAlign,
|
actionChangeVerticalAlign,
|
||||||
|
actionChangeArrowProperties,
|
||||||
} from "./actionProperties";
|
} from "./actionProperties";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|||||||
@ -69,6 +69,7 @@ export type ActionName =
|
|||||||
| "changeStrokeStyle"
|
| "changeStrokeStyle"
|
||||||
| "changeArrowhead"
|
| "changeArrowhead"
|
||||||
| "changeArrowType"
|
| "changeArrowType"
|
||||||
|
| "changeArrowProperties"
|
||||||
| "changeOpacity"
|
| "changeOpacity"
|
||||||
| "changeFontSize"
|
| "changeFontSize"
|
||||||
| "toggleCanvasMenu"
|
| "toggleCanvasMenu"
|
||||||
|
|||||||
@ -124,6 +124,7 @@ export const getDefaultAppState = (): Omit<
|
|||||||
lockedMultiSelections: {},
|
lockedMultiSelections: {},
|
||||||
activeLockedId: null,
|
activeLockedId: null,
|
||||||
bindMode: "orbit",
|
bindMode: "orbit",
|
||||||
|
stylesPanelMode: "full",
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -249,6 +250,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
lockedMultiSelections: { browser: true, export: true, server: true },
|
lockedMultiSelections: { browser: true, export: true, server: true },
|
||||||
activeLockedId: { browser: false, export: false, server: false },
|
activeLockedId: { browser: false, export: false, server: false },
|
||||||
bindMode: { browser: true, export: false, server: false },
|
bindMode: { browser: true, export: false, server: false },
|
||||||
|
stylesPanelMode: { browser: true, export: false, server: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const _clearAppStateForStorage = <
|
const _clearAppStateForStorage = <
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createPasteEvent,
|
createPasteEvent,
|
||||||
parseClipboard,
|
parseClipboard,
|
||||||
|
parseDataTransferEvent,
|
||||||
serializeAsClipboardJSON,
|
serializeAsClipboardJSON,
|
||||||
} from "./clipboard";
|
} from "./clipboard";
|
||||||
import { API } from "./tests/helpers/api";
|
import { API } from "./tests/helpers/api";
|
||||||
@ -13,7 +14,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "123";
|
text = "123";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@ -21,7 +24,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = "[123]";
|
text = "[123]";
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
|
|
||||||
@ -29,7 +34,9 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
text = JSON.stringify({ val: 42 });
|
text = JSON.stringify({ val: 42 });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({ types: { "text/plain": text } }),
|
await parseDataTransferEvent(
|
||||||
|
createPasteEvent({ types: { "text/plain": text } }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.text).toBe(text);
|
expect(clipboardData.text).toBe(text);
|
||||||
});
|
});
|
||||||
@ -39,11 +46,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": json,
|
types: {
|
||||||
},
|
"text/plain": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
});
|
});
|
||||||
@ -56,21 +65,25 @@ describe("parseClipboard()", () => {
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": json,
|
types: {
|
||||||
},
|
"text/html": json,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
json = serializeAsClipboardJSON({ elements: [rect], files: null });
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div> ${json}</div>`,
|
types: {
|
||||||
},
|
"text/html": `<div> ${json}</div>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.elements).toEqual([rect]);
|
expect(clipboardData.elements).toEqual([rect]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@ -80,11 +93,13 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<img src="https://example.com/image.png" />`,
|
types: {
|
||||||
},
|
"text/html": `<img src="https://example.com/image.png" />`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -94,11 +109,13 @@ describe("parseClipboard()", () => {
|
|||||||
]);
|
]);
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
types: {
|
||||||
},
|
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -114,11 +131,13 @@ describe("parseClipboard()", () => {
|
|||||||
|
|
||||||
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
|
||||||
const clipboardData = await parseClipboard(
|
const clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
types: {
|
||||||
},
|
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.mixedContent).toEqual([
|
expect(clipboardData.mixedContent).toEqual([
|
||||||
{
|
{
|
||||||
@ -141,14 +160,16 @@ describe("parseClipboard()", () => {
|
|||||||
let clipboardData;
|
let clipboardData;
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/plain": `a b
|
types: {
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -157,14 +178,16 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `a b
|
types: {
|
||||||
1 2
|
"text/html": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
@ -173,19 +196,21 @@ describe("parseClipboard()", () => {
|
|||||||
});
|
});
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
clipboardData = await parseClipboard(
|
clipboardData = await parseClipboard(
|
||||||
createPasteEvent({
|
await parseDataTransferEvent(
|
||||||
types: {
|
createPasteEvent({
|
||||||
"text/html": `<html>
|
types: {
|
||||||
<body>
|
"text/html": `<html>
|
||||||
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
<body>
|
||||||
</body>
|
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"a"}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{"1":2,"2":"b"}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{"1":3,"3":10}">10</td></tr></tbody></table><!--EndFragment-->
|
||||||
</html>`,
|
</body>
|
||||||
"text/plain": `a b
|
</html>`,
|
||||||
1 2
|
"text/plain": `a b
|
||||||
4 5
|
1 2
|
||||||
7 10`,
|
4 5
|
||||||
},
|
7 10`,
|
||||||
}),
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
expect(clipboardData.spreadsheet).toEqual({
|
expect(clipboardData.spreadsheet).toEqual({
|
||||||
title: "b",
|
title: "b",
|
||||||
|
|||||||
@ -17,15 +17,26 @@ import {
|
|||||||
|
|
||||||
import { getContainingFrame } from "@excalidraw/element";
|
import { getContainingFrame } from "@excalidraw/element";
|
||||||
|
|
||||||
|
import type { ValueOf } from "@excalidraw/common/utility-types";
|
||||||
|
|
||||||
|
import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common";
|
||||||
import type {
|
import type {
|
||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
} from "@excalidraw/element/types";
|
} from "@excalidraw/element/types";
|
||||||
|
|
||||||
import { ExcalidrawError } from "./errors";
|
import { ExcalidrawError } from "./errors";
|
||||||
import { createFile, isSupportedImageFileType } from "./data/blob";
|
import {
|
||||||
|
createFile,
|
||||||
|
getFileHandle,
|
||||||
|
isSupportedImageFileType,
|
||||||
|
normalizeFile,
|
||||||
|
} from "./data/blob";
|
||||||
|
|
||||||
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
|
|
||||||
|
import type { FileSystemHandle } from "./data/filesystem";
|
||||||
|
|
||||||
import type { Spreadsheet } from "./charts";
|
import type { Spreadsheet } from "./charts";
|
||||||
|
|
||||||
import type { BinaryFiles } from "./types";
|
import type { BinaryFiles } from "./types";
|
||||||
@ -102,10 +113,11 @@ export const createPasteEvent = ({
|
|||||||
if (typeof value !== "string") {
|
if (typeof value !== "string") {
|
||||||
files = files || [];
|
files = files || [];
|
||||||
files.push(value);
|
files.push(value);
|
||||||
|
event.clipboardData?.items.add(value);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
event.clipboardData?.setData(type, value);
|
event.clipboardData?.items.add(value, type);
|
||||||
if (event.clipboardData?.getData(type) !== value) {
|
if (event.clipboardData?.getData(type) !== value) {
|
||||||
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
throw new Error(`Failed to set "${type}" as clipboardData item`);
|
||||||
}
|
}
|
||||||
@ -230,14 +242,10 @@ function parseHTMLTree(el: ChildNode) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maybeParseHTMLPaste = (
|
const maybeParseHTMLDataItem = (
|
||||||
event: ClipboardEvent,
|
dataItem: ParsedDataTransferItemType<typeof MIME_TYPES["html"]>,
|
||||||
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
): { type: "mixedContent"; value: PastedMixedContent } | null => {
|
||||||
const html = event.clipboardData?.getData(MIME_TYPES.html);
|
const html = dataItem.value;
|
||||||
|
|
||||||
if (!html) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
|
||||||
@ -333,18 +341,21 @@ export const readSystemClipboard = async () => {
|
|||||||
* Parses "paste" ClipboardEvent.
|
* Parses "paste" ClipboardEvent.
|
||||||
*/
|
*/
|
||||||
const parseClipboardEventTextData = async (
|
const parseClipboardEventTextData = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ParsedClipboardEventTextData> => {
|
): Promise<ParsedClipboardEventTextData> => {
|
||||||
try {
|
try {
|
||||||
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
|
const htmlItem = dataList.findByType(MIME_TYPES.html);
|
||||||
|
|
||||||
|
const mixedContent =
|
||||||
|
!isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem);
|
||||||
|
|
||||||
if (mixedContent) {
|
if (mixedContent) {
|
||||||
if (mixedContent.value.every((item) => item.type === "text")) {
|
if (mixedContent.value.every((item) => item.type === "text")) {
|
||||||
return {
|
return {
|
||||||
type: "text",
|
type: "text",
|
||||||
value:
|
value:
|
||||||
event.clipboardData?.getData(MIME_TYPES.text) ||
|
dataList.getData(MIME_TYPES.text) ??
|
||||||
mixedContent.value
|
mixedContent.value
|
||||||
.map((item) => item.value)
|
.map((item) => item.value)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
@ -355,23 +366,155 @@ const parseClipboardEventTextData = async (
|
|||||||
return mixedContent;
|
return mixedContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = event.clipboardData?.getData(MIME_TYPES.text);
|
return {
|
||||||
|
type: "text",
|
||||||
return { type: "text", value: (text || "").trim() };
|
value: (dataList.getData(MIME_TYPES.text) || "").trim(),
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { type: "text", value: "" };
|
return { type: "text", value: "" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AllowedParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: ValueOf<typeof IMAGE_MIME_TYPES>;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: ValueOf<typeof STRING_MIME_TYPES>; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItem =
|
||||||
|
| {
|
||||||
|
type: string;
|
||||||
|
kind: "file";
|
||||||
|
file: File;
|
||||||
|
fileHandle: FileSystemHandle | null;
|
||||||
|
}
|
||||||
|
| { type: string; kind: "string"; value: string };
|
||||||
|
|
||||||
|
type ParsedDataTransferItemType<
|
||||||
|
T extends AllowedParsedDataTransferItem["type"],
|
||||||
|
> = AllowedParsedDataTransferItem & { type: T };
|
||||||
|
|
||||||
|
export type ParsedDataTransferFile = Extract<
|
||||||
|
AllowedParsedDataTransferItem,
|
||||||
|
{ kind: "file" }
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ParsedDataTranferList = ParsedDataTransferItem[] & {
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
findByType: typeof findDataTransferItemType;
|
||||||
|
/**
|
||||||
|
* Only allows filtering by known `string` data types, since `file`
|
||||||
|
* types can have multiple items of the same type (e.g. multiple image files)
|
||||||
|
* unlike `string` data transfer items.
|
||||||
|
*/
|
||||||
|
getData: typeof getDataTransferItemData;
|
||||||
|
getFiles: typeof getDataTransferFiles;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findDataTransferItemType = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType<T> | null {
|
||||||
|
return (
|
||||||
|
this.find(
|
||||||
|
(item): item is ParsedDataTransferItemType<T> => item.type === type,
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const getDataTransferItemData = function <
|
||||||
|
T extends ValueOf<typeof STRING_MIME_TYPES>,
|
||||||
|
>(
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
type: T,
|
||||||
|
):
|
||||||
|
| ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>>["value"]
|
||||||
|
| null {
|
||||||
|
const item = this.find(
|
||||||
|
(
|
||||||
|
item,
|
||||||
|
): item is ParsedDataTransferItemType<ValueOf<typeof STRING_MIME_TYPES>> =>
|
||||||
|
item.type === type,
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.value ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDataTransferFiles = function (
|
||||||
|
this: ParsedDataTranferList,
|
||||||
|
): ParsedDataTransferFile[] {
|
||||||
|
return this.filter(
|
||||||
|
(item): item is ParsedDataTransferFile => item.kind === "file",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseDataTransferEvent = async (
|
||||||
|
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
|
||||||
|
): Promise<ParsedDataTranferList> => {
|
||||||
|
let items: DataTransferItemList | undefined = undefined;
|
||||||
|
|
||||||
|
if (isClipboardEvent(event)) {
|
||||||
|
items = event.clipboardData?.items;
|
||||||
|
} else {
|
||||||
|
const dragEvent = event;
|
||||||
|
items = dragEvent.dataTransfer?.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataItems = (
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(items || []).map(
|
||||||
|
async (item): Promise<ParsedDataTransferItem | null> => {
|
||||||
|
if (item.kind === "file") {
|
||||||
|
const file = item.getAsFile();
|
||||||
|
if (file) {
|
||||||
|
const fileHandle = await getFileHandle(item);
|
||||||
|
return {
|
||||||
|
type: file.type,
|
||||||
|
kind: "file",
|
||||||
|
file: await normalizeFile(file),
|
||||||
|
fileHandle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (item.kind === "string") {
|
||||||
|
const { type } = item;
|
||||||
|
let value: string;
|
||||||
|
if ("clipboardData" in event && event.clipboardData) {
|
||||||
|
value = event.clipboardData?.getData(type);
|
||||||
|
} else {
|
||||||
|
value = await new Promise<string>((resolve) => {
|
||||||
|
item.getAsString((str) => resolve(str));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { type, kind: "string", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).filter((data): data is ParsedDataTransferItem => data != null);
|
||||||
|
|
||||||
|
return Object.assign(dataItems, {
|
||||||
|
findByType: findDataTransferItemType,
|
||||||
|
getData: getDataTransferItemData,
|
||||||
|
getFiles: getDataTransferFiles,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attempts to parse clipboard event.
|
* Attempts to parse clipboard event.
|
||||||
*/
|
*/
|
||||||
export const parseClipboard = async (
|
export const parseClipboard = async (
|
||||||
event: ClipboardEvent,
|
dataList: ParsedDataTranferList,
|
||||||
isPlainPaste = false,
|
isPlainPaste = false,
|
||||||
): Promise<ClipboardData> => {
|
): Promise<ClipboardData> => {
|
||||||
const parsedEventData = await parseClipboardEventTextData(
|
const parsedEventData = await parseClipboardEventTextData(
|
||||||
event,
|
dataList,
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -91,3 +91,120 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: calc(100vh - 200px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem;
|
||||||
|
|
||||||
|
.compact-action-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
|
||||||
|
--default-button-size: 2rem;
|
||||||
|
|
||||||
|
.compact-action-button {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--button-hover-bg, var(--island-bg-color));
|
||||||
|
border-color: var(
|
||||||
|
--button-hover-border,
|
||||||
|
var(--button-border, var(--default-border-color))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--button-active-bg, var(--island-bg-color));
|
||||||
|
border-color: var(--button-active-border, var(--color-primary-darkest));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-shape-actions-island {
|
||||||
|
width: fit-content;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-popover-content {
|
||||||
|
.popover-section {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-section-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonList {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape-actions-theme-scope {
|
||||||
|
--button-border: transparent;
|
||||||
|
--button-bg: var(--color-surface-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root.theme--dark .shape-actions-theme-scope {
|
||||||
|
--button-hover-bg: #363541;
|
||||||
|
--button-bg: var(--color-surface-high);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
@ -19,6 +20,7 @@ import {
|
|||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
|
isArrowElement,
|
||||||
} from "@excalidraw/element";
|
} from "@excalidraw/element";
|
||||||
|
|
||||||
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
import { hasStrokeColor, toolIsArrow } from "@excalidraw/element";
|
||||||
@ -46,15 +48,20 @@ import {
|
|||||||
hasStrokeWidth,
|
hasStrokeWidth,
|
||||||
} from "../scene";
|
} from "../scene";
|
||||||
|
|
||||||
|
import { getFormValue } from "../actions/actionProperties";
|
||||||
|
|
||||||
|
import { useTextEditorFocus } from "../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { getToolbarTools } from "./shapes";
|
import { getToolbarTools } from "./shapes";
|
||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
|
|
||||||
import { useDevice } from "./App";
|
import { useDevice, useExcalidrawContainer } from "./App";
|
||||||
import Stack from "./Stack";
|
import Stack from "./Stack";
|
||||||
import { ToolButton } from "./ToolButton";
|
import { ToolButton } from "./ToolButton";
|
||||||
import { Tooltip } from "./Tooltip";
|
import { Tooltip } from "./Tooltip";
|
||||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
|
import { PropertiesPopover } from "./PropertiesPopover";
|
||||||
import {
|
import {
|
||||||
EmbedIcon,
|
EmbedIcon,
|
||||||
extraToolsIcon,
|
extraToolsIcon,
|
||||||
@ -63,11 +70,29 @@ import {
|
|||||||
laserPointerToolIcon,
|
laserPointerToolIcon,
|
||||||
MagicIcon,
|
MagicIcon,
|
||||||
LassoIcon,
|
LassoIcon,
|
||||||
|
sharpArrowIcon,
|
||||||
|
roundArrowIcon,
|
||||||
|
elbowArrowIcon,
|
||||||
|
TextSizeIcon,
|
||||||
|
adjustmentsIcon,
|
||||||
|
DotsHorizontalIcon,
|
||||||
} from "./icons";
|
} from "./icons";
|
||||||
|
|
||||||
import type { AppClassProperties, AppProps, UIAppState, Zoom } from "../types";
|
import type {
|
||||||
|
AppClassProperties,
|
||||||
|
AppProps,
|
||||||
|
UIAppState,
|
||||||
|
Zoom,
|
||||||
|
AppState,
|
||||||
|
} from "../types";
|
||||||
import type { ActionManager } from "../actions/manager";
|
import type { ActionManager } from "../actions/manager";
|
||||||
|
|
||||||
|
// Common CSS class combinations
|
||||||
|
const PROPERTIES_CLASSES = clsx([
|
||||||
|
CLASSES.SHAPE_ACTIONS_THEME_SCOPE,
|
||||||
|
"properties-content",
|
||||||
|
]);
|
||||||
|
|
||||||
export const canChangeStrokeColor = (
|
export const canChangeStrokeColor = (
|
||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
targetElements: ExcalidrawElement[],
|
targetElements: ExcalidrawElement[],
|
||||||
@ -280,6 +305,437 @@ export const SelectedShapeActions = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CompactShapeActions = ({
|
||||||
|
appState,
|
||||||
|
elementsMap,
|
||||||
|
renderAction,
|
||||||
|
app,
|
||||||
|
setAppState,
|
||||||
|
}: {
|
||||||
|
appState: UIAppState;
|
||||||
|
elementsMap: NonDeletedElementsMap | NonDeletedSceneElementsMap;
|
||||||
|
renderAction: ActionManager["renderAction"];
|
||||||
|
app: AppClassProperties;
|
||||||
|
setAppState: React.Component<any, AppState>["setState"];
|
||||||
|
}) => {
|
||||||
|
const targetElements = getTargetElements(elementsMap, appState);
|
||||||
|
const { saveCaretPosition, restoreCaretPosition } = useTextEditorFocus();
|
||||||
|
const { container } = useExcalidrawContainer();
|
||||||
|
|
||||||
|
const isEditingTextOrNewElement = Boolean(
|
||||||
|
appState.editingTextElement || appState.newElement,
|
||||||
|
);
|
||||||
|
|
||||||
|
const showFillIcons =
|
||||||
|
(hasBackground(appState.activeTool.type) &&
|
||||||
|
!isTransparent(appState.currentItemBackgroundColor)) ||
|
||||||
|
targetElements.some(
|
||||||
|
(element) =>
|
||||||
|
hasBackground(element.type) && !isTransparent(element.backgroundColor),
|
||||||
|
);
|
||||||
|
|
||||||
|
const showLinkIcon = targetElements.length === 1;
|
||||||
|
|
||||||
|
const showLineEditorAction =
|
||||||
|
!appState.selectedLinearElement?.isEditing &&
|
||||||
|
targetElements.length === 1 &&
|
||||||
|
isLinearElement(targetElements[0]) &&
|
||||||
|
!isElbowArrow(targetElements[0]);
|
||||||
|
|
||||||
|
const showCropEditorAction =
|
||||||
|
!appState.croppingElementId &&
|
||||||
|
targetElements.length === 1 &&
|
||||||
|
isImageElement(targetElements[0]);
|
||||||
|
|
||||||
|
const showAlignActions = alignActionsPredicate(appState, app);
|
||||||
|
|
||||||
|
let isSingleElementBoundContainer = false;
|
||||||
|
if (
|
||||||
|
targetElements.length === 2 &&
|
||||||
|
(hasBoundTextElement(targetElements[0]) ||
|
||||||
|
hasBoundTextElement(targetElements[1]))
|
||||||
|
) {
|
||||||
|
isSingleElementBoundContainer = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRTL = document.documentElement.getAttribute("dir") === "rtl";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="compact-shape-actions">
|
||||||
|
{/* Stroke Color */}
|
||||||
|
{canChangeStrokeColor(appState, targetElements) && (
|
||||||
|
<div className={clsx("compact-action-item")}>
|
||||||
|
{renderAction("changeStrokeColor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background Color */}
|
||||||
|
{canChangeBackgroundColor(appState, targetElements) && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("changeBackgroundColor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined Properties (Fill, Stroke, Opacity) */}
|
||||||
|
{(showFillIcons ||
|
||||||
|
hasStrokeWidth(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => hasStrokeWidth(element.type)) ||
|
||||||
|
hasStrokeStyle(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => hasStrokeStyle(element.type)) ||
|
||||||
|
canChangeRoundness(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => canChangeRoundness(element.type))) && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactStrokeStyles"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setAppState({ openPopup: "compactStrokeStyles" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.stroke")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "compactStrokeStyles"
|
||||||
|
? null
|
||||||
|
: "compactStrokeStyles",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{adjustmentsIcon}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactStrokeStyles" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
className={PROPERTIES_CLASSES}
|
||||||
|
container={container}
|
||||||
|
style={{ maxWidth: "13rem" }}
|
||||||
|
onClose={() => {}}
|
||||||
|
>
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{showFillIcons && renderAction("changeFillStyle")}
|
||||||
|
{(hasStrokeWidth(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
hasStrokeWidth(element.type),
|
||||||
|
)) &&
|
||||||
|
renderAction("changeStrokeWidth")}
|
||||||
|
{(hasStrokeStyle(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
hasStrokeStyle(element.type),
|
||||||
|
)) && (
|
||||||
|
<>
|
||||||
|
{renderAction("changeStrokeStyle")}
|
||||||
|
{renderAction("changeSloppiness")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(canChangeRoundness(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) =>
|
||||||
|
canChangeRoundness(element.type),
|
||||||
|
)) &&
|
||||||
|
renderAction("changeRoundness")}
|
||||||
|
{renderAction("changeOpacity")}
|
||||||
|
</div>
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined Arrow Properties */}
|
||||||
|
{(toolIsArrow(appState.activeTool.type) ||
|
||||||
|
targetElements.some((element) => toolIsArrow(element.type))) && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactArrowProperties"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setAppState({ openPopup: "compactArrowProperties" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.arrowtypes")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "compactArrowProperties"
|
||||||
|
? null
|
||||||
|
: "compactArrowProperties",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
// Show an icon based on the current arrow type
|
||||||
|
const arrowType = getFormValue(
|
||||||
|
targetElements,
|
||||||
|
app,
|
||||||
|
(element) => {
|
||||||
|
if (isArrowElement(element)) {
|
||||||
|
return element.elbowed
|
||||||
|
? "elbow"
|
||||||
|
: element.roundness
|
||||||
|
? "round"
|
||||||
|
: "sharp";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
(element) => isArrowElement(element),
|
||||||
|
(hasSelection) =>
|
||||||
|
hasSelection ? null : appState.currentItemArrowType,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (arrowType === "elbow") {
|
||||||
|
return elbowArrowIcon;
|
||||||
|
}
|
||||||
|
if (arrowType === "round") {
|
||||||
|
return roundArrowIcon;
|
||||||
|
}
|
||||||
|
return sharpArrowIcon;
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactArrowProperties" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
container={container}
|
||||||
|
className="properties-content"
|
||||||
|
style={{ maxWidth: "13rem" }}
|
||||||
|
onClose={() => {}}
|
||||||
|
>
|
||||||
|
{renderAction("changeArrowProperties")}
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Linear Editor */}
|
||||||
|
{showLineEditorAction && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("toggleLinearEditor")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text Properties */}
|
||||||
|
{(appState.activeTool.type === "text" ||
|
||||||
|
targetElements.some(isTextElement)) && (
|
||||||
|
<>
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("changeFontFamily")}
|
||||||
|
</div>
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactTextProperties"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
saveCaretPosition();
|
||||||
|
}
|
||||||
|
setAppState({ openPopup: "compactTextProperties" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
restoreCaretPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.textAlign")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (appState.openPopup === "compactTextProperties") {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
} else {
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
saveCaretPosition();
|
||||||
|
}
|
||||||
|
setAppState({ openPopup: "compactTextProperties" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{TextSizeIcon}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactTextProperties" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
className={PROPERTIES_CLASSES}
|
||||||
|
container={container}
|
||||||
|
style={{ maxWidth: "13rem" }}
|
||||||
|
// Improve focus handling for text editing scenarios
|
||||||
|
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||||
|
onClose={() => {
|
||||||
|
// Refocus text editor when popover closes with caret restoration
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
restoreCaretPosition();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
{(appState.activeTool.type === "text" ||
|
||||||
|
targetElements.some(isTextElement)) &&
|
||||||
|
renderAction("changeFontSize")}
|
||||||
|
{(appState.activeTool.type === "text" ||
|
||||||
|
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||||
|
renderAction("changeTextAlign")}
|
||||||
|
{shouldAllowVerticalAlign(targetElements, elementsMap) &&
|
||||||
|
renderAction("changeVerticalAlign")}
|
||||||
|
</div>
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dedicated Copy Button */}
|
||||||
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("duplicateSelection")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dedicated Delete Button */}
|
||||||
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
{renderAction("deleteSelectedElements")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Combined Other Actions */}
|
||||||
|
{!isEditingTextOrNewElement && targetElements.length > 0 && (
|
||||||
|
<div className="compact-action-item">
|
||||||
|
<Popover.Root
|
||||||
|
open={appState.openPopup === "compactOtherProperties"}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setAppState({ openPopup: "compactOtherProperties" });
|
||||||
|
} else {
|
||||||
|
setAppState({ openPopup: null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popover.Trigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="compact-action-button properties-trigger"
|
||||||
|
title={t("labels.actions")}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setAppState({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "compactOtherProperties"
|
||||||
|
? null
|
||||||
|
: "compactOtherProperties",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DotsHorizontalIcon}
|
||||||
|
</button>
|
||||||
|
</Popover.Trigger>
|
||||||
|
{appState.openPopup === "compactOtherProperties" && (
|
||||||
|
<PropertiesPopover
|
||||||
|
className={PROPERTIES_CLASSES}
|
||||||
|
container={container}
|
||||||
|
style={{
|
||||||
|
maxWidth: "12rem",
|
||||||
|
// center the popover content
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
onClose={() => {}}
|
||||||
|
>
|
||||||
|
<div className="selected-shape-actions">
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.layers")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{renderAction("sendToBack")}
|
||||||
|
{renderAction("sendBackward")}
|
||||||
|
{renderAction("bringForward")}
|
||||||
|
{renderAction("bringToFront")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{showAlignActions && !isSingleElementBoundContainer && (
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.align")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{isRTL ? (
|
||||||
|
<>
|
||||||
|
{renderAction("alignRight")}
|
||||||
|
{renderAction("alignHorizontallyCentered")}
|
||||||
|
{renderAction("alignLeft")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{renderAction("alignLeft")}
|
||||||
|
{renderAction("alignHorizontallyCentered")}
|
||||||
|
{renderAction("alignRight")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeHorizontally")}
|
||||||
|
{/* breaks the row ˇˇ */}
|
||||||
|
<div style={{ flexBasis: "100%", height: 0 }} />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: ".5rem",
|
||||||
|
marginTop: "-0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderAction("alignTop")}
|
||||||
|
{renderAction("alignVerticallyCentered")}
|
||||||
|
{renderAction("alignBottom")}
|
||||||
|
{targetElements.length > 2 &&
|
||||||
|
renderAction("distributeVertically")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
)}
|
||||||
|
<fieldset>
|
||||||
|
<legend>{t("labels.actions")}</legend>
|
||||||
|
<div className="buttonList">
|
||||||
|
{renderAction("group")}
|
||||||
|
{renderAction("ungroup")}
|
||||||
|
{showLinkIcon && renderAction("hyperlink")}
|
||||||
|
{showCropEditorAction && renderAction("cropEditor")}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
</PropertiesPopover>
|
||||||
|
)}
|
||||||
|
</Popover.Root>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ShapesSwitcher = ({
|
export const ShapesSwitcher = ({
|
||||||
activeTool,
|
activeTool,
|
||||||
appState,
|
appState,
|
||||||
|
|||||||
@ -41,9 +41,6 @@ import {
|
|||||||
LINE_CONFIRM_THRESHOLD,
|
LINE_CONFIRM_THRESHOLD,
|
||||||
MAX_ALLOWED_FILE_BYTES,
|
MAX_ALLOWED_FILE_BYTES,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
MQ_MAX_HEIGHT_LANDSCAPE,
|
|
||||||
MQ_MAX_WIDTH_LANDSCAPE,
|
|
||||||
MQ_MAX_WIDTH_PORTRAIT,
|
|
||||||
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
MQ_RIGHT_SIDEBAR_MIN_WIDTH,
|
||||||
POINTER_BUTTON,
|
POINTER_BUTTON,
|
||||||
ROUNDNESS,
|
ROUNDNESS,
|
||||||
@ -100,11 +97,16 @@ import {
|
|||||||
randomInteger,
|
randomInteger,
|
||||||
CLASSES,
|
CLASSES,
|
||||||
Emitter,
|
Emitter,
|
||||||
isMobile,
|
|
||||||
MINIMUM_ARROW_SIZE,
|
MINIMUM_ARROW_SIZE,
|
||||||
DOUBLE_TAP_POSITION_THRESHOLD,
|
DOUBLE_TAP_POSITION_THRESHOLD,
|
||||||
BIND_MODE_TIMEOUT,
|
BIND_MODE_TIMEOUT,
|
||||||
invariant,
|
invariant,
|
||||||
|
isMobileOrTablet,
|
||||||
|
MQ_MAX_WIDTH_MOBILE,
|
||||||
|
MQ_MAX_HEIGHT_LANDSCAPE,
|
||||||
|
MQ_MAX_WIDTH_LANDSCAPE,
|
||||||
|
MQ_MIN_TABLET,
|
||||||
|
MQ_MAX_TABLET,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -327,7 +329,13 @@ import {
|
|||||||
isEraserActive,
|
isEraserActive,
|
||||||
isHandToolActive,
|
isHandToolActive,
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
import { copyTextToSystemClipboard, parseClipboard } from "../clipboard";
|
import {
|
||||||
|
copyTextToSystemClipboard,
|
||||||
|
parseClipboard,
|
||||||
|
parseDataTransferEvent,
|
||||||
|
type ParsedDataTransferFile,
|
||||||
|
} from "../clipboard";
|
||||||
|
|
||||||
import { exportCanvas, loadFromBlob } from "../data";
|
import { exportCanvas, loadFromBlob } from "../data";
|
||||||
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
import Library, { distributeLibraryItemsOnSquareGrid } from "../data/library";
|
||||||
import { restore, restoreElements } from "../data/restore";
|
import { restore, restoreElements } from "../data/restore";
|
||||||
@ -349,7 +357,6 @@ import {
|
|||||||
generateIdFromFile,
|
generateIdFromFile,
|
||||||
getDataURL,
|
getDataURL,
|
||||||
getDataURL_sync,
|
getDataURL_sync,
|
||||||
getFilesFromEvent,
|
|
||||||
ImageURLToFile,
|
ImageURLToFile,
|
||||||
isImageFileHandle,
|
isImageFileHandle,
|
||||||
isSupportedImageFile,
|
isSupportedImageFile,
|
||||||
@ -666,7 +673,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
constructor(props: AppProps) {
|
constructor(props: AppProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const defaultAppState = getDefaultAppState();
|
const defaultAppState = getDefaultAppState();
|
||||||
this.defaultSelectionTool = this.isMobileOrTablet()
|
this.defaultSelectionTool = isMobileOrTablet()
|
||||||
? ("lasso" as const)
|
? ("lasso" as const)
|
||||||
: ("selection" as const);
|
: ("selection" as const);
|
||||||
const {
|
const {
|
||||||
@ -2684,23 +2691,20 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private isMobileOrTablet = (): boolean => {
|
|
||||||
const hasTouch = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
||||||
const hasCoarsePointer =
|
|
||||||
"matchMedia" in window &&
|
|
||||||
window?.matchMedia("(pointer: coarse)")?.matches;
|
|
||||||
const isTouchMobile = hasTouch && hasCoarsePointer;
|
|
||||||
|
|
||||||
return isMobile || isTouchMobile;
|
|
||||||
};
|
|
||||||
|
|
||||||
private isMobileBreakpoint = (width: number, height: number) => {
|
private isMobileBreakpoint = (width: number, height: number) => {
|
||||||
return (
|
return (
|
||||||
width < MQ_MAX_WIDTH_PORTRAIT ||
|
width <= MQ_MAX_WIDTH_MOBILE ||
|
||||||
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
(height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => {
|
||||||
|
const minSide = Math.min(editorWidth, editorHeight);
|
||||||
|
const maxSide = Math.max(editorWidth, editorHeight);
|
||||||
|
|
||||||
|
return minSide >= MQ_MIN_TABLET && maxSide <= MQ_MAX_TABLET;
|
||||||
|
};
|
||||||
|
|
||||||
private refreshViewportBreakpoints = () => {
|
private refreshViewportBreakpoints = () => {
|
||||||
const container = this.excalidrawContainerRef.current;
|
const container = this.excalidrawContainerRef.current;
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@ -2745,6 +2749,17 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
canFitSidebar: editorWidth > sidebarBreakpoint,
|
canFitSidebar: editorWidth > sidebarBreakpoint,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// also check if we need to update the app state
|
||||||
|
this.setState({
|
||||||
|
stylesPanelMode:
|
||||||
|
// NOTE: we could also remove the isMobileOrTablet check here and
|
||||||
|
// always switch to compact mode when the editor is narrow (e.g. < MQ_MIN_WIDTH_DESKTOP)
|
||||||
|
// but not too narrow (> MQ_MAX_WIDTH_MOBILE)
|
||||||
|
this.isTabletBreakpoint(editorWidth, editorHeight) && isMobileOrTablet()
|
||||||
|
? "compact"
|
||||||
|
: "full",
|
||||||
|
});
|
||||||
|
|
||||||
if (prevEditorState !== nextEditorState) {
|
if (prevEditorState !== nextEditorState) {
|
||||||
this.device = { ...this.device, editor: nextEditorState };
|
this.device = { ...this.device, editor: nextEditorState };
|
||||||
return true;
|
return true;
|
||||||
@ -3339,7 +3354,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// TODO: Cover with tests
|
// TODO: Cover with tests
|
||||||
private async insertClipboardContent(
|
private async insertClipboardContent(
|
||||||
data: ClipboardData,
|
data: ClipboardData,
|
||||||
filesData: Awaited<ReturnType<typeof getFilesFromEvent>>,
|
dataTransferFiles: ParsedDataTransferFile[],
|
||||||
isPlainPaste: boolean,
|
isPlainPaste: boolean,
|
||||||
) {
|
) {
|
||||||
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
const { x: sceneX, y: sceneY } = viewportCoordsToSceneCoords(
|
||||||
@ -3357,7 +3372,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Mixed content with no files -------------------
|
// ------------------- Mixed content with no files -------------------
|
||||||
if (filesData.length === 0 && !isPlainPaste && data.mixedContent) {
|
if (dataTransferFiles.length === 0 && !isPlainPaste && data.mixedContent) {
|
||||||
await this.addElementsFromMixedContentPaste(data.mixedContent, {
|
await this.addElementsFromMixedContentPaste(data.mixedContent, {
|
||||||
isPlainPaste,
|
isPlainPaste,
|
||||||
sceneX,
|
sceneX,
|
||||||
@ -3378,9 +3393,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ------------------- Images or SVG code -------------------
|
// ------------------- Images or SVG code -------------------
|
||||||
const imageFiles = filesData
|
const imageFiles = dataTransferFiles.map((data) => data.file);
|
||||||
.map((data) => data.file)
|
|
||||||
.filter((file): file is File => isSupportedImageFile(file));
|
|
||||||
|
|
||||||
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
|
if (imageFiles.length === 0 && data.text && !isPlainPaste) {
|
||||||
const trimmedText = data.text.trim();
|
const trimmedText = data.text.trim();
|
||||||
@ -3413,7 +3426,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements,
|
elements,
|
||||||
files: data.files || null,
|
files: data.files || null,
|
||||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
position: isMobileOrTablet() ? "center" : "cursor",
|
||||||
retainSeed: isPlainPaste,
|
retainSeed: isPlainPaste,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@ -3438,7 +3451,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
this.addElementsFromPasteOrLibrary({
|
this.addElementsFromPasteOrLibrary({
|
||||||
elements,
|
elements,
|
||||||
files,
|
files,
|
||||||
position: this.isMobileOrTablet() ? "center" : "cursor",
|
position: isMobileOrTablet() ? "center" : "cursor",
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -3525,8 +3538,11 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
// must be called in the same frame (thus before any awaits) as the paste
|
// must be called in the same frame (thus before any awaits) as the paste
|
||||||
// event else some browsers (FF...) will clear the clipboardData
|
// event else some browsers (FF...) will clear the clipboardData
|
||||||
// (something something security)
|
// (something something security)
|
||||||
const filesData = await getFilesFromEvent(event);
|
const dataTransferList = await parseDataTransferEvent(event);
|
||||||
const data = await parseClipboard(event, isPlainPaste);
|
|
||||||
|
const filesList = dataTransferList.getFiles();
|
||||||
|
|
||||||
|
const data = await parseClipboard(dataTransferList, isPlainPaste);
|
||||||
|
|
||||||
if (this.props.onPaste) {
|
if (this.props.onPaste) {
|
||||||
try {
|
try {
|
||||||
@ -3538,7 +3554,8 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.insertClipboardContent(data, filesData, isPlainPaste);
|
await this.insertClipboardContent(data, filesList, isPlainPaste);
|
||||||
|
|
||||||
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
this.setActiveTool({ type: this.defaultSelectionTool }, true);
|
||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
},
|
},
|
||||||
@ -7020,8 +7037,6 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
pointerDownState.hit.element &&
|
pointerDownState.hit.element &&
|
||||||
this.isASelectedElement(pointerDownState.hit.element);
|
this.isASelectedElement(pointerDownState.hit.element);
|
||||||
|
|
||||||
const isMobileOrTablet = this.isMobileOrTablet();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
|
!pointerDownState.hit.hasHitCommonBoundingBoxOfSelectedElements &&
|
||||||
!pointerDownState.resize.handleType &&
|
!pointerDownState.resize.handleType &&
|
||||||
@ -7035,12 +7050,12 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
|
|
||||||
// block dragging after lasso selection on PCs until the next pointer down
|
// block dragging after lasso selection on PCs until the next pointer down
|
||||||
// (on mobile or tablet, we want to allow user to drag immediately)
|
// (on mobile or tablet, we want to allow user to drag immediately)
|
||||||
pointerDownState.drag.blockDragging = !isMobileOrTablet;
|
pointerDownState.drag.blockDragging = !isMobileOrTablet();
|
||||||
}
|
}
|
||||||
|
|
||||||
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
|
// only for mobile or tablet, if we hit an element, select it immediately like normal selection
|
||||||
if (
|
if (
|
||||||
isMobileOrTablet &&
|
isMobileOrTablet() &&
|
||||||
pointerDownState.hit.element &&
|
pointerDownState.hit.element &&
|
||||||
!hitSelectedElement
|
!hitSelectedElement
|
||||||
) {
|
) {
|
||||||
@ -8956,7 +8971,7 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
if (
|
if (
|
||||||
this.state.activeTool.type === "lasso" &&
|
this.state.activeTool.type === "lasso" &&
|
||||||
this.lassoTrail.hasCurrentTrail &&
|
this.lassoTrail.hasCurrentTrail &&
|
||||||
!(this.isMobileOrTablet() && pointerDownState.hit.element) &&
|
!(isMobileOrTablet() && pointerDownState.hit.element) &&
|
||||||
!this.state.activeTool.fromSelection
|
!this.state.activeTool.fromSelection
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@ -10983,12 +10998,13 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
event,
|
event,
|
||||||
this.state,
|
this.state,
|
||||||
);
|
);
|
||||||
|
const dataTransferList = await parseDataTransferEvent(event);
|
||||||
|
|
||||||
// must be retrieved first, in the same frame
|
// must be retrieved first, in the same frame
|
||||||
const filesData = await getFilesFromEvent(event);
|
const fileItems = dataTransferList.getFiles();
|
||||||
|
|
||||||
if (filesData.length === 1) {
|
if (fileItems.length === 1) {
|
||||||
const { file, fileHandle } = filesData[0];
|
const { file, fileHandle } = fileItems[0];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
file &&
|
file &&
|
||||||
@ -11020,15 +11036,15 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageFiles = filesData
|
const imageFiles = fileItems
|
||||||
.map((data) => data.file)
|
.map((data) => data.file)
|
||||||
.filter((file): file is File => isSupportedImageFile(file));
|
.filter((file) => isSupportedImageFile(file));
|
||||||
|
|
||||||
if (imageFiles.length > 0 && this.isToolSupported("image")) {
|
if (imageFiles.length > 0 && this.isToolSupported("image")) {
|
||||||
return this.insertImages(imageFiles, sceneX, sceneY);
|
return this.insertImages(imageFiles, sceneX, sceneY);
|
||||||
}
|
}
|
||||||
|
|
||||||
const libraryJSON = event.dataTransfer.getData(MIME_TYPES.excalidrawlib);
|
const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib);
|
||||||
if (libraryJSON && typeof libraryJSON === "string") {
|
if (libraryJSON && typeof libraryJSON === "string") {
|
||||||
try {
|
try {
|
||||||
const libraryItems = parseLibraryJSON(libraryJSON);
|
const libraryItems = parseLibraryJSON(libraryJSON);
|
||||||
@ -11043,16 +11059,18 @@ class App extends React.Component<AppProps, AppState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filesData.length > 0) {
|
if (fileItems.length > 0) {
|
||||||
const { file, fileHandle } = filesData[0];
|
const { file, fileHandle } = fileItems[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
// Attempt to parse an excalidraw/excalidrawlib file
|
// Attempt to parse an excalidraw/excalidrawlib file
|
||||||
await this.loadFileToCanvas(file, fileHandle);
|
await this.loadFileToCanvas(file, fileHandle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.dataTransfer?.types?.includes("text/plain")) {
|
const textItem = dataTransferList.findByType(MIME_TYPES.text);
|
||||||
const text = event.dataTransfer?.getData("text");
|
|
||||||
|
if (textItem) {
|
||||||
|
const text = textItem.value;
|
||||||
if (
|
if (
|
||||||
text &&
|
text &&
|
||||||
embeddableURLValidator(text, this.props.validateEmbeddable) &&
|
embeddableURLValidator(text, this.props.validateEmbeddable) &&
|
||||||
|
|||||||
@ -22,6 +22,12 @@
|
|||||||
@include isMobile {
|
@include isMobile {
|
||||||
max-width: 11rem;
|
max-width: 11rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.color-picker-container--no-top-picks {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker__top-picks {
|
.color-picker__top-picks {
|
||||||
@ -80,6 +86,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.color-picker__button-background {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
.color-picker__button-outline {
|
.color-picker__button-outline {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useRef } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
COLOR_OUTLINE_CONTRAST_THRESHOLD,
|
||||||
@ -18,7 +18,12 @@ import { useExcalidrawContainer } from "../App";
|
|||||||
import { ButtonSeparator } from "../ButtonSeparator";
|
import { ButtonSeparator } from "../ButtonSeparator";
|
||||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||||
import { PropertiesPopover } from "../PropertiesPopover";
|
import { PropertiesPopover } from "../PropertiesPopover";
|
||||||
import { slashIcon } from "../icons";
|
import { backgroundIcon, slashIcon, strokeIcon } from "../icons";
|
||||||
|
import {
|
||||||
|
saveCaretPosition,
|
||||||
|
restoreCaretPosition,
|
||||||
|
temporarilyDisableTextEditorBlur,
|
||||||
|
} from "../../hooks/useTextEditorFocus";
|
||||||
|
|
||||||
import { ColorInput } from "./ColorInput";
|
import { ColorInput } from "./ColorInput";
|
||||||
import { Picker } from "./Picker";
|
import { Picker } from "./Picker";
|
||||||
@ -67,6 +72,7 @@ interface ColorPickerProps {
|
|||||||
palette?: ColorPaletteCustom | null;
|
palette?: ColorPaletteCustom | null;
|
||||||
topPicks?: ColorTuple;
|
topPicks?: ColorTuple;
|
||||||
updateData: (formData?: any) => void;
|
updateData: (formData?: any) => void;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ColorPickerPopupContent = ({
|
const ColorPickerPopupContent = ({
|
||||||
@ -77,6 +83,8 @@ const ColorPickerPopupContent = ({
|
|||||||
elements,
|
elements,
|
||||||
palette = COLOR_PALETTE,
|
palette = COLOR_PALETTE,
|
||||||
updateData,
|
updateData,
|
||||||
|
getOpenPopup,
|
||||||
|
appState,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
ColorPickerProps,
|
ColorPickerProps,
|
||||||
| "type"
|
| "type"
|
||||||
@ -86,7 +94,10 @@ const ColorPickerPopupContent = ({
|
|||||||
| "elements"
|
| "elements"
|
||||||
| "palette"
|
| "palette"
|
||||||
| "updateData"
|
| "updateData"
|
||||||
>) => {
|
| "appState"
|
||||||
|
> & {
|
||||||
|
getOpenPopup: () => AppState["openPopup"];
|
||||||
|
}) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||||
|
|
||||||
@ -117,6 +128,8 @@ const ColorPickerPopupContent = ({
|
|||||||
<PropertiesPopover
|
<PropertiesPopover
|
||||||
container={container}
|
container={container}
|
||||||
style={{ maxWidth: "13rem" }}
|
style={{ maxWidth: "13rem" }}
|
||||||
|
// Improve focus handling for text editing scenarios
|
||||||
|
preventAutoFocusOnTouch={!!appState.editingTextElement}
|
||||||
onFocusOutside={(event) => {
|
onFocusOutside={(event) => {
|
||||||
// refocus due to eye dropper
|
// refocus due to eye dropper
|
||||||
focusPickerContent();
|
focusPickerContent();
|
||||||
@ -131,8 +144,23 @@ const ColorPickerPopupContent = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
updateData({ openPopup: null });
|
// only clear if we're still the active popup (avoid racing with switch)
|
||||||
|
if (getOpenPopup() === type) {
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
}
|
||||||
setActiveColorPickerSection(null);
|
setActiveColorPickerSection(null);
|
||||||
|
|
||||||
|
// Refocus text editor when popover closes if we were editing text
|
||||||
|
if (appState.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{palette ? (
|
{palette ? (
|
||||||
@ -141,7 +169,17 @@ const ColorPickerPopupContent = ({
|
|||||||
palette={palette}
|
palette={palette}
|
||||||
color={color}
|
color={color}
|
||||||
onChange={(changedColor) => {
|
onChange={(changedColor) => {
|
||||||
|
// Save caret position before color change if editing text
|
||||||
|
const savedSelection = appState.editingTextElement
|
||||||
|
? saveCaretPosition()
|
||||||
|
: null;
|
||||||
|
|
||||||
onChange(changedColor);
|
onChange(changedColor);
|
||||||
|
|
||||||
|
// Restore caret position after color change if editing text
|
||||||
|
if (appState.editingTextElement && savedSelection) {
|
||||||
|
restoreCaretPosition(savedSelection);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onEyeDropperToggle={(force) => {
|
onEyeDropperToggle={(force) => {
|
||||||
setEyeDropperState((state) => {
|
setEyeDropperState((state) => {
|
||||||
@ -168,6 +206,7 @@ const ColorPickerPopupContent = ({
|
|||||||
if (eyeDropperState) {
|
if (eyeDropperState) {
|
||||||
setEyeDropperState(null);
|
setEyeDropperState(null);
|
||||||
} else {
|
} else {
|
||||||
|
// close explicitly on Escape
|
||||||
updateData({ openPopup: null });
|
updateData({ openPopup: null });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
@ -188,11 +227,32 @@ const ColorPickerTrigger = ({
|
|||||||
label,
|
label,
|
||||||
color,
|
color,
|
||||||
type,
|
type,
|
||||||
|
compactMode = false,
|
||||||
|
mode = "background",
|
||||||
|
onToggle,
|
||||||
|
editingTextElement,
|
||||||
}: {
|
}: {
|
||||||
color: string | null;
|
color: string | null;
|
||||||
label: string;
|
label: string;
|
||||||
type: ColorPickerType;
|
type: ColorPickerType;
|
||||||
|
compactMode?: boolean;
|
||||||
|
mode?: "background" | "stroke";
|
||||||
|
onToggle: () => void;
|
||||||
|
editingTextElement?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
// use pointerdown so we run before outside-close logic
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// If editing text, temporarily disable the wysiwyg blur event
|
||||||
|
if (editingTextElement) {
|
||||||
|
temporarilyDisableTextEditorBlur();
|
||||||
|
}
|
||||||
|
|
||||||
|
onToggle();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger
|
<Popover.Trigger
|
||||||
type="button"
|
type="button"
|
||||||
@ -208,8 +268,37 @@ const ColorPickerTrigger = ({
|
|||||||
? t("labels.showStroke")
|
? t("labels.showStroke")
|
||||||
: t("labels.showBackground")
|
: t("labels.showBackground")
|
||||||
}
|
}
|
||||||
|
data-openpopup={type}
|
||||||
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
<div className="color-picker__button-outline">{!color && slashIcon}</div>
|
||||||
|
{compactMode && color && (
|
||||||
|
<div className="color-picker__button-background">
|
||||||
|
{mode === "background" ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||||
|
? "#fff"
|
||||||
|
: "#111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{backgroundIcon}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
color && isColorDark(color, COLOR_OUTLINE_CONTRAST_THRESHOLD)
|
||||||
|
? "#fff"
|
||||||
|
: "#111",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{strokeIcon}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -224,25 +313,59 @@ export const ColorPicker = ({
|
|||||||
topPicks,
|
topPicks,
|
||||||
updateData,
|
updateData,
|
||||||
appState,
|
appState,
|
||||||
|
compactMode = false,
|
||||||
}: ColorPickerProps) => {
|
}: ColorPickerProps) => {
|
||||||
|
const openRef = useRef(appState.openPopup);
|
||||||
|
useEffect(() => {
|
||||||
|
openRef.current = appState.openPopup;
|
||||||
|
}, [appState.openPopup]);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div role="dialog" aria-modal="true" className="color-picker-container">
|
<div
|
||||||
<TopPicks
|
role="dialog"
|
||||||
activeColor={color}
|
aria-modal="true"
|
||||||
onChange={onChange}
|
className={clsx("color-picker-container", {
|
||||||
type={type}
|
"color-picker-container--no-top-picks": compactMode,
|
||||||
topPicks={topPicks}
|
})}
|
||||||
/>
|
>
|
||||||
<ButtonSeparator />
|
{!compactMode && (
|
||||||
|
<TopPicks
|
||||||
|
activeColor={color}
|
||||||
|
onChange={onChange}
|
||||||
|
type={type}
|
||||||
|
topPicks={topPicks}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!compactMode && <ButtonSeparator />}
|
||||||
<Popover.Root
|
<Popover.Root
|
||||||
open={appState.openPopup === type}
|
open={appState.openPopup === type}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
updateData({ openPopup: open ? type : null });
|
if (open) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* serves as an active color indicator as well */}
|
{/* serves as an active color indicator as well */}
|
||||||
<ColorPickerTrigger color={color} label={label} type={type} />
|
<ColorPickerTrigger
|
||||||
|
color={color}
|
||||||
|
label={label}
|
||||||
|
type={type}
|
||||||
|
compactMode={compactMode}
|
||||||
|
mode={type === "elementStroke" ? "stroke" : "background"}
|
||||||
|
editingTextElement={!!appState.editingTextElement}
|
||||||
|
onToggle={() => {
|
||||||
|
// atomic switch: if another popup is open, close it first, then open this one next tick
|
||||||
|
if (appState.openPopup === type) {
|
||||||
|
// toggle off on same trigger
|
||||||
|
updateData({ openPopup: null });
|
||||||
|
} else if (appState.openPopup) {
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
} else {
|
||||||
|
// open this one
|
||||||
|
updateData({ openPopup: type });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* popup content */}
|
{/* popup content */}
|
||||||
{appState.openPopup === type && (
|
{appState.openPopup === type && (
|
||||||
<ColorPickerPopupContent
|
<ColorPickerPopupContent
|
||||||
@ -253,6 +376,8 @@ export const ColorPicker = ({
|
|||||||
elements={elements}
|
elements={elements}
|
||||||
palette={palette}
|
palette={palette}
|
||||||
updateData={updateData}
|
updateData={updateData}
|
||||||
|
getOpenPopup={() => openRef.current}
|
||||||
|
appState={appState}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
|
|||||||
@ -11,5 +11,10 @@
|
|||||||
2rem + 4 * var(--default-button-size)
|
2rem + 4 * var(--default-button-size)
|
||||||
); // 4 gaps + 4 buttons
|
); // 4 gaps + 4 buttons
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
display: block;
|
||||||
|
grid-template-columns: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
import clsx from "clsx";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
import { FONT_FAMILY } from "@excalidraw/common";
|
import { FONT_FAMILY } from "@excalidraw/common";
|
||||||
@ -58,6 +59,7 @@ interface FontPickerProps {
|
|||||||
onHover: (fontFamily: FontFamilyValues) => void;
|
onHover: (fontFamily: FontFamilyValues) => void;
|
||||||
onLeave: () => void;
|
onLeave: () => void;
|
||||||
onPopupChange: (open: boolean) => void;
|
onPopupChange: (open: boolean) => void;
|
||||||
|
compactMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPicker = React.memo(
|
export const FontPicker = React.memo(
|
||||||
@ -69,6 +71,7 @@ export const FontPicker = React.memo(
|
|||||||
onHover,
|
onHover,
|
||||||
onLeave,
|
onLeave,
|
||||||
onPopupChange,
|
onPopupChange,
|
||||||
|
compactMode = false,
|
||||||
}: FontPickerProps) => {
|
}: FontPickerProps) => {
|
||||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||||
const onSelectCallback = useCallback(
|
const onSelectCallback = useCallback(
|
||||||
@ -81,18 +84,29 @@ export const FontPicker = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
<div
|
||||||
<div className="buttonList">
|
role="dialog"
|
||||||
<RadioSelection<FontFamilyValues | false>
|
aria-modal="true"
|
||||||
type="button"
|
className={clsx("FontPicker__container", {
|
||||||
options={defaultFonts}
|
"FontPicker__container--compact": compactMode,
|
||||||
value={selectedFontFamily}
|
})}
|
||||||
onClick={onSelectCallback}
|
>
|
||||||
/>
|
{!compactMode && (
|
||||||
</div>
|
<div className="buttonList">
|
||||||
<ButtonSeparator />
|
<RadioSelection<FontFamilyValues | false>
|
||||||
|
type="button"
|
||||||
|
options={defaultFonts}
|
||||||
|
value={selectedFontFamily}
|
||||||
|
onClick={onSelectCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!compactMode && <ButtonSeparator />}
|
||||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
<FontPickerTrigger
|
||||||
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
isOpened={isOpened}
|
||||||
|
/>
|
||||||
{isOpened && (
|
{isOpened && (
|
||||||
<FontPickerList
|
<FontPickerList
|
||||||
selectedFontFamily={selectedFontFamily}
|
selectedFontFamily={selectedFontFamily}
|
||||||
|
|||||||
@ -90,7 +90,8 @@ export const FontPickerList = React.memo(
|
|||||||
onClose,
|
onClose,
|
||||||
}: FontPickerListProps) => {
|
}: FontPickerListProps) => {
|
||||||
const { container } = useExcalidrawContainer();
|
const { container } = useExcalidrawContainer();
|
||||||
const { fonts } = useApp();
|
const app = useApp();
|
||||||
|
const { fonts } = app;
|
||||||
const { showDeprecatedFonts } = useAppProps();
|
const { showDeprecatedFonts } = useAppProps();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
@ -187,6 +188,42 @@ export const FontPickerList = React.memo(
|
|||||||
onLeave,
|
onLeave,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create a wrapped onSelect function that preserves caret position
|
||||||
|
const wrappedOnSelect = useCallback(
|
||||||
|
(fontFamily: FontFamilyValues) => {
|
||||||
|
// Save caret position before font selection if editing text
|
||||||
|
let savedSelection: { start: number; end: number } | null = null;
|
||||||
|
if (app.state.editingTextElement) {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
savedSelection = {
|
||||||
|
start: textEditor.selectionStart,
|
||||||
|
end: textEditor.selectionEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect(fontFamily);
|
||||||
|
|
||||||
|
// Restore caret position after font selection if editing text
|
||||||
|
if (app.state.editingTextElement && savedSelection) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor && savedSelection) {
|
||||||
|
textEditor.focus();
|
||||||
|
textEditor.selectionStart = savedSelection.start;
|
||||||
|
textEditor.selectionEnd = savedSelection.end;
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelect, app.state.editingTextElement],
|
||||||
|
);
|
||||||
|
|
||||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||||
(event) => {
|
(event) => {
|
||||||
const handled = fontPickerKeyHandler({
|
const handled = fontPickerKeyHandler({
|
||||||
@ -194,7 +231,7 @@ export const FontPickerList = React.memo(
|
|||||||
inputRef,
|
inputRef,
|
||||||
hoveredFont,
|
hoveredFont,
|
||||||
filteredFonts,
|
filteredFonts,
|
||||||
onSelect,
|
onSelect: wrappedOnSelect,
|
||||||
onHover,
|
onHover,
|
||||||
onClose,
|
onClose,
|
||||||
});
|
});
|
||||||
@ -204,7 +241,7 @@ export const FontPickerList = React.memo(
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
[hoveredFont, filteredFonts, wrappedOnSelect, onHover, onClose],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -240,7 +277,7 @@ export const FontPickerList = React.memo(
|
|||||||
// allow to tab between search and selected font
|
// allow to tab between search and selected font
|
||||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
onSelect(Number(e.currentTarget.value));
|
wrappedOnSelect(Number(e.currentTarget.value));
|
||||||
}}
|
}}
|
||||||
onMouseMove={() => {
|
onMouseMove={() => {
|
||||||
if (hoveredFont?.value !== font.value) {
|
if (hoveredFont?.value !== font.value) {
|
||||||
@ -282,9 +319,24 @@ export const FontPickerList = React.memo(
|
|||||||
className="properties-content"
|
className="properties-content"
|
||||||
container={container}
|
container={container}
|
||||||
style={{ width: "15rem" }}
|
style={{ width: "15rem" }}
|
||||||
onClose={onClose}
|
onClose={() => {
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// Refocus text editor when font picker closes if we were editing text
|
||||||
|
if (app.state.editingTextElement) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = document.querySelector(
|
||||||
|
".excalidraw-wysiwyg",
|
||||||
|
) as HTMLTextAreaElement;
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}}
|
||||||
onPointerLeave={onLeave}
|
onPointerLeave={onLeave}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
preventAutoFocusOnTouch={!!app.state.editingTextElement}
|
||||||
>
|
>
|
||||||
<QuickSearch
|
<QuickSearch
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import type { FontFamilyValues } from "@excalidraw/element/types";
|
import type { FontFamilyValues } from "@excalidraw/element/types";
|
||||||
|
|
||||||
@ -7,33 +6,38 @@ import { t } from "../../i18n";
|
|||||||
import { ButtonIcon } from "../ButtonIcon";
|
import { ButtonIcon } from "../ButtonIcon";
|
||||||
import { TextIcon } from "../icons";
|
import { TextIcon } from "../icons";
|
||||||
|
|
||||||
import { isDefaultFont } from "./FontPicker";
|
import { useExcalidrawSetAppState } from "../App";
|
||||||
|
|
||||||
interface FontPickerTriggerProps {
|
interface FontPickerTriggerProps {
|
||||||
selectedFontFamily: FontFamilyValues | null;
|
selectedFontFamily: FontFamilyValues | null;
|
||||||
|
isOpened?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FontPickerTrigger = ({
|
export const FontPickerTrigger = ({
|
||||||
selectedFontFamily,
|
selectedFontFamily,
|
||||||
|
isOpened = false,
|
||||||
}: FontPickerTriggerProps) => {
|
}: FontPickerTriggerProps) => {
|
||||||
const isTriggerActive = useMemo(
|
const setAppState = useExcalidrawSetAppState();
|
||||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
|
||||||
[selectedFontFamily],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
<div data-openpopup="fontFamily" className="properties-trigger">
|
||||||
<div>
|
|
||||||
<ButtonIcon
|
<ButtonIcon
|
||||||
standalone
|
standalone
|
||||||
icon={TextIcon}
|
icon={TextIcon}
|
||||||
title={t("labels.showFonts")}
|
title={t("labels.showFonts")}
|
||||||
className="properties-trigger"
|
className="properties-trigger"
|
||||||
testId={"font-family-show-fonts"}
|
testId={"font-family-show-fonts"}
|
||||||
active={isTriggerActive}
|
active={isOpened}
|
||||||
// no-op
|
onClick={() => {
|
||||||
onClick={() => {}}
|
setAppState((appState) => ({
|
||||||
|
openPopup:
|
||||||
|
appState.openPopup === "fontFamily" ? null : appState.openPopup,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
border: "none",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|||||||
@ -24,6 +24,10 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
pointer-events: var(--ui-pointerEvents);
|
pointer-events: var(--ui-pointerEvents);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
CLASSES,
|
CLASSES,
|
||||||
DEFAULT_SIDEBAR,
|
DEFAULT_SIDEBAR,
|
||||||
|
MQ_MIN_WIDTH_DESKTOP,
|
||||||
TOOL_TYPE,
|
TOOL_TYPE,
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
capitalizeString,
|
capitalizeString,
|
||||||
@ -28,7 +29,11 @@ import { useAtom, useAtomValue } from "../editor-jotai";
|
|||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
|
|
||||||
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
|
import {
|
||||||
|
SelectedShapeActions,
|
||||||
|
ShapesSwitcher,
|
||||||
|
CompactShapeActions,
|
||||||
|
} from "./Actions";
|
||||||
import { LoadingMessage } from "./LoadingMessage";
|
import { LoadingMessage } from "./LoadingMessage";
|
||||||
import { LockButton } from "./LockButton";
|
import { LockButton } from "./LockButton";
|
||||||
import { MobileMenu } from "./MobileMenu";
|
import { MobileMenu } from "./MobileMenu";
|
||||||
@ -157,6 +162,25 @@ const LayerUI = ({
|
|||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const tunnels = useInitializeTunnels();
|
const tunnels = useInitializeTunnels();
|
||||||
|
|
||||||
|
const spacing =
|
||||||
|
appState.stylesPanelMode === "compact"
|
||||||
|
? {
|
||||||
|
menuTopGap: 4,
|
||||||
|
toolbarColGap: 4,
|
||||||
|
toolbarRowGap: 1,
|
||||||
|
toolbarInnerRowGap: 0.5,
|
||||||
|
islandPadding: 1,
|
||||||
|
collabMarginLeft: 8,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
menuTopGap: 6,
|
||||||
|
toolbarColGap: 4,
|
||||||
|
toolbarRowGap: 1,
|
||||||
|
toolbarInnerRowGap: 1,
|
||||||
|
islandPadding: 1,
|
||||||
|
collabMarginLeft: 8,
|
||||||
|
};
|
||||||
|
|
||||||
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
const TunnelsJotaiProvider = tunnels.tunnelsJotai.Provider;
|
||||||
|
|
||||||
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
const [eyeDropperState, setEyeDropperState] = useAtom(activeEyeDropperAtom);
|
||||||
@ -209,31 +233,55 @@ const LayerUI = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderSelectedShapeActions = () => (
|
const renderSelectedShapeActions = () => {
|
||||||
<Section
|
const isCompactMode = appState.stylesPanelMode === "compact";
|
||||||
heading="selectedShapeActions"
|
|
||||||
className={clsx("selected-shape-actions zen-mode-transition", {
|
return (
|
||||||
"transition-left": appState.zenModeEnabled,
|
<Section
|
||||||
})}
|
heading="selectedShapeActions"
|
||||||
>
|
className={clsx("selected-shape-actions zen-mode-transition", {
|
||||||
<Island
|
"transition-left": appState.zenModeEnabled,
|
||||||
className={CLASSES.SHAPE_ACTIONS_MENU}
|
})}
|
||||||
padding={2}
|
|
||||||
style={{
|
|
||||||
// we want to make sure this doesn't overflow so subtracting the
|
|
||||||
// approximate height of hamburgerMenu + footer
|
|
||||||
maxHeight: `${appState.height - 166}px`,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectedShapeActions
|
{isCompactMode ? (
|
||||||
appState={appState}
|
<Island
|
||||||
elementsMap={app.scene.getNonDeletedElementsMap()}
|
className={clsx("compact-shape-actions-island")}
|
||||||
renderAction={actionManager.renderAction}
|
padding={0}
|
||||||
app={app}
|
style={{
|
||||||
/>
|
// we want to make sure this doesn't overflow so subtracting the
|
||||||
</Island>
|
// approximate height of hamburgerMenu + footer
|
||||||
</Section>
|
maxHeight: `${appState.height - 166}px`,
|
||||||
);
|
}}
|
||||||
|
>
|
||||||
|
<CompactShapeActions
|
||||||
|
appState={appState}
|
||||||
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
app={app}
|
||||||
|
setAppState={setAppState}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
) : (
|
||||||
|
<Island
|
||||||
|
className={CLASSES.SHAPE_ACTIONS_MENU}
|
||||||
|
padding={2}
|
||||||
|
style={{
|
||||||
|
// we want to make sure this doesn't overflow so subtracting the
|
||||||
|
// approximate height of hamburgerMenu + footer
|
||||||
|
maxHeight: `${appState.height - 166}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectedShapeActions
|
||||||
|
appState={appState}
|
||||||
|
elementsMap={app.scene.getNonDeletedElementsMap()}
|
||||||
|
renderAction={actionManager.renderAction}
|
||||||
|
app={app}
|
||||||
|
/>
|
||||||
|
</Island>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderFixedSideContainer = () => {
|
const renderFixedSideContainer = () => {
|
||||||
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
const shouldRenderSelectedShapeActions = showSelectedShapeActions(
|
||||||
@ -250,9 +298,19 @@ const LayerUI = ({
|
|||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
<Stack.Col
|
||||||
|
gap={spacing.menuTopGap}
|
||||||
|
className={clsx("App-menu_top__left")}
|
||||||
|
>
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
<div
|
||||||
|
className={clsx("selected-shape-actions-container", {
|
||||||
|
"selected-shape-actions-container--compact":
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
|
</div>
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
{!appState.viewModeEnabled &&
|
{!appState.viewModeEnabled &&
|
||||||
appState.openDialog?.name !== "elementLinkSelector" && (
|
appState.openDialog?.name !== "elementLinkSelector" && (
|
||||||
@ -262,17 +320,19 @@ const LayerUI = ({
|
|||||||
{renderWelcomeScreen && (
|
{renderWelcomeScreen && (
|
||||||
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
<tunnels.WelcomeScreenToolbarHintTunnel.Out />
|
||||||
)}
|
)}
|
||||||
<Stack.Col gap={4} align="start">
|
<Stack.Col gap={spacing.toolbarColGap} align="start">
|
||||||
<Stack.Row
|
<Stack.Row
|
||||||
gap={1}
|
gap={spacing.toolbarRowGap}
|
||||||
className={clsx("App-toolbar-container", {
|
className={clsx("App-toolbar-container", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Island
|
<Island
|
||||||
padding={1}
|
padding={spacing.islandPadding}
|
||||||
className={clsx("App-toolbar", {
|
className={clsx("App-toolbar", {
|
||||||
"zen-mode": appState.zenModeEnabled,
|
"zen-mode": appState.zenModeEnabled,
|
||||||
|
"App-toolbar--compact":
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HintViewer
|
<HintViewer
|
||||||
@ -282,7 +342,7 @@ const LayerUI = ({
|
|||||||
app={app}
|
app={app}
|
||||||
/>
|
/>
|
||||||
{heading}
|
{heading}
|
||||||
<Stack.Row gap={1}>
|
<Stack.Row gap={spacing.toolbarInnerRowGap}>
|
||||||
<PenModeButton
|
<PenModeButton
|
||||||
zenModeEnabled={appState.zenModeEnabled}
|
zenModeEnabled={appState.zenModeEnabled}
|
||||||
checked={appState.penMode}
|
checked={appState.penMode}
|
||||||
@ -316,7 +376,7 @@ const LayerUI = ({
|
|||||||
{isCollaborating && (
|
{isCollaborating && (
|
||||||
<Island
|
<Island
|
||||||
style={{
|
style={{
|
||||||
marginLeft: 8,
|
marginLeft: spacing.collabMarginLeft,
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
height: "fit-content",
|
height: "fit-content",
|
||||||
}}
|
}}
|
||||||
@ -344,6 +404,8 @@ const LayerUI = ({
|
|||||||
"layer-ui__wrapper__top-right zen-mode-transition",
|
"layer-ui__wrapper__top-right zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-right": appState.zenModeEnabled,
|
"transition-right": appState.zenModeEnabled,
|
||||||
|
"layer-ui__wrapper__top-right--compact":
|
||||||
|
appState.stylesPanelMode === "compact",
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@ -418,7 +480,9 @@ const LayerUI = ({
|
|||||||
}}
|
}}
|
||||||
tab={DEFAULT_SIDEBAR.defaultTab}
|
tab={DEFAULT_SIDEBAR.defaultTab}
|
||||||
>
|
>
|
||||||
{t("toolBar.library")}
|
{appState.stylesPanelMode === "full" &&
|
||||||
|
appState.width >= MQ_MIN_WIDTH_DESKTOP &&
|
||||||
|
t("toolBar.library")}
|
||||||
</DefaultSidebar.Trigger>
|
</DefaultSidebar.Trigger>
|
||||||
<DefaultOverwriteConfirmDialog />
|
<DefaultOverwriteConfirmDialog />
|
||||||
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
{appState.openDialog?.name === "ttd" && <TTDDialog __fallback />}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ interface PropertiesPopoverProps {
|
|||||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||||
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
|
onFocusOutside?: Popover.PopoverContentProps["onFocusOutside"];
|
||||||
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
|
onPointerDownOutside?: Popover.PopoverContentProps["onPointerDownOutside"];
|
||||||
|
preventAutoFocusOnTouch?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PropertiesPopover = React.forwardRef<
|
export const PropertiesPopover = React.forwardRef<
|
||||||
@ -34,6 +35,7 @@ export const PropertiesPopover = React.forwardRef<
|
|||||||
onFocusOutside,
|
onFocusOutside,
|
||||||
onPointerLeave,
|
onPointerLeave,
|
||||||
onPointerDownOutside,
|
onPointerDownOutside,
|
||||||
|
preventAutoFocusOnTouch = false,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@ -64,6 +66,12 @@ export const PropertiesPopover = React.forwardRef<
|
|||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onFocusOutside={onFocusOutside}
|
onFocusOutside={onFocusOutside}
|
||||||
onPointerDownOutside={onPointerDownOutside}
|
onPointerDownOutside={onPointerDownOutside}
|
||||||
|
onOpenAutoFocus={(e) => {
|
||||||
|
// prevent auto-focus on touch devices to avoid keyboard popup
|
||||||
|
if (preventAutoFocusOnTouch && device.isTouchScreen) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
onCloseAutoFocus={(e) => {
|
onCloseAutoFocus={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
// prevents focusing the trigger
|
// prevents focusing the trigger
|
||||||
|
|||||||
@ -10,6 +10,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
.ToolIcon__keybinding {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.App-toolbar__divider {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__divider {
|
&__divider {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
|||||||
@ -118,6 +118,17 @@ export const DotsIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// tabler-icons: dots-horizontal (horizontal equivalent of dots-vertical)
|
||||||
|
export const DotsHorizontalIcon = createIcon(
|
||||||
|
<g strokeWidth="1.5">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
<path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
// tabler-icons: pinned
|
// tabler-icons: pinned
|
||||||
export const PinIcon = createIcon(
|
export const PinIcon = createIcon(
|
||||||
<svg strokeWidth="1.5">
|
<svg strokeWidth="1.5">
|
||||||
@ -396,6 +407,19 @@ export const TextIcon = createIcon(
|
|||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const TextSizeIcon = createIcon(
|
||||||
|
<g stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M3 7v-2h13v2" />
|
||||||
|
<path d="M10 5v14" />
|
||||||
|
<path d="M12 19h-4" />
|
||||||
|
<path d="M15 13v-1h6v1" />
|
||||||
|
<path d="M18 12v7" />
|
||||||
|
<path d="M17 19h2" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
// modified tabler-icons: photo
|
// modified tabler-icons: photo
|
||||||
export const ImageIcon = createIcon(
|
export const ImageIcon = createIcon(
|
||||||
<g strokeWidth="1.25">
|
<g strokeWidth="1.25">
|
||||||
@ -2269,3 +2293,48 @@ export const elementLinkIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
tablerIconProps,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const resizeIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M4 11v8a1 1 0 0 0 1 1h8m-9 -14v-1a1 1 0 0 1 1 -1h1m5 0h2m5 0h1a1 1 0 0 1 1 1v1m0 5v2m0 5v1a1 1 0 0 1 -1 1h-1" />
|
||||||
|
<path d="M4 12h7a1 1 0 0 1 1 1v7" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const adjustmentsIcon = createIcon(
|
||||||
|
<g strokeWidth={1.5}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M14 6m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M4 6l8 0" />
|
||||||
|
<path d="M16 6l4 0" />
|
||||||
|
<path d="M8 12m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M4 12l2 0" />
|
||||||
|
<path d="M10 12l10 0" />
|
||||||
|
<path d="M17 18m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||||
|
<path d="M4 18l11 0" />
|
||||||
|
<path d="M19 18l1 0" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const backgroundIcon = createIcon(
|
||||||
|
<g strokeWidth={1}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M6 10l4 -4" />
|
||||||
|
<path d="M6 14l8 -8" />
|
||||||
|
<path d="M6 18l12 -12" />
|
||||||
|
<path d="M10 18l8 -8" />
|
||||||
|
<path d="M14 18l4 -4" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const strokeIcon = createIcon(
|
||||||
|
<g strokeWidth={1}>
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<rect x="6" y="6" width="12" height="12" fill="none" />
|
||||||
|
</g>,
|
||||||
|
tablerIconProps,
|
||||||
|
);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import { isMobileOrTablet, MQ_MIN_WIDTH_DESKTOP } from "@excalidraw/common";
|
||||||
|
|
||||||
import { t } from "../../i18n";
|
import { t } from "../../i18n";
|
||||||
import { Button } from "../Button";
|
import { Button } from "../Button";
|
||||||
import { share } from "../icons";
|
import { share } from "../icons";
|
||||||
@ -17,7 +19,8 @@ const LiveCollaborationTrigger = ({
|
|||||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||||
const appState = useUIAppState();
|
const appState = useUIAppState();
|
||||||
|
|
||||||
const showIconOnly = appState.width < 830;
|
const showIconOnly =
|
||||||
|
isMobileOrTablet() || appState.width < MQ_MIN_WIDTH_DESKTOP;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -317,7 +317,7 @@ body.excalidraw-cursor-resize * {
|
|||||||
|
|
||||||
.App-menu_top {
|
.App-menu_top {
|
||||||
grid-template-columns: 1fr 2fr 1fr;
|
grid-template-columns: 1fr 2fr 1fr;
|
||||||
grid-gap: 2rem;
|
grid-gap: 1rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
pointer-events: none !important;
|
pointer-events: none !important;
|
||||||
@ -336,6 +336,14 @@ body.excalidraw-cursor-resize * {
|
|||||||
justify-self: flex-start;
|
justify-self: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-shape-actions-container {
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
&--compact {
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.App-menu_top > *:last-child {
|
.App-menu_top > *:last-child {
|
||||||
justify-self: flex-end;
|
justify-self: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,8 +16,6 @@ import { CanvasError, ImageSceneDataError } from "../errors";
|
|||||||
import { calculateScrollCenter } from "../scene";
|
import { calculateScrollCenter } from "../scene";
|
||||||
import { decodeSvgBase64Payload } from "../scene/export";
|
import { decodeSvgBase64Payload } from "../scene/export";
|
||||||
|
|
||||||
import { isClipboardEvent } from "../clipboard";
|
|
||||||
|
|
||||||
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
import { base64ToString, stringToBase64, toByteString } from "./encode";
|
||||||
import { nativeFileSystemSupported } from "./filesystem";
|
import { nativeFileSystemSupported } from "./filesystem";
|
||||||
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
import { isValidExcalidrawData, isValidLibrary } from "./json";
|
||||||
@ -96,6 +94,8 @@ export const getMimeType = (blob: Blob | string): string => {
|
|||||||
return MIME_TYPES.jpg;
|
return MIME_TYPES.jpg;
|
||||||
} else if (/\.svg$/.test(name)) {
|
} else if (/\.svg$/.test(name)) {
|
||||||
return MIME_TYPES.svg;
|
return MIME_TYPES.svg;
|
||||||
|
} else if (/\.excalidrawlib$/.test(name)) {
|
||||||
|
return MIME_TYPES.excalidrawlib;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
};
|
};
|
||||||
@ -389,42 +389,6 @@ export const ImageURLToFile = async (
|
|||||||
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getFilesFromEvent = async (
|
|
||||||
event: React.DragEvent<HTMLDivElement> | ClipboardEvent,
|
|
||||||
) => {
|
|
||||||
let fileList: FileList | undefined = undefined;
|
|
||||||
let items: DataTransferItemList | undefined = undefined;
|
|
||||||
|
|
||||||
if (isClipboardEvent(event)) {
|
|
||||||
fileList = event.clipboardData?.files;
|
|
||||||
items = event.clipboardData?.items;
|
|
||||||
} else {
|
|
||||||
const dragEvent = event as React.DragEvent<HTMLDivElement>;
|
|
||||||
fileList = dragEvent.dataTransfer?.files;
|
|
||||||
items = dragEvent.dataTransfer?.items;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files: (File | null)[] = Array.from(fileList || []);
|
|
||||||
|
|
||||||
return await Promise.all(
|
|
||||||
files.map(async (file, idx) => {
|
|
||||||
const dataTransferItem = items?.[idx];
|
|
||||||
const fileHandle = dataTransferItem
|
|
||||||
? getFileHandle(dataTransferItem)
|
|
||||||
: null;
|
|
||||||
return file
|
|
||||||
? {
|
|
||||||
file: await normalizeFile(file),
|
|
||||||
fileHandle: await fileHandle,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
file: null,
|
|
||||||
fileHandle: null,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileHandle = async (
|
export const getFileHandle = async (
|
||||||
event: DragEvent | React.DragEvent | DataTransferItem,
|
event: DragEvent | React.DragEvent | DataTransferItem,
|
||||||
): Promise<FileSystemHandle | null> => {
|
): Promise<FileSystemHandle | null> => {
|
||||||
|
|||||||
112
packages/excalidraw/hooks/useTextEditorFocus.ts
Normal file
112
packages/excalidraw/hooks/useTextEditorFocus.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
|
||||||
|
// Utility type for caret position
|
||||||
|
export type CaretPosition = {
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to get text editor element
|
||||||
|
const getTextEditor = (): HTMLTextAreaElement | null => {
|
||||||
|
return document.querySelector(".excalidraw-wysiwyg") as HTMLTextAreaElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility functions for caret position management
|
||||||
|
export const saveCaretPosition = (): CaretPosition | null => {
|
||||||
|
const textEditor = getTextEditor();
|
||||||
|
if (textEditor) {
|
||||||
|
return {
|
||||||
|
start: textEditor.selectionStart,
|
||||||
|
end: textEditor.selectionEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const restoreCaretPosition = (position: CaretPosition | null): void => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = getTextEditor();
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
if (position) {
|
||||||
|
textEditor.selectionStart = position.start;
|
||||||
|
textEditor.selectionEnd = position.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const withCaretPositionPreservation = (
|
||||||
|
callback: () => void,
|
||||||
|
isCompactMode: boolean,
|
||||||
|
isEditingText: boolean,
|
||||||
|
onPreventClose?: () => void,
|
||||||
|
): void => {
|
||||||
|
// Prevent popover from closing in compact mode
|
||||||
|
if (isCompactMode && onPreventClose) {
|
||||||
|
onPreventClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save caret position if editing text
|
||||||
|
const savedPosition =
|
||||||
|
isCompactMode && isEditingText ? saveCaretPosition() : null;
|
||||||
|
|
||||||
|
// Execute the callback
|
||||||
|
callback();
|
||||||
|
|
||||||
|
// Restore caret position if needed
|
||||||
|
if (isCompactMode && isEditingText) {
|
||||||
|
restoreCaretPosition(savedPosition);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook for managing text editor caret position with state
|
||||||
|
export const useTextEditorFocus = () => {
|
||||||
|
const [savedCaretPosition, setSavedCaretPosition] =
|
||||||
|
useState<CaretPosition | null>(null);
|
||||||
|
|
||||||
|
const saveCaretPositionToState = useCallback(() => {
|
||||||
|
const position = saveCaretPosition();
|
||||||
|
setSavedCaretPosition(position);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restoreCaretPositionFromState = useCallback(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const textEditor = getTextEditor();
|
||||||
|
if (textEditor) {
|
||||||
|
textEditor.focus();
|
||||||
|
if (savedCaretPosition) {
|
||||||
|
textEditor.selectionStart = savedCaretPosition.start;
|
||||||
|
textEditor.selectionEnd = savedCaretPosition.end;
|
||||||
|
setSavedCaretPosition(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
}, [savedCaretPosition]);
|
||||||
|
|
||||||
|
const clearSavedPosition = useCallback(() => {
|
||||||
|
setSavedCaretPosition(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
saveCaretPosition: saveCaretPositionToState,
|
||||||
|
restoreCaretPosition: restoreCaretPositionFromState,
|
||||||
|
clearSavedPosition,
|
||||||
|
hasSavedPosition: !!savedCaretPosition,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to temporarily disable text editor blur
|
||||||
|
export const temporarilyDisableTextEditorBlur = (
|
||||||
|
duration: number = 100,
|
||||||
|
): void => {
|
||||||
|
const textEditor = getTextEditor();
|
||||||
|
if (textEditor) {
|
||||||
|
const originalOnBlur = textEditor.onblur;
|
||||||
|
textEditor.onblur = null;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
textEditor.onblur = originalOnBlur;
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -982,6 +982,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -1174,6 +1175,7 @@ exports[`contextMenu element > selecting 'Add to library' in context menu adds e
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": {
|
"toast": {
|
||||||
@ -1387,6 +1389,7 @@ exports[`contextMenu element > selecting 'Bring forward' in context menu brings
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -1717,6 +1720,7 @@ exports[`contextMenu element > selecting 'Bring to front' in context menu brings
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2047,6 +2051,7 @@ exports[`contextMenu element > selecting 'Copy styles' in context menu copies st
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": {
|
"toast": {
|
||||||
@ -2258,6 +2263,7 @@ exports[`contextMenu element > selecting 'Delete' in context menu deletes elemen
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2500,6 +2506,7 @@ exports[`contextMenu element > selecting 'Duplicate' in context menu duplicates
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2802,6 +2809,7 @@ exports[`contextMenu element > selecting 'Group selection' in context menu group
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3168,6 +3176,7 @@ exports[`contextMenu element > selecting 'Paste styles' in context menu pastes s
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": {
|
"toast": {
|
||||||
@ -3660,6 +3669,7 @@ exports[`contextMenu element > selecting 'Send backward' in context menu sends e
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3982,6 +3992,7 @@ exports[`contextMenu element > selecting 'Send to back' in context menu sends el
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4307,6 +4318,7 @@ exports[`contextMenu element > selecting 'Ungroup selection' in context menu ung
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5591,6 +5603,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -6809,6 +6822,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7739,6 +7753,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8737,6 +8752,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9730,6 +9746,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
|
|||||||
@ -688,6 +688,7 @@ exports[`<Excalidraw/> > Test UIOptions prop > Test canvasActions > should rende
|
|||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
aria-label="Canvas background"
|
aria-label="Canvas background"
|
||||||
class="color-picker__button active-color properties-trigger has-outline"
|
class="color-picker__button active-color properties-trigger has-outline"
|
||||||
|
data-openpopup="canvasBackground"
|
||||||
data-state="closed"
|
data-state="closed"
|
||||||
style="--swatch-color: #ffffff;"
|
style="--swatch-color: #ffffff;"
|
||||||
title="Show background color picker"
|
title="Show background color picker"
|
||||||
|
|||||||
@ -101,6 +101,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -872,6 +873,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -1570,6 +1572,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -1929,6 +1932,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2290,6 +2294,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2551,6 +2556,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3072,6 +3078,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3374,6 +3381,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3692,6 +3700,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3985,6 +3994,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4270,6 +4280,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4504,6 +4515,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4760,6 +4772,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5030,6 +5043,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5258,6 +5272,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5486,6 +5501,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5732,6 +5748,7 @@ exports[`history > multiplayer undo/redo > conflicts in bound text elements and
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5987,6 +6004,7 @@ exports[`history > multiplayer undo/redo > conflicts in frames and their childre
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -6243,6 +6261,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -6571,6 +6590,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7000,6 +7020,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7379,6 +7400,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7679,6 +7701,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7970,6 +7993,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8199,6 +8223,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8550,6 +8575,7 @@ exports[`history > multiplayer undo/redo > should iterate through the history wh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8907,6 +8933,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9306,6 +9333,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9586,6 +9614,7 @@ exports[`history > multiplayer undo/redo > should not let remote changes to inte
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9849,6 +9878,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10113,6 +10143,7 @@ exports[`history > multiplayer undo/redo > should not override remote changes on
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10345,6 +10376,7 @@ exports[`history > multiplayer undo/redo > should override remotely added groups
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10640,6 +10672,7 @@ exports[`history > multiplayer undo/redo > should override remotely added points
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10954,6 +10987,7 @@ exports[`history > multiplayer undo/redo > should redistribute deltas when eleme
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11192,6 +11226,7 @@ exports[`history > multiplayer undo/redo > should redraw arrows on undo > [end o
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11632,6 +11667,7 @@ exports[`history > multiplayer undo/redo > should update history entries after r
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11889,6 +11925,7 @@ exports[`history > singleplayer undo/redo > remounting undo/redo buttons should
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -12125,6 +12162,7 @@ exports[`history > singleplayer undo/redo > should clear the redo stack on eleme
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -12359,6 +12397,7 @@ exports[`history > singleplayer undo/redo > should create entry when selecting f
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -12689,7 +12728,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
|||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
"editingTextElement": null,
|
"editingTextElement": null,
|
||||||
"elementsToHighlight": null,
|
"elementsToHighlight": null,
|
||||||
"errorMessage": "Couldn't load invalid file",
|
"errorMessage": null,
|
||||||
"exportBackground": true,
|
"exportBackground": true,
|
||||||
"exportEmbedScene": false,
|
"exportEmbedScene": false,
|
||||||
"exportScale": 1,
|
"exportScale": 1,
|
||||||
@ -12754,6 +12793,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -12960,6 +13000,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on e
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -13170,6 +13211,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -13467,6 +13509,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on i
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -13764,6 +13807,7 @@ exports[`history > singleplayer undo/redo > should create new history entry on s
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14007,6 +14051,7 @@ exports[`history > singleplayer undo/redo > should disable undo/redo buttons whe
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14243,6 +14288,7 @@ exports[`history > singleplayer undo/redo > should end up with no history entry
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14479,6 +14525,7 @@ exports[`history > singleplayer undo/redo > should iterate through the history w
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14725,6 +14772,7 @@ exports[`history > singleplayer undo/redo > should not clear the redo stack on s
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -15056,6 +15104,7 @@ exports[`history > singleplayer undo/redo > should not collapse when applying co
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -15227,6 +15276,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -15508,6 +15558,7 @@ exports[`history > singleplayer undo/redo > should not end up with history entry
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -15770,6 +15821,7 @@ exports[`history > singleplayer undo/redo > should not modify anything on unrela
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -15923,6 +15975,7 @@ exports[`history > singleplayer undo/redo > should not override appstate changes
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -16203,6 +16256,7 @@ exports[`history > singleplayer undo/redo > should support appstate name or view
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -16365,6 +16419,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -17243,6 +17298,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -17956,6 +18012,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -18667,6 +18724,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -19554,6 +19612,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -20457,6 +20516,7 @@ exports[`history > singleplayer undo/redo > should support changes in elements'
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -20938,6 +20998,7 @@ exports[`history > singleplayer undo/redo > should support duplication of groups
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -21443,6 +21504,7 @@ exports[`history > singleplayer undo/redo > should support element creation, del
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -21903,6 +21965,7 @@ exports[`history > singleplayer undo/redo > should support linear element creati
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
|
|||||||
@ -109,6 +109,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -536,6 +537,7 @@ exports[`given element A and group of elements B and given both are selected whe
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -942,6 +944,7 @@ exports[`regression tests > Cmd/Ctrl-click exclusively select element under poin
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -1507,6 +1510,7 @@ exports[`regression tests > Drags selected element when hitting only bounding bo
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -1718,6 +1722,7 @@ exports[`regression tests > adjusts z order when grouping > [end of test] appSta
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2098,6 +2103,7 @@ exports[`regression tests > alt-drag duplicates an element > [end of test] appSt
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2340,6 +2346,7 @@ exports[`regression tests > arrow keys > [end of test] appState 1`] = `
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2521,6 +2528,7 @@ exports[`regression tests > can drag element that covers another element, while
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -2843,6 +2851,7 @@ exports[`regression tests > change the properties of a shape > [end of test] app
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3099,6 +3108,7 @@ exports[`regression tests > click on an element and drag it > [dragged] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3339,6 +3349,7 @@ exports[`regression tests > click on an element and drag it > [end of test] appS
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3574,6 +3585,7 @@ exports[`regression tests > click to select a shape > [end of test] appState 1`]
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -3832,6 +3844,7 @@ exports[`regression tests > click-drag to select a group > [end of test] appStat
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4144,6 +4157,7 @@ exports[`regression tests > deleting last but one element in editing group shoul
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4606,6 +4620,7 @@ exports[`regression tests > deselects group of selected elements on pointer down
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -4860,6 +4875,7 @@ exports[`regression tests > deselects group of selected elements on pointer up w
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5162,6 +5178,7 @@ exports[`regression tests > deselects selected element on pointer down when poin
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5341,6 +5358,7 @@ exports[`regression tests > deselects selected element, on pointer up, when clic
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5540,6 +5558,7 @@ exports[`regression tests > double click to edit a group > [end of test] appStat
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -5936,6 +5955,7 @@ exports[`regression tests > drags selected elements from point inside common bou
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -6226,6 +6246,7 @@ exports[`regression tests > draw every type of shape > [end of test] appState 1`
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7020,6 +7041,7 @@ exports[`regression tests > given a group of selected elements with an element t
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7353,6 +7375,7 @@ exports[`regression tests > given a selected element A and a not selected elemen
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7630,6 +7653,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -7864,6 +7888,7 @@ exports[`regression tests > given selected element A with lower z-index than uns
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8101,6 +8126,7 @@ exports[`regression tests > key 2 selects rectangle tool > [end of test] appStat
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8280,6 +8306,7 @@ exports[`regression tests > key 3 selects diamond tool > [end of test] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8459,6 +8486,7 @@ exports[`regression tests > key 4 selects ellipse tool > [end of test] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8665,6 +8693,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -8893,6 +8922,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9090,6 +9120,7 @@ exports[`regression tests > key 7 selects freedraw tool > [end of test] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9310,6 +9341,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9511,6 +9543,7 @@ exports[`regression tests > key d selects diamond tool > [end of test] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9717,6 +9750,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -9916,6 +9950,7 @@ exports[`regression tests > key o selects ellipse tool > [end of test] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10093,6 +10128,7 @@ exports[`regression tests > key p selects freedraw tool > [end of test] appState
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10286,6 +10322,7 @@ exports[`regression tests > key r selects rectangle tool > [end of test] appStat
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10473,6 +10510,7 @@ exports[`regression tests > make a group and duplicate it > [end of test] appSta
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -10997,6 +11035,7 @@ exports[`regression tests > noop interaction after undo shouldn't create history
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11272,6 +11311,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11396,6 +11436,7 @@ exports[`regression tests > shift click on selected element should deselect it o
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11599,6 +11640,7 @@ exports[`regression tests > shift-click to multiselect, then drag > [end of test
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -11919,6 +11961,7 @@ exports[`regression tests > should group elements and ungroup them > [end of tes
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -12351,6 +12394,7 @@ exports[`regression tests > single-clicking on a subgroup of a selected group sh
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -12981,6 +13025,7 @@ exports[`regression tests > spacebar + drag scrolls the canvas > [end of test] a
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -13107,6 +13152,7 @@ exports[`regression tests > supports nested groups > [end of test] appState 1`]
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -13766,6 +13812,7 @@ exports[`regression tests > switches from group of selected elements to another
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14103,6 +14150,7 @@ exports[`regression tests > switches selected element on pointer down > [end of
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14334,6 +14382,7 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14458,6 +14507,7 @@ exports[`regression tests > undo/redo drawing an element > [end of test] appStat
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14819,6 +14869,7 @@ exports[`regression tests > updates fontSize & fontFamily appState > [end of tes
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
@ -14944,6 +14995,7 @@ exports[`regression tests > zoom hotkeys > [end of test] appState 1`] = `
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
|
|||||||
@ -35,20 +35,23 @@ describe("appState", () => {
|
|||||||
expect(h.state.viewBackgroundColor).toBe("#F00");
|
expect(h.state.viewBackgroundColor).toBe("#F00");
|
||||||
});
|
});
|
||||||
|
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob(
|
{
|
||||||
[
|
kind: "file",
|
||||||
JSON.stringify({
|
file: new Blob(
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
[
|
||||||
appState: {
|
JSON.stringify({
|
||||||
viewBackgroundColor: "#000",
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
},
|
appState: {
|
||||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
viewBackgroundColor: "#000",
|
||||||
}),
|
},
|
||||||
],
|
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||||
{ type: MIME_TYPES.json },
|
}),
|
||||||
),
|
],
|
||||||
);
|
{ type: MIME_TYPES.json },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||||
|
|||||||
@ -57,7 +57,7 @@ describe("export", () => {
|
|||||||
blob: pngBlob,
|
blob: pngBlob,
|
||||||
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
metadata: serializeAsJSON(testElements, h.state, {}, "local"),
|
||||||
});
|
});
|
||||||
await API.drop(pngBlobEmbedded);
|
await API.drop([{ kind: "file", file: pngBlobEmbedded }]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@ -94,7 +94,12 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded png (legacy v1)", async () => {
|
it("import embedded png (legacy v1)", async () => {
|
||||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.png"));
|
await API.drop([
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
file: await API.loadFile("./fixtures/test_embedded_v1.png"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "test" }),
|
expect.objectContaining({ type: "text", text: "test" }),
|
||||||
@ -103,7 +108,12 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded png (v2)", async () => {
|
it("import embedded png (v2)", async () => {
|
||||||
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.png"));
|
await API.drop([
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
file: await API.loadFile("./fixtures/smiley_embedded_v2.png"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
@ -112,7 +122,12 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded svg (legacy v1)", async () => {
|
it("import embedded svg (legacy v1)", async () => {
|
||||||
await API.drop(await API.loadFile("./fixtures/test_embedded_v1.svg"));
|
await API.drop([
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
file: await API.loadFile("./fixtures/test_embedded_v1.svg"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "test" }),
|
expect.objectContaining({ type: "text", text: "test" }),
|
||||||
@ -121,7 +136,12 @@ describe("export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("import embedded svg (v2)", async () => {
|
it("import embedded svg (v2)", async () => {
|
||||||
await API.drop(await API.loadFile("./fixtures/smiley_embedded_v2.svg"));
|
await API.drop([
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
file: await API.loadFile("./fixtures/smiley_embedded_v2.svg"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
expect.objectContaining({ type: "text", text: "😀" }),
|
expect.objectContaining({ type: "text", text: "😀" }),
|
||||||
|
|||||||
@ -478,43 +478,43 @@ export class API {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
static drop = async (_blobs: Blob[] | Blob) => {
|
static drop = async (items: ({kind: "string", value: string, type: string} | {kind: "file", file: File | Blob, type?: string })[]) => {
|
||||||
const blobs = Array.isArray(_blobs) ? _blobs : [_blobs];
|
|
||||||
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
|
||||||
const texts = await Promise.all(
|
|
||||||
blobs.map(
|
|
||||||
(blob) =>
|
|
||||||
new Promise<string>((resolve, reject) => {
|
|
||||||
try {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => {
|
|
||||||
resolve(reader.result as string);
|
|
||||||
};
|
|
||||||
reader.readAsText(blob);
|
|
||||||
} catch (error: any) {
|
|
||||||
reject(error);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const files = blobs as File[] & { item: (index: number) => File };
|
const fileDropEvent = createEvent.drop(GlobalTestState.interactiveCanvas);
|
||||||
|
|
||||||
|
const dataTransferFileItems = items.filter(i => i.kind === "file") as {kind: "file", file: File | Blob, type: string }[];
|
||||||
|
|
||||||
|
const files = dataTransferFileItems.map(item => item.file) as File[] & { item: (index: number) => File };
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/FileList/item
|
||||||
files.item = (index: number) => files[index];
|
files.item = (index: number) => files[index];
|
||||||
|
|
||||||
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
Object.defineProperty(fileDropEvent, "dataTransfer", {
|
||||||
value: {
|
value: {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/files
|
||||||
files,
|
files,
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/items
|
||||||
|
items: items.map((item, idx) => {
|
||||||
|
if (item.kind === "string") {
|
||||||
|
return {
|
||||||
|
kind: "string",
|
||||||
|
type: item.type,
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsString
|
||||||
|
getAsString: (cb: (text: string) => any) => cb(item.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
kind: "file",
|
||||||
|
type: item.type || item.file.type,
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransferItem/getAsFile
|
||||||
|
getAsFile: () => item.file,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/getData
|
||||||
getData: (type: string) => {
|
getData: (type: string) => {
|
||||||
const idx = blobs.findIndex((b) => b.type === type);
|
return items.find((item) => item.type === "string" && item.type === type) || "";
|
||||||
if (idx >= 0) {
|
|
||||||
return texts[idx];
|
|
||||||
}
|
|
||||||
if (type === "text") {
|
|
||||||
return texts.join("\n");
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
},
|
},
|
||||||
types: Array.from(new Set(blobs.map((b) => b.type))),
|
// https://developer.mozilla.org/en-US/docs/Web/API/DataTransfer/types
|
||||||
|
types: Array.from(new Set(items.map((item) => item.kind === "file" ? "Files" : item.type))),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
Object.defineProperty(fileDropEvent, "clientX", {
|
Object.defineProperty(fileDropEvent, "clientX", {
|
||||||
|
|||||||
@ -47,42 +47,43 @@ class DataTransferItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataTransferList {
|
class DataTransferItemList extends Array<DataTransferItem> {
|
||||||
items: DataTransferItem[] = [];
|
|
||||||
|
|
||||||
add(data: string | File, type: string = ""): void {
|
add(data: string | File, type: string = ""): void {
|
||||||
if (typeof data === "string") {
|
if (typeof data === "string") {
|
||||||
this.items.push(new DataTransferItem("string", type, data));
|
this.push(new DataTransferItem("string", type, data));
|
||||||
} else if (data instanceof File) {
|
} else if (data instanceof File) {
|
||||||
this.items.push(new DataTransferItem("file", type, data));
|
this.push(new DataTransferItem("file", type, data));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.items = [];
|
this.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DataTransfer {
|
class DataTransfer {
|
||||||
public items: DataTransferList = new DataTransferList();
|
public items: DataTransferItemList = new DataTransferItemList();
|
||||||
private _types: Record<string, string> = {};
|
|
||||||
|
|
||||||
get files() {
|
get files() {
|
||||||
return this.items.items
|
return this.items
|
||||||
.filter((item) => item.kind === "file")
|
.filter((item) => item.kind === "file")
|
||||||
.map((item) => item.getAsFile()!);
|
.map((item) => item.getAsFile()!);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(data: string | File, type: string = ""): void {
|
add(data: string | File, type: string = ""): void {
|
||||||
this.items.add(data, type);
|
if (typeof data === "string") {
|
||||||
|
this.items.add(data, type);
|
||||||
|
} else {
|
||||||
|
this.items.add(data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setData(type: string, value: string) {
|
setData(type: string, value: string) {
|
||||||
this._types[type] = value;
|
this.items.add(value, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
getData(type: string) {
|
getData(type: string) {
|
||||||
return this._types[type] || "";
|
return this.items.find((item) => item.type === type)?.data || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -568,21 +568,24 @@ describe("history", () => {
|
|||||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob(
|
{
|
||||||
[
|
kind: "file",
|
||||||
JSON.stringify({
|
file: new Blob(
|
||||||
type: EXPORT_DATA_TYPES.excalidraw,
|
[
|
||||||
appState: {
|
JSON.stringify({
|
||||||
...getDefaultAppState(),
|
type: EXPORT_DATA_TYPES.excalidraw,
|
||||||
viewBackgroundColor: "#000",
|
appState: {
|
||||||
},
|
...getDefaultAppState(),
|
||||||
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
viewBackgroundColor: "#000",
|
||||||
}),
|
},
|
||||||
],
|
elements: [API.createElement({ type: "rectangle", id: "B" })],
|
||||||
{ type: MIME_TYPES.json },
|
}),
|
||||||
),
|
],
|
||||||
);
|
{ type: MIME_TYPES.json },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
|
await waitFor(() => expect(API.getUndoStack().length).toBe(1));
|
||||||
expect(h.state.viewBackgroundColor).toBe("#000");
|
expect(h.state.viewBackgroundColor).toBe("#000");
|
||||||
@ -624,11 +627,13 @@ describe("history", () => {
|
|||||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||||
|
|
||||||
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
|
const link = "https://www.youtube.com/watch?v=gkGMXY0wekg";
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob([link], {
|
{
|
||||||
|
kind: "string",
|
||||||
|
value: link,
|
||||||
type: MIME_TYPES.text,
|
type: MIME_TYPES.text,
|
||||||
}),
|
},
|
||||||
);
|
]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(API.getUndoStack().length).toBe(1);
|
expect(API.getUndoStack().length).toBe(1);
|
||||||
@ -726,10 +731,15 @@ describe("history", () => {
|
|||||||
await setupImageTest();
|
await setupImageTest();
|
||||||
|
|
||||||
await API.drop(
|
await API.drop(
|
||||||
await Promise.all([
|
(
|
||||||
API.loadFile("./fixtures/deer.png"),
|
await Promise.all([
|
||||||
API.loadFile("./fixtures/smiley.png"),
|
API.loadFile("./fixtures/deer.png"),
|
||||||
]),
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
|
])
|
||||||
|
).map((file) => ({
|
||||||
|
kind: "file",
|
||||||
|
file,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
await assertImageTest();
|
await assertImageTest();
|
||||||
|
|||||||
@ -77,7 +77,7 @@ describe("image insertion", () => {
|
|||||||
API.loadFile("./fixtures/deer.png"),
|
API.loadFile("./fixtures/deer.png"),
|
||||||
API.loadFile("./fixtures/smiley.png"),
|
API.loadFile("./fixtures/smiley.png"),
|
||||||
]);
|
]);
|
||||||
await API.drop(files);
|
await API.drop(files.map((file) => ({ kind: "file", file })));
|
||||||
|
|
||||||
await assert();
|
await assert();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -56,9 +56,13 @@ describe("library", () => {
|
|||||||
|
|
||||||
it("import library via drag&drop", async () => {
|
it("import library via drag&drop", async () => {
|
||||||
expect(await h.app.library.getLatestLibrary()).toEqual([]);
|
expect(await h.app.library.getLatestLibrary()).toEqual([]);
|
||||||
await API.drop(
|
await API.drop([
|
||||||
await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
|
{
|
||||||
);
|
kind: "file",
|
||||||
|
type: MIME_TYPES.excalidrawlib,
|
||||||
|
file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"),
|
||||||
|
},
|
||||||
|
]);
|
||||||
await waitFor(async () => {
|
await waitFor(async () => {
|
||||||
expect(await h.app.library.getLatestLibrary()).toEqual([
|
expect(await h.app.library.getLatestLibrary()).toEqual([
|
||||||
{
|
{
|
||||||
@ -75,11 +79,13 @@ describe("library", () => {
|
|||||||
it("drop library item onto canvas", async () => {
|
it("drop library item onto canvas", async () => {
|
||||||
expect(h.elements).toEqual([]);
|
expect(h.elements).toEqual([]);
|
||||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob([serializeLibraryAsJSON(libraryItems)], {
|
{
|
||||||
|
kind: "string",
|
||||||
|
value: serializeLibraryAsJSON(libraryItems),
|
||||||
type: MIME_TYPES.excalidrawlib,
|
type: MIME_TYPES.excalidrawlib,
|
||||||
}),
|
},
|
||||||
);
|
]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||||
});
|
});
|
||||||
@ -110,23 +116,20 @@ describe("library", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob(
|
{
|
||||||
[
|
kind: "string",
|
||||||
serializeLibraryAsJSON([
|
value: serializeLibraryAsJSON([
|
||||||
{
|
{
|
||||||
id: "item1",
|
id: "item1",
|
||||||
status: "published",
|
status: "published",
|
||||||
elements: [rectangle, text, arrow],
|
elements: [rectangle, text, arrow],
|
||||||
created: 1,
|
created: 1,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
],
|
type: MIME_TYPES.excalidrawlib,
|
||||||
{
|
},
|
||||||
type: MIME_TYPES.excalidrawlib,
|
]);
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual(
|
expect(h.elements).toEqual(
|
||||||
@ -169,11 +172,13 @@ describe("library", () => {
|
|||||||
created: 1,
|
created: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob([serializeLibraryAsJSON([item1, item1])], {
|
{
|
||||||
|
kind: "string",
|
||||||
|
value: serializeLibraryAsJSON([item1, item1]),
|
||||||
type: MIME_TYPES.excalidrawlib,
|
type: MIME_TYPES.excalidrawlib,
|
||||||
}),
|
},
|
||||||
);
|
]);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([
|
expect(h.elements).toEqual([
|
||||||
@ -192,11 +197,13 @@ describe("library", () => {
|
|||||||
UI.clickTool("rectangle");
|
UI.clickTool("rectangle");
|
||||||
expect(h.elements).toEqual([]);
|
expect(h.elements).toEqual([]);
|
||||||
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
const libraryItems = parseLibraryJSON(await libraryJSONPromise);
|
||||||
await API.drop(
|
await API.drop([
|
||||||
new Blob([serializeLibraryAsJSON(libraryItems)], {
|
{
|
||||||
|
kind: "string",
|
||||||
|
value: serializeLibraryAsJSON(libraryItems),
|
||||||
type: MIME_TYPES.excalidrawlib,
|
type: MIME_TYPES.excalidrawlib,
|
||||||
}),
|
},
|
||||||
);
|
]);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -358,6 +358,10 @@ export interface AppState {
|
|||||||
| "elementBackground"
|
| "elementBackground"
|
||||||
| "elementStroke"
|
| "elementStroke"
|
||||||
| "fontFamily"
|
| "fontFamily"
|
||||||
|
| "compactTextProperties"
|
||||||
|
| "compactStrokeStyles"
|
||||||
|
| "compactOtherProperties"
|
||||||
|
| "compactArrowProperties"
|
||||||
| null;
|
| null;
|
||||||
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
openSidebar: { name: SidebarName; tab?: SidebarTabName } | null;
|
||||||
openDialog:
|
openDialog:
|
||||||
@ -449,6 +453,9 @@ export interface AppState {
|
|||||||
// and also remove groupId from this map
|
// and also remove groupId from this map
|
||||||
lockedMultiSelections: { [groupId: string]: true };
|
lockedMultiSelections: { [groupId: string]: true };
|
||||||
bindMode: BindMode;
|
bindMode: BindMode;
|
||||||
|
|
||||||
|
/** properties sidebar mode - determines whether to show compact or complete sidebar */
|
||||||
|
stylesPanelMode: "compact" | "full";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchMatch = {
|
export type SearchMatch = {
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
getFontString,
|
getFontString,
|
||||||
getFontFamilyString,
|
getFontFamilyString,
|
||||||
isTestEnv,
|
isTestEnv,
|
||||||
|
MIME_TYPES,
|
||||||
} from "@excalidraw/common";
|
} from "@excalidraw/common";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -45,7 +46,7 @@ import type {
|
|||||||
|
|
||||||
import { actionSaveToActiveFile } from "../actions";
|
import { actionSaveToActiveFile } from "../actions";
|
||||||
|
|
||||||
import { parseClipboard } from "../clipboard";
|
import { parseDataTransferEvent } from "../clipboard";
|
||||||
import {
|
import {
|
||||||
actionDecreaseFontSize,
|
actionDecreaseFontSize,
|
||||||
actionIncreaseFontSize,
|
actionIncreaseFontSize,
|
||||||
@ -332,12 +333,14 @@ export const textWysiwyg = ({
|
|||||||
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
editable.onpaste = async (event) => {
|
editable.onpaste = async (event) => {
|
||||||
const clipboardData = await parseClipboard(event, true);
|
const textItem = (await parseDataTransferEvent(event)).findByType(
|
||||||
if (!clipboardData.text) {
|
MIME_TYPES.text,
|
||||||
|
);
|
||||||
|
if (!textItem) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = normalizeText(clipboardData.text);
|
const text = normalizeText(textItem.value);
|
||||||
if (!data) {
|
if (!text) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const container = getContainerElement(
|
const container = getContainerElement(
|
||||||
@ -355,7 +358,7 @@ export const textWysiwyg = ({
|
|||||||
app.scene.getNonDeletedElementsMap(),
|
app.scene.getNonDeletedElementsMap(),
|
||||||
);
|
);
|
||||||
const wrappedText = wrapText(
|
const wrappedText = wrapText(
|
||||||
`${editable.value}${data}`,
|
`${editable.value}${text}`,
|
||||||
font,
|
font,
|
||||||
getBoundTextMaxWidth(container, boundTextElement),
|
getBoundTextMaxWidth(container, boundTextElement),
|
||||||
);
|
);
|
||||||
@ -539,6 +542,7 @@ export const textWysiwyg = ({
|
|||||||
if (isDestroyed) {
|
if (isDestroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
isDestroyed = true;
|
isDestroyed = true;
|
||||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||||
@ -622,14 +626,24 @@ export const textWysiwyg = ({
|
|||||||
const isPropertiesTrigger =
|
const isPropertiesTrigger =
|
||||||
target instanceof HTMLElement &&
|
target instanceof HTMLElement &&
|
||||||
target.classList.contains("properties-trigger");
|
target.classList.contains("properties-trigger");
|
||||||
|
const isPropertiesContent =
|
||||||
|
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||||
|
!!(target as Element).closest(".properties-content");
|
||||||
|
const inShapeActionsMenu =
|
||||||
|
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||||
|
(!!(target as Element).closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) ||
|
||||||
|
!!(target as Element).closest(".compact-shape-actions-island"));
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
editable.onblur = handleSubmit;
|
// If we interacted within shape actions menu or its popovers/triggers,
|
||||||
|
// keep submit disabled and don't steal focus back to textarea.
|
||||||
// case: clicking on the same property → no change → no update → no focus
|
if (inShapeActionsMenu || isPropertiesTrigger || isPropertiesContent) {
|
||||||
if (!isPropertiesTrigger) {
|
return;
|
||||||
editable.focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Otherwise, re-enable submit on blur and refocus the editor.
|
||||||
|
editable.onblur = handleSubmit;
|
||||||
|
editable.focus();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -652,6 +666,7 @@ export const textWysiwyg = ({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
|
app.handleCanvasPanUsingWheelOrSpaceDrag(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
temporarilyDisableSubmit();
|
temporarilyDisableSubmit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -659,15 +674,20 @@ export const textWysiwyg = ({
|
|||||||
const isPropertiesTrigger =
|
const isPropertiesTrigger =
|
||||||
target instanceof HTMLElement &&
|
target instanceof HTMLElement &&
|
||||||
target.classList.contains("properties-trigger");
|
target.classList.contains("properties-trigger");
|
||||||
|
const isPropertiesContent =
|
||||||
|
(target instanceof HTMLElement || target instanceof SVGElement) &&
|
||||||
|
!!(target as Element).closest(".properties-content");
|
||||||
|
|
||||||
if (
|
if (
|
||||||
((event.target instanceof HTMLElement ||
|
((event.target instanceof HTMLElement ||
|
||||||
event.target instanceof SVGElement) &&
|
event.target instanceof SVGElement) &&
|
||||||
event.target.closest(
|
(event.target.closest(
|
||||||
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
`.${CLASSES.SHAPE_ACTIONS_MENU}, .${CLASSES.ZOOM_ACTIONS}`,
|
||||||
) &&
|
) ||
|
||||||
|
event.target.closest(".compact-shape-actions-island")) &&
|
||||||
!isWritableElement(event.target)) ||
|
!isWritableElement(event.target)) ||
|
||||||
isPropertiesTrigger
|
isPropertiesTrigger ||
|
||||||
|
isPropertiesContent
|
||||||
) {
|
) {
|
||||||
temporarilyDisableSubmit();
|
temporarilyDisableSubmit();
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@ -101,6 +101,7 @@ exports[`exportToSvg > with default arguments 1`] = `
|
|||||||
"open": false,
|
"open": false,
|
||||||
"panels": 3,
|
"panels": 3,
|
||||||
},
|
},
|
||||||
|
"stylesPanelMode": "full",
|
||||||
"suggestedBinding": null,
|
"suggestedBinding": null,
|
||||||
"theme": "light",
|
"theme": "light",
|
||||||
"toast": null,
|
"toast": null,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user