mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax-maint-stage
This commit is contained in:
commit
629cd307fd
205 changed files with 10358 additions and 2742 deletions
|
@ -15,8 +15,12 @@ Please add the latest change on the top under the correct section.
|
|||
|
||||
### Features
|
||||
|
||||
- `props.initialData` can now be a function that returns `ExcalidrawInitialDataState` or `Promise<ExcalidrawInitialDataState>`. [#8107](https://github.com/excalidraw/excalidraw/pull/8135)
|
||||
|
||||
- Added support for multiplayer undo/redo, by calculating invertible increments and storing them inside the local-only undo/redo stacks. [#7348](https://github.com/excalidraw/excalidraw/pull/7348)
|
||||
|
||||
- Added font picker component to have the ability to choose from a range of different fonts. Also, changed the default fonts to `Excalifont`, `Nunito` and `Comic Shanns` and deprecated `Virgil`, `Helvetica` and `Cascadia`.
|
||||
|
||||
- `MainMenu.DefaultItems.ToggleTheme` now supports `onSelect(theme: string)` callback, and optionally `allowSystemTheme: boolean` alongside `theme: string` to indicate you want to allow users to set to system theme (you need to handle this yourself). [#7853](https://github.com/excalidraw/excalidraw/pull/7853)
|
||||
|
||||
- Add `useHandleLibrary`'s `opts.adapter` as the new recommended pattern to handle library initialization and persistence on library updates. [#7655](https://github.com/excalidraw/excalidraw/pull/7655)
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
ROUNDNESS,
|
||||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
|
@ -140,6 +140,7 @@ export const actionBindText = register({
|
|||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
|
@ -294,6 +295,7 @@ export const actionWrapTextInContainer = register({
|
|||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
|
|
@ -104,7 +104,7 @@ export const actionClearCanvas = register({
|
|||
exportBackground: appState.exportBackground,
|
||||
exportEmbedScene: appState.exportEmbedScene,
|
||||
gridSize: appState.gridSize,
|
||||
showStats: appState.showStats,
|
||||
stats: appState.stats,
|
||||
pasteDialog: appState.pasteDialog,
|
||||
activeTool:
|
||||
appState.activeTool.type === "image"
|
||||
|
|
|
@ -131,7 +131,12 @@ export const actionFinalize = register({
|
|||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, app);
|
||||
maybeBindLinearElement(
|
||||
multiPointElement,
|
||||
appState,
|
||||
{ x, y },
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -124,7 +124,7 @@ const flipElements = (
|
|||
|
||||
bindOrUnbindLinearElements(
|
||||
selectedElements.filter(isLinearElement),
|
||||
app,
|
||||
elementsMap,
|
||||
isBindingEnabled(appState),
|
||||
[],
|
||||
);
|
||||
|
|
|
@ -65,7 +65,10 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isUndoStackEmpty } = useEmitter<HistoryChangedEvent>(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(),
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -76,6 +79,7 @@ export const createUndoAction: ActionCreator = (history, store) => ({
|
|||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isUndoStackEmpty}
|
||||
data-testid="button-undo"
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
@ -103,7 +107,10 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||
PanelComponent: ({ updateData, data }) => {
|
||||
const { isRedoStackEmpty } = useEmitter(
|
||||
history.onHistoryChangedEmitter,
|
||||
new HistoryChangedEvent(),
|
||||
new HistoryChangedEvent(
|
||||
history.isUndoStackEmpty,
|
||||
history.isRedoStackEmpty,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
|
@ -114,6 +121,7 @@ export const createRedoAction: ActionCreator = (history, store) => ({
|
|||
onClick={updateData}
|
||||
size={data?.size || "medium"}
|
||||
disabled={isRedoStackEmpty}
|
||||
data-testid="button-redo"
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -155,13 +155,15 @@ describe("element locking", () => {
|
|||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
fontFamily: FONT_FAMILY.Cascadia,
|
||||
fontFamily: FONT_FAMILY["Comic Shanns"],
|
||||
});
|
||||
h.elements = [rect, text];
|
||||
API.setSelectedElements([rect, text]);
|
||||
|
||||
expect(queryByTestId(document.body, `strokeWidth-bold`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toBeChecked();
|
||||
expect(queryByTestId(document.body, `font-family-code`)).toHaveClass(
|
||||
"active",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
import type { StoreActionType } from "../store";
|
||||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_PALETTE,
|
||||
DEFAULT_ELEMENT_BACKGROUND_PICKS,
|
||||
|
@ -9,6 +11,7 @@ import { trackEvent } from "../analytics";
|
|||
import { ButtonIconSelect } from "../components/ButtonIconSelect";
|
||||
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
|
||||
import { IconPicker } from "../components/IconPicker";
|
||||
import { FontPicker } from "../components/FontPicker/FontPicker";
|
||||
// TODO barnabasmolnar/editor-redesign
|
||||
// TextAlignTopIcon, TextAlignBottomIcon,TextAlignMiddleIcon,
|
||||
// ArrowHead icons
|
||||
|
@ -38,9 +41,6 @@ import {
|
|||
FontSizeExtraLargeIcon,
|
||||
EdgeSharpIcon,
|
||||
EdgeRoundIcon,
|
||||
FreedrawIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FontFamilyCodeIcon,
|
||||
TextAlignLeftIcon,
|
||||
TextAlignCenterIcon,
|
||||
TextAlignRightIcon,
|
||||
|
@ -65,10 +65,7 @@ import {
|
|||
redrawTextBoundingBox,
|
||||
} from "../element";
|
||||
import { mutateElement, newElementWith } from "../element/mutateElement";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
isBoundToContainer,
|
||||
isLinearElement,
|
||||
|
@ -94,9 +91,10 @@ import {
|
|||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
import { hasStrokeColor } from "../scene/comparisons";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { arrayToMap, getFontFamilyString, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
import { StoreAction } from "../store";
|
||||
import { Fonts, getLineHeight } from "../fonts";
|
||||
|
||||
const FONT_SIZE_RELATIVE_INCREASE_STEP = 0.1;
|
||||
|
||||
|
@ -167,7 +165,7 @@ const offsetElementAfterFontResize = (
|
|||
prevElement: ExcalidrawTextElement,
|
||||
nextElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
if (isBoundToContainer(nextElement)) {
|
||||
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
||||
return nextElement;
|
||||
}
|
||||
return mutateElement(
|
||||
|
@ -729,104 +727,391 @@ export const actionIncreaseFontSize = register({
|
|||
},
|
||||
});
|
||||
|
||||
type ChangeFontFamilyData = Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
"openPopup" | "currentItemFontFamily" | "currentHoveredFontFamily"
|
||||
>
|
||||
> & {
|
||||
/** cache of selected & editing elements populated on opened popup */
|
||||
cachedElements?: Map<string, ExcalidrawElement>;
|
||||
/** flag to reset all elements to their cached versions */
|
||||
resetAll?: true;
|
||||
/** flag to reset all containers to their cached versions */
|
||||
resetContainers?: true;
|
||||
};
|
||||
|
||||
export const actionChangeFontFamily = register({
|
||||
name: "changeFontFamily",
|
||||
label: "labels.fontFamily",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
return {
|
||||
elements: changeProperty(
|
||||
const { cachedElements, resetAll, resetContainers, ...nextAppState } =
|
||||
value as ChangeFontFamilyData;
|
||||
|
||||
if (resetAll) {
|
||||
const nextElements = changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (isTextElement(oldElement)) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: value,
|
||||
lineHeight: getDefaultLineHeight(value),
|
||||
},
|
||||
);
|
||||
redrawTextBoundingBox(
|
||||
newElement,
|
||||
app.scene.getContainerElement(oldElement),
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
(element) => {
|
||||
const cachedElement = cachedElements?.get(element.id);
|
||||
if (cachedElement) {
|
||||
const newElement = newElementWith(element, {
|
||||
...cachedElement,
|
||||
});
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
return element;
|
||||
},
|
||||
true,
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: nextElements,
|
||||
appState: {
|
||||
...appState,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.UPDATE,
|
||||
};
|
||||
}
|
||||
|
||||
const { currentItemFontFamily, currentHoveredFontFamily } = value;
|
||||
|
||||
let nexStoreAction: StoreActionType = StoreAction.NONE;
|
||||
let nextFontFamily: FontFamilyValues | undefined;
|
||||
let skipOnHoverRender = false;
|
||||
|
||||
if (currentItemFontFamily) {
|
||||
nextFontFamily = currentItemFontFamily;
|
||||
nexStoreAction = StoreAction.CAPTURE;
|
||||
} else if (currentHoveredFontFamily) {
|
||||
nextFontFamily = currentHoveredFontFamily;
|
||||
nexStoreAction = StoreAction.NONE;
|
||||
|
||||
const selectedTextElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).filter((element) => isTextElement(element));
|
||||
|
||||
// skip on hover re-render for more than 200 text elements or for text element with more than 5000 chars combined
|
||||
if (selectedTextElements.length > 200) {
|
||||
skipOnHoverRender = true;
|
||||
} else {
|
||||
let i = 0;
|
||||
let textLengthAccumulator = 0;
|
||||
|
||||
while (
|
||||
i < selectedTextElements.length &&
|
||||
textLengthAccumulator < 5000
|
||||
) {
|
||||
const textElement = selectedTextElements[i] as ExcalidrawTextElement;
|
||||
textLengthAccumulator += textElement?.originalText.length || 0;
|
||||
i++;
|
||||
}
|
||||
|
||||
if (textLengthAccumulator > 5000) {
|
||||
skipOnHoverRender = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
appState: {
|
||||
...appState,
|
||||
currentItemFontFamily: value,
|
||||
...nextAppState,
|
||||
},
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
storeAction: nexStoreAction,
|
||||
};
|
||||
|
||||
if (nextFontFamily && !skipOnHoverRender) {
|
||||
const elementContainerMapping = new Map<
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawElement | null
|
||||
>();
|
||||
let uniqueGlyphs = new Set<string>();
|
||||
let skipFontFaceCheck = false;
|
||||
|
||||
const fontsCache = Array.from(Fonts.loadedFontsCache.values());
|
||||
const fontFamily = Object.entries(FONT_FAMILY).find(
|
||||
([_, value]) => value === nextFontFamily,
|
||||
)?.[0];
|
||||
|
||||
// skip `document.font.check` check on hover, if at least one font family has loaded as it's super slow (could result in slightly different bbox, which is fine)
|
||||
if (
|
||||
currentHoveredFontFamily &&
|
||||
fontFamily &&
|
||||
fontsCache.some((sig) => sig.startsWith(fontFamily))
|
||||
) {
|
||||
skipFontFaceCheck = true;
|
||||
}
|
||||
|
||||
// following causes re-render so make sure we changed the family
|
||||
// otherwise it could cause unexpected issues, such as preventing opening the popover when in wysiwyg
|
||||
Object.assign(result, {
|
||||
elements: changeProperty(
|
||||
elements,
|
||||
appState,
|
||||
(oldElement) => {
|
||||
if (
|
||||
isTextElement(oldElement) &&
|
||||
(oldElement.fontFamily !== nextFontFamily ||
|
||||
currentItemFontFamily) // force update on selection
|
||||
) {
|
||||
const newElement: ExcalidrawTextElement = newElementWith(
|
||||
oldElement,
|
||||
{
|
||||
fontFamily: nextFontFamily,
|
||||
lineHeight: getLineHeight(nextFontFamily!),
|
||||
},
|
||||
);
|
||||
|
||||
const cachedContainer =
|
||||
cachedElements?.get(oldElement.containerId || "") || {};
|
||||
|
||||
const container = app.scene.getContainerElement(oldElement);
|
||||
|
||||
if (resetContainers && container && cachedContainer) {
|
||||
// reset the container back to it's cached version
|
||||
mutateElement(container, { ...cachedContainer }, false);
|
||||
}
|
||||
|
||||
if (!skipFontFaceCheck) {
|
||||
uniqueGlyphs = new Set([
|
||||
...uniqueGlyphs,
|
||||
...Array.from(newElement.originalText),
|
||||
]);
|
||||
}
|
||||
|
||||
elementContainerMapping.set(newElement, container);
|
||||
|
||||
return newElement;
|
||||
}
|
||||
|
||||
return oldElement;
|
||||
},
|
||||
true,
|
||||
),
|
||||
});
|
||||
|
||||
// size is irrelevant, but necessary
|
||||
const fontString = `10px ${getFontFamilyString({
|
||||
fontFamily: nextFontFamily,
|
||||
})}`;
|
||||
const glyphs = Array.from(uniqueGlyphs.values()).join();
|
||||
|
||||
if (
|
||||
skipFontFaceCheck ||
|
||||
window.document.fonts.check(fontString, glyphs)
|
||||
) {
|
||||
// we either skip the check (have at least one font face loaded) or do the check and find out all the font faces have loaded
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// trigger synchronous redraw
|
||||
redrawTextBoundingBox(
|
||||
element,
|
||||
container,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// otherwise try to load all font faces for the given glyphs and redraw elements once our font faces loaded
|
||||
window.document.fonts.load(fontString, glyphs).then((fontFaces) => {
|
||||
for (const [element, container] of elementContainerMapping) {
|
||||
// use latest element state to ensure we don't have closure over an old instance in order to avoid possible race conditions (i.e. font faces load out-of-order while rapidly switching fonts)
|
||||
const latestElement = app.scene.getElement(element.id);
|
||||
const latestContainer = container
|
||||
? app.scene.getElement(container.id)
|
||||
: null;
|
||||
|
||||
if (latestElement) {
|
||||
// trigger async redraw
|
||||
redrawTextBoundingBox(
|
||||
latestElement as ExcalidrawTextElement,
|
||||
latestContainer,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// trigger update once we've mutated all the elements, which also updates our cache
|
||||
app.fonts.onLoaded(fontFaces);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const options: {
|
||||
value: FontFamilyValues;
|
||||
text: string;
|
||||
icon: JSX.Element;
|
||||
testId: string;
|
||||
}[] = [
|
||||
{
|
||||
value: FONT_FAMILY.Virgil,
|
||||
text: t("labels.handDrawn"),
|
||||
icon: FreedrawIcon,
|
||||
testId: "font-family-virgil",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Helvetica,
|
||||
text: t("labels.normal"),
|
||||
icon: FontFamilyNormalIcon,
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Cascadia,
|
||||
text: t("labels.code"),
|
||||
icon: FontFamilyCodeIcon,
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
PanelComponent: ({ elements, appState, app, updateData }) => {
|
||||
const cachedElementsRef = useRef<Map<string, ExcalidrawElement>>(new Map());
|
||||
const prevSelectedFontFamilyRef = useRef<number | null>(null);
|
||||
// relying on state batching as multiple `FontPicker` handlers could be called in rapid succession and we want to combine them
|
||||
const [batchedData, setBatchedData] = useState<ChangeFontFamilyData>({});
|
||||
const isUnmounted = useRef(true);
|
||||
|
||||
const selectedFontFamily = useMemo(() => {
|
||||
const getFontFamily = (
|
||||
elementsArray: readonly ExcalidrawElement[],
|
||||
elementsMap: Map<string, ExcalidrawElement>,
|
||||
) =>
|
||||
getFormValue(
|
||||
elementsArray,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(element, elementsMap) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
);
|
||||
|
||||
// popup opened, use cached elements
|
||||
if (
|
||||
batchedData.openPopup === "fontFamily" &&
|
||||
appState.openPopup === "fontFamily"
|
||||
) {
|
||||
return getFontFamily(
|
||||
Array.from(cachedElementsRef.current?.values() ?? []),
|
||||
cachedElementsRef.current,
|
||||
);
|
||||
}
|
||||
|
||||
// popup closed, use all elements
|
||||
if (!batchedData.openPopup && appState.openPopup !== "fontFamily") {
|
||||
return getFontFamily(elements, app.scene.getNonDeletedElementsMap());
|
||||
}
|
||||
|
||||
// popup props are not in sync, hence we are in the middle of an update, so keeping the previous value we've had
|
||||
return prevSelectedFontFamilyRef.current;
|
||||
}, [batchedData.openPopup, appState, elements, app.scene]);
|
||||
|
||||
useEffect(() => {
|
||||
prevSelectedFontFamilyRef.current = selectedFontFamily;
|
||||
}, [selectedFontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(batchedData).length) {
|
||||
updateData(batchedData);
|
||||
// reset the data after we've used the data
|
||||
setBatchedData({});
|
||||
}
|
||||
// call update only on internal state changes
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchedData]);
|
||||
|
||||
useEffect(() => {
|
||||
isUnmounted.current = false;
|
||||
|
||||
return () => {
|
||||
isUnmounted.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>{t("labels.fontFamily")}</legend>
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
group="font-family"
|
||||
options={options}
|
||||
value={getFormValue(
|
||||
elements,
|
||||
appState,
|
||||
(element) => {
|
||||
if (isTextElement(element)) {
|
||||
return element.fontFamily;
|
||||
<FontPicker
|
||||
isOpened={appState.openPopup === "fontFamily"}
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={appState.currentHoveredFontFamily}
|
||||
onSelect={(fontFamily) => {
|
||||
setBatchedData({
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
currentItemFontFamily: fontFamily,
|
||||
});
|
||||
|
||||
// defensive clear so immediate close won't abuse the cached elements
|
||||
cachedElementsRef.current.clear();
|
||||
}}
|
||||
onHover={(fontFamily) => {
|
||||
setBatchedData({
|
||||
currentHoveredFontFamily: fontFamily,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetContainers: true,
|
||||
});
|
||||
}}
|
||||
onLeave={() => {
|
||||
setBatchedData({
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
});
|
||||
}}
|
||||
onPopupChange={(open) => {
|
||||
if (open) {
|
||||
// open, populate the cache from scratch
|
||||
cachedElementsRef.current.clear();
|
||||
|
||||
const { editingElement } = appState;
|
||||
|
||||
if (editingElement?.type === "text") {
|
||||
// retrieve the latest version from the scene, as `editingElement` isn't mutated
|
||||
const latestEditingElement = app.scene.getElement(
|
||||
editingElement.id,
|
||||
);
|
||||
|
||||
// inside the wysiwyg editor
|
||||
cachedElementsRef.current.set(
|
||||
editingElement.id,
|
||||
newElementWith(
|
||||
latestEditingElement || editingElement,
|
||||
{},
|
||||
true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const selectedElements = getSelectedElements(
|
||||
elements,
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
for (const element of selectedElements) {
|
||||
cachedElementsRef.current.set(
|
||||
element.id,
|
||||
newElementWith(element, {}, true),
|
||||
);
|
||||
}
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
if (boundTextElement) {
|
||||
return boundTextElement.fontFamily;
|
||||
|
||||
setBatchedData({
|
||||
openPopup: "fontFamily",
|
||||
});
|
||||
} else {
|
||||
// close, use the cache and clear it afterwards
|
||||
const data = {
|
||||
openPopup: null,
|
||||
currentHoveredFontFamily: null,
|
||||
cachedElements: new Map(cachedElementsRef.current),
|
||||
resetAll: true,
|
||||
} as ChangeFontFamilyData;
|
||||
|
||||
if (isUnmounted.current) {
|
||||
// in case the component was unmounted by the parent, trigger the update directly
|
||||
updateData({ ...batchedData, ...data });
|
||||
} else {
|
||||
setBatchedData(data);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
(element) =>
|
||||
isTextElement(element) ||
|
||||
getBoundTextElement(
|
||||
element,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
) !== null,
|
||||
(hasSelection) =>
|
||||
hasSelection
|
||||
? null
|
||||
: appState.currentItemFontFamily || DEFAULT_FONT_FAMILY,
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
|
||||
cachedElementsRef.current.clear();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
);
|
||||
|
|
|
@ -12,10 +12,7 @@ import {
|
|||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { getBoundTextElement } from "../element/textElement";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
|
@ -27,6 +24,7 @@ import { getSelectedElements } from "../scene";
|
|||
import type { ExcalidrawTextElement } from "../element/types";
|
||||
import { paintIcon } from "../components/icons";
|
||||
import { StoreAction } from "../store";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
// `copiedStyles` is exported only for tests.
|
||||
export let copiedStyles: string = "{}";
|
||||
|
@ -122,7 +120,7 @@ export const actionPasteStyles = register({
|
|||
DEFAULT_TEXT_ALIGN,
|
||||
lineHeight:
|
||||
(elementStylesToCopyFrom as ExcalidrawTextElement).lineHeight ||
|
||||
getDefaultLineHeight(fontFamily),
|
||||
getLineHeight(fontFamily),
|
||||
});
|
||||
let container = null;
|
||||
if (newElement.containerId) {
|
||||
|
|
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
48
packages/excalidraw/actions/actionTextAutoResize.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { isTextElement } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { measureText } from "../element/textElement";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { StoreAction } from "../store";
|
||||
import type { AppClassProperties } from "../types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionTextAutoResize = register({
|
||||
name: "autoResize",
|
||||
label: "labels.autoResize",
|
||||
icon: null,
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState, _: unknown, app: AppClassProperties) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return (
|
||||
selectedElements.length === 1 &&
|
||||
isTextElement(selectedElements[0]) &&
|
||||
!selectedElements[0].autoResize
|
||||
);
|
||||
},
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
|
||||
return {
|
||||
appState,
|
||||
elements: elements.map((element) => {
|
||||
if (element.id === selectedElements[0].id && isTextElement(element)) {
|
||||
const metrics = measureText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
return newElementWith(element, {
|
||||
autoResize: true,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
text: element.originalText,
|
||||
});
|
||||
}
|
||||
return element;
|
||||
}),
|
||||
storeAction: StoreAction.CAPTURE,
|
||||
};
|
||||
},
|
||||
});
|
|
@ -5,21 +5,22 @@ import { StoreAction } from "../store";
|
|||
|
||||
export const actionToggleStats = register({
|
||||
name: "stats",
|
||||
label: "stats.title",
|
||||
label: "stats.fullTitle",
|
||||
icon: abacusIcon,
|
||||
paletteName: "Toggle stats",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "menu" },
|
||||
keywords: ["edit", "attributes", "customize"],
|
||||
perform(elements, appState) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
showStats: !this.checked!(appState),
|
||||
stats: { ...appState.stats, open: !this.checked!(appState) },
|
||||
},
|
||||
storeAction: StoreAction.NONE,
|
||||
};
|
||||
},
|
||||
checked: (appState) => appState.showStats,
|
||||
checked: (appState) => appState.stats.open,
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.SLASH,
|
||||
});
|
||||
|
|
|
@ -148,7 +148,9 @@ export type ActionName =
|
|||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// place here categories that you want to track. We want to track just a
|
||||
// small subset of categories at a given time.
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = ["ai", "command_palette"] as string[];
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = new Set(["command_palette"]);
|
||||
|
||||
export const trackEvent = (
|
||||
category: string,
|
||||
|
@ -9,17 +9,20 @@ export const trackEvent = (
|
|||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// prettier-ignore
|
||||
if (
|
||||
typeof window === "undefined"
|
||||
|| import.meta.env.VITE_WORKER_ID
|
||||
// comment out to debug locally
|
||||
|| import.meta.env.PROD
|
||||
typeof window === "undefined" ||
|
||||
import.meta.env.VITE_WORKER_ID ||
|
||||
import.meta.env.VITE_APP_ENABLE_TRACKING !== "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.has(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// comment out to debug in dev
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import {
|
|||
DEFAULT_FONT_SIZE,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
EXPORT_SCALES,
|
||||
STATS_PANELS,
|
||||
THEME,
|
||||
} from "./constants";
|
||||
import type { AppState, NormalizedZoomValue } from "./types";
|
||||
|
@ -35,6 +36,7 @@ export const getDefaultAppState = (): Omit<
|
|||
currentItemStrokeStyle: DEFAULT_ELEMENT_PROPS.strokeStyle,
|
||||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
currentHoveredFontFamily: null,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
|
@ -80,7 +82,10 @@ export const getDefaultAppState = (): Omit<
|
|||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
stats: {
|
||||
open: false,
|
||||
panels: STATS_PANELS.generalStats | STATS_PANELS.elementProperties,
|
||||
},
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
|
@ -145,6 +150,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
currentItemStrokeStyle: { browser: true, export: false, server: false },
|
||||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
currentHoveredFontFamily: { browser: false, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
|
@ -198,7 +204,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
stats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
|
|
|
@ -1477,19 +1477,28 @@ export class ElementsChange implements Change<SceneElementsMap> {
|
|||
return elements;
|
||||
}
|
||||
|
||||
const previous = Array.from(elements.values());
|
||||
const reordered = orderByFractionalIndex([...previous]);
|
||||
const unordered = Array.from(elements.values());
|
||||
const ordered = orderByFractionalIndex([...unordered]);
|
||||
const moved = Delta.getRightDifferences(unordered, ordered, true).reduce(
|
||||
(acc, arrayIndex) => {
|
||||
const candidate = unordered[Number(arrayIndex)];
|
||||
if (candidate && changed.has(candidate.id)) {
|
||||
acc.set(candidate.id, candidate);
|
||||
}
|
||||
|
||||
if (
|
||||
!flags.containsVisibleDifference &&
|
||||
Delta.isRightDifferent(previous, reordered, true)
|
||||
) {
|
||||
return acc;
|
||||
},
|
||||
new Map(),
|
||||
);
|
||||
|
||||
if (!flags.containsVisibleDifference && moved.size) {
|
||||
// we found a difference in order!
|
||||
flags.containsVisibleDifference = true;
|
||||
}
|
||||
|
||||
// let's synchronize all invalid indices of moved elements
|
||||
return arrayToMap(syncMovedIndices(reordered, changed)) as typeof elements;
|
||||
// synchronize all elements that were actually moved
|
||||
// could fallback to synchronizing all invalid indices
|
||||
return arrayToMap(syncMovedIndices(ordered, moved)) as typeof elements;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -160,10 +160,8 @@ export const SelectedShapeActions = ({
|
|||
{(appState.activeTool.type === "text" ||
|
||||
targetElements.some(isTextElement)) && (
|
||||
<>
|
||||
{renderAction("changeFontSize")}
|
||||
|
||||
{renderAction("changeFontFamily")}
|
||||
|
||||
{renderAction("changeFontSize")}
|
||||
{(appState.activeTool.type === "text" ||
|
||||
suppportsHorizontalAlign(targetElements, elementsMap)) &&
|
||||
renderAction("changeTextAlign")}
|
||||
|
@ -470,6 +468,7 @@ export const ExitZenModeAction = ({
|
|||
showExitZenModeBtn: boolean;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
|
|
File diff suppressed because it is too large
Load diff
12
packages/excalidraw/components/ButtonIcon.scss
Normal file
12
packages/excalidraw/components/ButtonIcon.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
@import "../css/theme";
|
||||
|
||||
.excalidraw {
|
||||
button.standalone {
|
||||
@include outlineButtonIconStyles;
|
||||
|
||||
& > * {
|
||||
// dissalow pointer events on children, so we always have event.target on the button itself
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
36
packages/excalidraw/components/ButtonIcon.tsx
Normal file
36
packages/excalidraw/components/ButtonIcon.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { forwardRef } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./ButtonIcon.scss";
|
||||
|
||||
interface ButtonIconProps {
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
/** if not supplied, defaults to value identity check */
|
||||
active?: boolean;
|
||||
/** include standalone style (could interfere with parent styles) */
|
||||
standalone?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
(props, ref) => {
|
||||
const { title, className, testId, active, standalone, icon, onClick } =
|
||||
props;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={ref}
|
||||
key={title}
|
||||
title={title}
|
||||
data-testid={testId}
|
||||
className={clsx(className, { standalone, active })}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -1,4 +1,5 @@
|
|||
import clsx from "clsx";
|
||||
import { ButtonIcon } from "./ButtonIcon";
|
||||
|
||||
// TODO: It might be "clever" to add option.icon to the existing component <ButtonSelect />
|
||||
export const ButtonIconSelect = <T extends Object>(
|
||||
|
@ -24,20 +25,17 @@ export const ButtonIconSelect = <T extends Object>(
|
|||
}
|
||||
),
|
||||
) => (
|
||||
<div className="buttonList buttonListIcon">
|
||||
<div className="buttonList">
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<button
|
||||
<ButtonIcon
|
||||
key={option.text}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
className={clsx({
|
||||
active: option.active ?? props.value === option.value,
|
||||
})}
|
||||
data-testid={option.testId}
|
||||
icon={option.icon}
|
||||
title={option.text}
|
||||
>
|
||||
{option.icon}
|
||||
</button>
|
||||
testId={option.testId}
|
||||
active={option.active ?? props.value === option.value}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
/>
|
||||
) : (
|
||||
<label
|
||||
key={option.text}
|
||||
|
|
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
10
packages/excalidraw/components/ButtonSeparator.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
export const ButtonSeparator = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "1rem",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
);
|
|
@ -22,7 +22,12 @@ export const CheckboxItem: React.FC<{
|
|||
).focus();
|
||||
}}
|
||||
>
|
||||
<button className="Checkbox-box" role="checkbox" aria-checked={checked}>
|
||||
<button
|
||||
type="button"
|
||||
className="Checkbox-box"
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
>
|
||||
{checkIcon}
|
||||
</button>
|
||||
<div className="Checkbox-label">{children}</div>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: 175px;
|
||||
max-width: 11rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,22 +1,24 @@
|
|||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { isTransparent } from "../../utils";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import type { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import { Picker } from "./Picker";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useAtom } from "jotai";
|
||||
import type { ColorPickerType } from "./colorPickerUtils";
|
||||
import { activeColorPickerSectionAtom } from "./colorPickerUtils";
|
||||
import { useDevice, useExcalidrawContainer } from "../App";
|
||||
import { useExcalidrawContainer } from "../App";
|
||||
import type { ColorTuple, ColorPaletteCustom } from "../../colors";
|
||||
import { COLOR_PALETTE } from "../../colors";
|
||||
import PickerHeading from "./PickerHeading";
|
||||
import { t } from "../../i18n";
|
||||
import clsx from "clsx";
|
||||
import { useRef } from "react";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { ColorInput } from "./ColorInput";
|
||||
import { useRef } from "react";
|
||||
import { activeEyeDropperAtom } from "../EyeDropper";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
|
||||
import "./ColorPicker.scss";
|
||||
|
||||
|
@ -71,6 +73,7 @@ const ColorPickerPopupContent = ({
|
|||
| "palette"
|
||||
| "updateData"
|
||||
>) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const [, setActiveColorPickerSection] = useAtom(activeColorPickerSectionAtom);
|
||||
|
||||
const [eyeDropperState, setEyeDropperState] = useAtom(
|
||||
|
@ -78,9 +81,6 @@ const ColorPickerPopupContent = ({
|
|||
jotaiScope,
|
||||
);
|
||||
|
||||
const { container } = useExcalidrawContainer();
|
||||
const device = useDevice();
|
||||
|
||||
const colorInputJSX = (
|
||||
<div>
|
||||
<PickerHeading>{t("colorPicker.hexCode")}</PickerHeading>
|
||||
|
@ -94,6 +94,7 @@ const ColorPickerPopupContent = ({
|
|||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const focusPickerContent = () => {
|
||||
|
@ -103,120 +104,73 @@ const ColorPickerPopupContent = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
ref={popoverRef}
|
||||
className="focus-visible-none"
|
||||
data-prevent-outside-click
|
||||
onFocusOutside={(event) => {
|
||||
focusPickerContent();
|
||||
<PropertiesPopover
|
||||
container={container}
|
||||
style={{ maxWidth: "208px" }}
|
||||
onFocusOutside={(event) => {
|
||||
// refocus due to eye dropper
|
||||
focusPickerContent();
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (eyeDropperState) {
|
||||
// prevent from closing if we click outside the popover
|
||||
// while eyedropping (e.g. click when clicking the sidebar;
|
||||
// the eye-dropper-backdrop is prevented downstream)
|
||||
event.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(event) => {
|
||||
if (eyeDropperState) {
|
||||
// prevent from closing if we click outside the popover
|
||||
// while eyedropping (e.g. click when clicking the sidebar;
|
||||
// the eye-dropper-backdrop is prevented downstream)
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-layerUI)",
|
||||
backgroundColor: "var(--popup-bg-color)",
|
||||
maxWidth: "208px",
|
||||
maxHeight: window.innerHeight,
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
boxSizing: "border-box",
|
||||
overflowY: "auto",
|
||||
boxShadow:
|
||||
"0px 7px 14px rgba(0, 0, 0, 0.05), 0px 0px 3.12708px rgba(0, 0, 0, 0.0798), 0px 0px 0.931014px rgba(0, 0, 0, 0.1702)",
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
}}
|
||||
onClose={() => {
|
||||
updateData({ openPopup: null });
|
||||
setActiveColorPickerSection(null);
|
||||
}}
|
||||
>
|
||||
{palette ? (
|
||||
<Picker
|
||||
palette={palette}
|
||||
color={color}
|
||||
onChange={(changedColor) => {
|
||||
onChange(changedColor);
|
||||
}}
|
||||
onEyeDropperToggle={(force) => {
|
||||
setEyeDropperState((state) => {
|
||||
if (force) {
|
||||
state = state || {
|
||||
keepOpenOnAlt: true,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
return force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
state.keepOpenOnAlt = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
return force === false || state
|
||||
? null
|
||||
: {
|
||||
keepOpenOnAlt: false,
|
||||
onSelect: onChange,
|
||||
colorPickerType: type,
|
||||
};
|
||||
});
|
||||
}}
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else if (isWritableElement(event.target)) {
|
||||
focusPickerContent();
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
onEscape={(event) => {
|
||||
if (eyeDropperState) {
|
||||
setEyeDropperState(null);
|
||||
} else {
|
||||
updateData({ openPopup: null });
|
||||
}
|
||||
}}
|
||||
label={label}
|
||||
type={type}
|
||||
elements={elements}
|
||||
updateData={updateData}
|
||||
>
|
||||
{colorInputJSX}
|
||||
</Picker>
|
||||
) : (
|
||||
colorInputJSX
|
||||
)}
|
||||
</PropertiesPopover>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -232,7 +186,7 @@ const ColorPickerTrigger = ({
|
|||
return (
|
||||
<Popover.Trigger
|
||||
type="button"
|
||||
className={clsx("color-picker__button active-color", {
|
||||
className={clsx("color-picker__button active-color properties-trigger", {
|
||||
"is-transparent": color === "transparent" || !color,
|
||||
})}
|
||||
aria-label={label}
|
||||
|
@ -268,14 +222,7 @@ export const ColorPicker = ({
|
|||
type={type}
|
||||
topPicks={topPicks}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: "100%",
|
||||
backgroundColor: "var(--default-border-color)",
|
||||
margin: "0 auto",
|
||||
}}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<Popover.Root
|
||||
open={appState.openPopup === type}
|
||||
onOpenChange={(open) => {
|
||||
|
|
|
@ -138,7 +138,7 @@ export const Picker = ({
|
|||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
className="color-picker-content"
|
||||
className="color-picker-content properties-content"
|
||||
// to allow focusing by clicking but not by tabbing
|
||||
tabIndex={-1}
|
||||
>
|
||||
|
|
|
@ -540,7 +540,7 @@ function CommandPaletteInner({
|
|||
...command,
|
||||
icon: command.icon || boltIcon,
|
||||
order: command.order ?? getCategoryOrder(command.category),
|
||||
haystack: `${deburr(command.label)} ${
|
||||
haystack: `${deburr(command.label.toLocaleLowerCase())} ${
|
||||
command.keywords?.join(" ") || ""
|
||||
}`,
|
||||
};
|
||||
|
@ -777,7 +777,9 @@ function CommandPaletteInner({
|
|||
return;
|
||||
}
|
||||
|
||||
const _query = deburr(commandSearch.replace(/[<>-_| ]/g, ""));
|
||||
const _query = deburr(
|
||||
commandSearch.toLocaleLowerCase().replace(/[<>_| -]/g, ""),
|
||||
);
|
||||
matchingCommands = fuzzy
|
||||
.filter(_query, matchingCommands, {
|
||||
extract: (command) => command.haystack,
|
||||
|
|
|
@ -105,6 +105,7 @@ export const ContextMenu = React.memo(
|
|||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("context-menu-item", {
|
||||
dangerous: actionName === "deleteSelectedElements",
|
||||
checkmark: item.checked?.(appState),
|
||||
|
|
|
@ -123,6 +123,7 @@ export const Dialog = (props: DialogProps) => {
|
|||
onClick={onClose}
|
||||
title={t("buttons.close")}
|
||||
aria-label={t("buttons.close")}
|
||||
type="button"
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
|
|
|
@ -27,7 +27,11 @@ const FollowMode = ({
|
|||
{userToFollow.username}
|
||||
</span>
|
||||
</div>
|
||||
<button onClick={onDisconnect} className="follow-mode__disconnect-btn">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDisconnect}
|
||||
className="follow-mode__disconnect-btn"
|
||||
>
|
||||
{CloseIcon}
|
||||
</button>
|
||||
</div>
|
||||
|
|
15
packages/excalidraw/components/FontPicker/FontPicker.scss
Normal file
15
packages/excalidraw/components/FontPicker/FontPicker.scss
Normal file
|
@ -0,0 +1,15 @@
|
|||
@import "../../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.FontPicker__container {
|
||||
display: grid;
|
||||
grid-template-columns: calc(1rem + 3 * var(--default-button-size)) 1rem 1fr; // calc ~ 2 gaps + 4 buttons
|
||||
align-items: center;
|
||||
|
||||
@include isMobile {
|
||||
max-width: calc(
|
||||
2rem + 4 * var(--default-button-size)
|
||||
); // 4 gaps + 4 buttons
|
||||
}
|
||||
}
|
||||
}
|
110
packages/excalidraw/components/FontPicker/FontPicker.tsx
Normal file
110
packages/excalidraw/components/FontPicker/FontPicker.tsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import React, { useCallback, useMemo } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { FontPickerList } from "./FontPickerList";
|
||||
import { FontPickerTrigger } from "./FontPickerTrigger";
|
||||
import { ButtonIconSelect } from "../ButtonIconSelect";
|
||||
import {
|
||||
FontFamilyCodeIcon,
|
||||
FontFamilyNormalIcon,
|
||||
FreedrawIcon,
|
||||
} from "../icons";
|
||||
import { ButtonSeparator } from "../ButtonSeparator";
|
||||
import type { FontFamilyValues } from "../../element/types";
|
||||
import { FONT_FAMILY } from "../../constants";
|
||||
import { t } from "../../i18n";
|
||||
|
||||
import "./FontPicker.scss";
|
||||
|
||||
export const DEFAULT_FONTS = [
|
||||
{
|
||||
value: FONT_FAMILY.Excalifont,
|
||||
icon: FreedrawIcon,
|
||||
text: t("labels.handDrawn"),
|
||||
testId: "font-family-handrawn",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY.Nunito,
|
||||
icon: FontFamilyNormalIcon,
|
||||
text: t("labels.normal"),
|
||||
testId: "font-family-normal",
|
||||
},
|
||||
{
|
||||
value: FONT_FAMILY["Comic Shanns"],
|
||||
icon: FontFamilyCodeIcon,
|
||||
text: t("labels.code"),
|
||||
testId: "font-family-code",
|
||||
},
|
||||
];
|
||||
|
||||
const defaultFontFamilies = new Set(DEFAULT_FONTS.map((x) => x.value));
|
||||
|
||||
export const isDefaultFont = (fontFamily: number | null) => {
|
||||
if (!fontFamily) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return defaultFontFamilies.has(fontFamily);
|
||||
};
|
||||
|
||||
interface FontPickerProps {
|
||||
isOpened: boolean;
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
hoveredFontFamily: FontFamilyValues | null;
|
||||
onSelect: (fontFamily: FontFamilyValues) => void;
|
||||
onHover: (fontFamily: FontFamilyValues) => void;
|
||||
onLeave: () => void;
|
||||
onPopupChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const FontPicker = React.memo(
|
||||
({
|
||||
isOpened,
|
||||
selectedFontFamily,
|
||||
hoveredFontFamily,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onPopupChange,
|
||||
}: FontPickerProps) => {
|
||||
const defaultFonts = useMemo(() => DEFAULT_FONTS, []);
|
||||
const onSelectCallback = useCallback(
|
||||
(value: number | false) => {
|
||||
if (value) {
|
||||
onSelect(value);
|
||||
}
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div role="dialog" aria-modal="true" className="FontPicker__container">
|
||||
<ButtonIconSelect<FontFamilyValues | false>
|
||||
type="button"
|
||||
options={defaultFonts}
|
||||
value={selectedFontFamily}
|
||||
onClick={onSelectCallback}
|
||||
/>
|
||||
<ButtonSeparator />
|
||||
<Popover.Root open={isOpened} onOpenChange={onPopupChange}>
|
||||
<FontPickerTrigger selectedFontFamily={selectedFontFamily} />
|
||||
{isOpened && (
|
||||
<FontPickerList
|
||||
selectedFontFamily={selectedFontFamily}
|
||||
hoveredFontFamily={hoveredFontFamily}
|
||||
onSelect={onSelectCallback}
|
||||
onHover={onHover}
|
||||
onLeave={onLeave}
|
||||
onOpen={() => onPopupChange(true)}
|
||||
onClose={() => onPopupChange(false)}
|
||||
/>
|
||||
)}
|
||||
</Popover.Root>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.isOpened === next.isOpened &&
|
||||
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||
);
|
268
packages/excalidraw/components/FontPicker/FontPickerList.tsx
Normal file
268
packages/excalidraw/components/FontPicker/FontPickerList.tsx
Normal file
|
@ -0,0 +1,268 @@
|
|||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type KeyboardEventHandler,
|
||||
} from "react";
|
||||
import { useApp, useAppProps, useExcalidrawContainer } from "../App";
|
||||
import { PropertiesPopover } from "../PropertiesPopover";
|
||||
import { QuickSearch } from "../QuickSearch";
|
||||
import { ScrollableList } from "../ScrollableList";
|
||||
import DropdownMenuGroup from "../dropdownMenu/DropdownMenuGroup";
|
||||
import DropdownMenuItem, {
|
||||
DropDownMenuItemBadgeType,
|
||||
DropDownMenuItemBadge,
|
||||
} from "../dropdownMenu/DropdownMenuItem";
|
||||
import { type FontFamilyValues } from "../../element/types";
|
||||
import { arrayToList, debounce, getFontFamilyString } from "../../utils";
|
||||
import { t } from "../../i18n";
|
||||
import { fontPickerKeyHandler } from "./keyboardNavHandlers";
|
||||
import { Fonts } from "../../fonts";
|
||||
import type { ValueOf } from "../../utility-types";
|
||||
|
||||
export interface FontDescriptor {
|
||||
value: number;
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
deprecated?: true;
|
||||
badge?: {
|
||||
type: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||
placeholder: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface FontPickerListProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
hoveredFontFamily: FontFamilyValues | null;
|
||||
onSelect: (value: number) => void;
|
||||
onHover: (value: number) => void;
|
||||
onLeave: () => void;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const FontPickerList = React.memo(
|
||||
({
|
||||
selectedFontFamily,
|
||||
hoveredFontFamily,
|
||||
onSelect,
|
||||
onHover,
|
||||
onLeave,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: FontPickerListProps) => {
|
||||
const { container } = useExcalidrawContainer();
|
||||
const { fonts } = useApp();
|
||||
const { showDeprecatedFonts } = useAppProps();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const allFonts = useMemo(
|
||||
() =>
|
||||
Array.from(Fonts.registered.entries())
|
||||
.filter(([_, { metadata }]) => !metadata.serverSide)
|
||||
.map(([familyId, { metadata, fontFaces }]) => {
|
||||
const font = {
|
||||
value: familyId,
|
||||
icon: metadata.icon,
|
||||
text: fontFaces[0].fontFace.family,
|
||||
};
|
||||
|
||||
if (metadata.deprecated) {
|
||||
Object.assign(font, {
|
||||
deprecated: metadata.deprecated,
|
||||
badge: {
|
||||
type: DropDownMenuItemBadgeType.RED,
|
||||
placeholder: t("fontList.badge.old"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return font as FontDescriptor;
|
||||
})
|
||||
.sort((a, b) =>
|
||||
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const sceneFamilies = useMemo(
|
||||
() => new Set(fonts.sceneFamilies),
|
||||
// cache per selected font family, so hover re-render won't mess it up
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedFontFamily],
|
||||
);
|
||||
|
||||
const sceneFonts = useMemo(
|
||||
() => allFonts.filter((font) => sceneFamilies.has(font.value)), // always show all the fonts in the scene, even those that were deprecated
|
||||
[allFonts, sceneFamilies],
|
||||
);
|
||||
|
||||
const availableFonts = useMemo(
|
||||
() =>
|
||||
allFonts.filter(
|
||||
(font) =>
|
||||
!sceneFamilies.has(font.value) &&
|
||||
(showDeprecatedFonts || !font.deprecated), // skip deprecated fonts
|
||||
),
|
||||
[allFonts, sceneFamilies, showDeprecatedFonts],
|
||||
);
|
||||
|
||||
const filteredFonts = useMemo(
|
||||
() =>
|
||||
arrayToList(
|
||||
[...sceneFonts, ...availableFonts].filter((font) =>
|
||||
font.text?.toLowerCase().includes(searchTerm),
|
||||
),
|
||||
),
|
||||
[sceneFonts, availableFonts, searchTerm],
|
||||
);
|
||||
|
||||
const hoveredFont = useMemo(() => {
|
||||
let font;
|
||||
|
||||
if (hoveredFontFamily) {
|
||||
font = filteredFonts.find((font) => font.value === hoveredFontFamily);
|
||||
} else if (selectedFontFamily) {
|
||||
font = filteredFonts.find((font) => font.value === selectedFontFamily);
|
||||
}
|
||||
|
||||
if (!font && searchTerm) {
|
||||
if (filteredFonts[0]?.value) {
|
||||
// hover first element on search
|
||||
onHover(filteredFonts[0].value);
|
||||
} else {
|
||||
// re-render cache on no results
|
||||
onLeave();
|
||||
}
|
||||
}
|
||||
|
||||
return font;
|
||||
}, [
|
||||
hoveredFontFamily,
|
||||
selectedFontFamily,
|
||||
searchTerm,
|
||||
filteredFonts,
|
||||
onHover,
|
||||
onLeave,
|
||||
]);
|
||||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLDivElement>>(
|
||||
(event) => {
|
||||
const handled = fontPickerKeyHandler({
|
||||
event,
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onSelect,
|
||||
onHover,
|
||||
onClose,
|
||||
});
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
[hoveredFont, filteredFonts, onSelect, onHover, onClose],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onOpen();
|
||||
|
||||
return () => {
|
||||
onClose();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sceneFilteredFonts = useMemo(
|
||||
() => filteredFonts.filter((font) => sceneFamilies.has(font.value)),
|
||||
[filteredFonts, sceneFamilies],
|
||||
);
|
||||
|
||||
const availableFilteredFonts = useMemo(
|
||||
() => filteredFonts.filter((font) => !sceneFamilies.has(font.value)),
|
||||
[filteredFonts, sceneFamilies],
|
||||
);
|
||||
|
||||
const renderFont = (font: FontDescriptor, index: number) => (
|
||||
<DropdownMenuItem
|
||||
key={font.value}
|
||||
icon={font.icon}
|
||||
value={font.value}
|
||||
order={index}
|
||||
textStyle={{
|
||||
fontFamily: getFontFamilyString({ fontFamily: font.value }),
|
||||
}}
|
||||
hovered={font.value === hoveredFont?.value}
|
||||
selected={font.value === selectedFontFamily}
|
||||
// allow to tab between search and selected font
|
||||
tabIndex={font.value === selectedFontFamily ? 0 : -1}
|
||||
onClick={(e) => {
|
||||
onSelect(Number(e.currentTarget.value));
|
||||
}}
|
||||
onMouseMove={() => {
|
||||
if (hoveredFont?.value !== font.value) {
|
||||
onHover(font.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{font.text}
|
||||
{font.badge && (
|
||||
<DropDownMenuItemBadge type={font.badge.type}>
|
||||
{font.badge.placeholder}
|
||||
</DropDownMenuItemBadge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
const groups = [];
|
||||
|
||||
if (sceneFilteredFonts.length) {
|
||||
groups.push(
|
||||
<DropdownMenuGroup title={t("fontList.sceneFonts")} key="group_1">
|
||||
{sceneFilteredFonts.map(renderFont)}
|
||||
</DropdownMenuGroup>,
|
||||
);
|
||||
}
|
||||
|
||||
if (availableFilteredFonts.length) {
|
||||
groups.push(
|
||||
<DropdownMenuGroup title={t("fontList.availableFonts")} key="group_2">
|
||||
{availableFilteredFonts.map((font, index) =>
|
||||
renderFont(font, index + sceneFilteredFonts.length),
|
||||
)}
|
||||
</DropdownMenuGroup>,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PropertiesPopover
|
||||
className="properties-content"
|
||||
container={container}
|
||||
style={{ width: "15rem" }}
|
||||
onClose={onClose}
|
||||
onPointerLeave={onLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<QuickSearch
|
||||
ref={inputRef}
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={debounce(setSearchTerm, 20)}
|
||||
/>
|
||||
<ScrollableList
|
||||
className="dropdown-menu fonts manual-hover"
|
||||
placeholder={t("fontList.empty")}
|
||||
>
|
||||
{groups.length ? groups : null}
|
||||
</ScrollableList>
|
||||
</PropertiesPopover>
|
||||
);
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.selectedFontFamily === next.selectedFontFamily &&
|
||||
prev.hoveredFontFamily === next.hoveredFontFamily,
|
||||
);
|
|
@ -0,0 +1,38 @@
|
|||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { useMemo } from "react";
|
||||
import { ButtonIcon } from "../ButtonIcon";
|
||||
import { TextIcon } from "../icons";
|
||||
import type { FontFamilyValues } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { isDefaultFont } from "./FontPicker";
|
||||
|
||||
interface FontPickerTriggerProps {
|
||||
selectedFontFamily: FontFamilyValues | null;
|
||||
}
|
||||
|
||||
export const FontPickerTrigger = ({
|
||||
selectedFontFamily,
|
||||
}: FontPickerTriggerProps) => {
|
||||
const isTriggerActive = useMemo(
|
||||
() => Boolean(selectedFontFamily && !isDefaultFont(selectedFontFamily)),
|
||||
[selectedFontFamily],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Trigger asChild>
|
||||
{/* Empty div as trigger so it's stretched 100% due to different button sizes */}
|
||||
<div>
|
||||
<ButtonIcon
|
||||
standalone
|
||||
icon={TextIcon}
|
||||
title={t("labels.showFonts")}
|
||||
className="properties-trigger"
|
||||
testId={"font-family-show-fonts"}
|
||||
active={isTriggerActive}
|
||||
// no-op
|
||||
onClick={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Trigger>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
import type { Node } from "../../utils";
|
||||
import { KEYS } from "../../keys";
|
||||
import { type FontDescriptor } from "./FontPickerList";
|
||||
|
||||
interface FontPickerKeyNavHandlerProps {
|
||||
event: React.KeyboardEvent<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
hoveredFont: Node<FontDescriptor> | undefined;
|
||||
filteredFonts: Node<FontDescriptor>[];
|
||||
onClose: () => void;
|
||||
onSelect: (value: number) => void;
|
||||
onHover: (value: number) => void;
|
||||
}
|
||||
|
||||
export const fontPickerKeyHandler = ({
|
||||
event,
|
||||
inputRef,
|
||||
hoveredFont,
|
||||
filteredFonts,
|
||||
onClose,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: FontPickerKeyNavHandlerProps) => {
|
||||
if (
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
event.key.toLowerCase() === KEYS.F
|
||||
) {
|
||||
// refocus input on the popup trigger shortcut
|
||||
inputRef.current?.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ESCAPE) {
|
||||
onClose();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ENTER) {
|
||||
if (hoveredFont?.value) {
|
||||
onSelect(hoveredFont.value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_DOWN) {
|
||||
if (hoveredFont?.next) {
|
||||
onHover(hoveredFont.next.value);
|
||||
} else if (filteredFonts[0]?.value) {
|
||||
onHover(filteredFonts[0].value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KEYS.ARROW_UP) {
|
||||
if (hoveredFont?.prev) {
|
||||
onHover(hoveredFont.prev.value);
|
||||
} else if (filteredFonts[filteredFonts.length - 1]?.value) {
|
||||
onHover(filteredFonts[filteredFonts.length - 1].value);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
h3 {
|
||||
margin: 1.5rem 0;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
|
@ -82,7 +82,7 @@
|
|||
&__island {
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
|
|
@ -285,7 +285,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
shortcuts={[getShortcutKey("Alt+Shift+D")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("stats.title")}
|
||||
label={t("stats.fullTitle")}
|
||||
shortcuts={[getShortcutKey("Alt+/")]}
|
||||
/>
|
||||
<Shortcut
|
||||
|
@ -458,6 +458,10 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
label={t("labels.showBackground")}
|
||||
shortcuts={[getShortcutKey("G")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.showFonts")}
|
||||
shortcuts={[getShortcutKey("Shift+F")]}
|
||||
/>
|
||||
<Shortcut
|
||||
label={t("labels.decreaseFontSize")}
|
||||
shortcuts={[getShortcutKey("CtrlOrCmd+Shift+<")]}
|
||||
|
|
|
@ -108,6 +108,7 @@ function Picker<T>({
|
|||
<div className="picker-content" ref={rGallery}>
|
||||
{options.map((option, i) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("picker-option", {
|
||||
active: value === option.value,
|
||||
})}
|
||||
|
@ -171,6 +172,7 @@ export function IconPicker<T>({
|
|||
<div>
|
||||
<button
|
||||
name={group}
|
||||
type="button"
|
||||
className={isActive ? "active" : ""}
|
||||
aria-label={label}
|
||||
onClick={() => setActive(!isActive)}
|
||||
|
|
|
@ -27,6 +27,99 @@
|
|||
& > * {
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
}
|
||||
|
||||
& > .Stats {
|
||||
width: 204px;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
font-size: 12px;
|
||||
z-index: var(--zIndex-layerUI);
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.elementType {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.elementsCount {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.statsItem {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
white-space: nowrap;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
th {
|
||||
border-bottom: 1px solid var(--input-border-color);
|
||||
padding: 4px;
|
||||
}
|
||||
tr {
|
||||
td:nth-child(2) {
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 12px;
|
||||
right: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
|
|
|
@ -39,8 +39,6 @@ import { JSONExportDialog } from "./JSONExportDialog";
|
|||
import { PenModeButton } from "./PenModeButton";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useDevice } from "./App";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions/actionToggleStats";
|
||||
import Footer from "./footer/Footer";
|
||||
import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
@ -65,6 +63,8 @@ import { SubtypeToggles } from "./Subtypes";
|
|||
import { LaserPointerButton } from "./LaserPointerButton";
|
||||
import { MagicSettings } from "./MagicSettings";
|
||||
import { TTDDialog } from "./TTDDialog/TTDDialog";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
|
||||
interface LayerUIProps {
|
||||
actionManager: ActionManager;
|
||||
|
@ -242,6 +242,11 @@ const LayerUI = ({
|
|||
elements,
|
||||
);
|
||||
|
||||
const shouldShowStats =
|
||||
appState.stats.open &&
|
||||
!appState.zenModeEnabled &&
|
||||
!appState.viewModeEnabled;
|
||||
|
||||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
|
@ -355,6 +360,15 @@ const LayerUI = ({
|
|||
appState.openSidebar?.name !== DEFAULT_SIDEBAR.name) && (
|
||||
<tunnels.DefaultSidebarTriggerTunnel.Out />
|
||||
)}
|
||||
{shouldShowStats && (
|
||||
<Stats
|
||||
scene={app.scene}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FixedSideContainer>
|
||||
|
@ -446,7 +460,7 @@ const LayerUI = ({
|
|||
);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(selectedElements[0])?.informMutation();
|
||||
Scene.getScene(selectedElements[0])?.triggerUpdate();
|
||||
} else if (colorPickerType === "elementBackground") {
|
||||
setAppState({
|
||||
currentItemBackgroundColor: color,
|
||||
|
@ -544,19 +558,9 @@ const LayerUI = ({
|
|||
showExitZenModeBtn={showExitZenModeBtn}
|
||||
renderWelcomeScreen={renderWelcomeScreen}
|
||||
/>
|
||||
{appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
{appState.scrolledOutside && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
.library-actions-counter {
|
||||
background-color: var(--color-primary);
|
||||
color: var(--color-primary-light);
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
&__label {
|
||||
color: var(--color-primary);
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@
|
|||
&__header {
|
||||
color: var(--color-primary);
|
||||
font-size: 1.125rem;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.75rem;
|
||||
width: 100%;
|
||||
padding-right: 4rem; // due to dropdown button
|
||||
|
|
|
@ -21,8 +21,6 @@ import { Section } from "./Section";
|
|||
import { SCROLLBAR_WIDTH, SCROLLBAR_MARGIN } from "../scene/scrollbars";
|
||||
import { LockButton } from "./LockButton";
|
||||
import { PenModeButton } from "./PenModeButton";
|
||||
import { Stats } from "./Stats";
|
||||
import { actionToggleStats } from "../actions";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { useTunnels } from "../context/tunnels";
|
||||
|
@ -159,17 +157,6 @@ export const MobileMenu = ({
|
|||
<>
|
||||
{renderSidebars()}
|
||||
{!appState.viewModeEnabled && renderToolbar()}
|
||||
{!appState.openMenu && appState.showStats && (
|
||||
<Stats
|
||||
appState={appState}
|
||||
setAppState={setAppState}
|
||||
elements={elements}
|
||||
onClose={() => {
|
||||
actionManager.executeAction(actionToggleStats);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="App-bottom-bar"
|
||||
style={{
|
||||
|
@ -196,6 +183,7 @@ export const MobileMenu = ({
|
|||
!appState.openMenu &&
|
||||
!appState.openSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
|
|
|
@ -94,6 +94,7 @@ const ChartPreviewBtn = (props: {
|
|||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ChartPreview"
|
||||
onClick={() => {
|
||||
if (chartElements) {
|
||||
|
|
96
packages/excalidraw/components/PropertiesPopover.tsx
Normal file
96
packages/excalidraw/components/PropertiesPopover.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import React, { type ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { useDevice } from "./App";
|
||||
import { Island } from "./Island";
|
||||
import { isInteractive } from "../utils";
|
||||
|
||||
interface PropertiesPopoverProps {
|
||||
className?: string;
|
||||
container: HTMLDivElement | null;
|
||||
children: ReactNode;
|
||||
style?: object;
|
||||
onClose: () => void;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
||||
onPointerLeave?: React.PointerEventHandler<HTMLDivElement>;
|
||||
onFocusOutside?: Popover.DismissableLayerProps["onFocusOutside"];
|
||||
onPointerDownOutside?: Popover.DismissableLayerProps["onPointerDownOutside"];
|
||||
}
|
||||
|
||||
export const PropertiesPopover = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
PropertiesPopoverProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
container,
|
||||
children,
|
||||
style,
|
||||
onClose,
|
||||
onKeyDown,
|
||||
onFocusOutside,
|
||||
onPointerLeave,
|
||||
onPointerDownOutside,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const device = useDevice();
|
||||
|
||||
return (
|
||||
<Popover.Portal container={container}>
|
||||
<Popover.Content
|
||||
ref={ref}
|
||||
className={clsx("focus-visible-none", className)}
|
||||
data-prevent-outside-click
|
||||
side={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "bottom"
|
||||
: "right"
|
||||
}
|
||||
align={
|
||||
device.editor.isMobile && !device.viewport.isLandscape
|
||||
? "center"
|
||||
: "start"
|
||||
}
|
||||
alignOffset={-16}
|
||||
sideOffset={20}
|
||||
style={{
|
||||
zIndex: "var(--zIndex-popup)",
|
||||
}}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Island padding={3} style={style}>
|
||||
{children}
|
||||
</Island>
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
}}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
);
|
||||
},
|
||||
);
|
|
@ -133,7 +133,7 @@
|
|||
.required,
|
||||
.error {
|
||||
color: $oc-red-8;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
margin: 0.2rem;
|
||||
}
|
||||
|
|
48
packages/excalidraw/components/QuickSearch.scss
Normal file
48
packages/excalidraw/components/QuickSearch.scss
Normal file
|
@ -0,0 +1,48 @@
|
|||
.excalidraw {
|
||||
--list-border-color: var(--color-gray-20);
|
||||
|
||||
.QuickSearch__wrapper {
|
||||
position: relative;
|
||||
height: 2.6rem; // added +0.1 due to Safari
|
||||
border-bottom: 1px solid var(--list-border-color);
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 47.5%; // 50% is not exactly in the center of the input
|
||||
transform: translateY(-50%);
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-40);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
--list-border-color: var(--color-gray-80);
|
||||
|
||||
.QuickSearch__wrapper {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.QuickSearch__input {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 0 !important;
|
||||
font-size: 0.875rem;
|
||||
padding-left: 2.5rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
28
packages/excalidraw/components/QuickSearch.tsx
Normal file
28
packages/excalidraw/components/QuickSearch.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { searchIcon } from "./icons";
|
||||
|
||||
import "./QuickSearch.scss";
|
||||
|
||||
interface QuickSearchProps {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
onChange: (term: string) => void;
|
||||
}
|
||||
|
||||
export const QuickSearch = React.forwardRef<HTMLInputElement, QuickSearchProps>(
|
||||
({ className, placeholder, onChange }, ref) => {
|
||||
return (
|
||||
<div className={clsx("QuickSearch__wrapper", className)}>
|
||||
{searchIcon}
|
||||
<input
|
||||
ref={ref}
|
||||
className="QuickSearch__input"
|
||||
type="text"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value.trim().toLowerCase())}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
21
packages/excalidraw/components/ScrollableList.scss
Normal file
21
packages/excalidraw/components/ScrollableList.scss
Normal file
|
@ -0,0 +1,21 @@
|
|||
.excalidraw {
|
||||
.ScrollableList__wrapper {
|
||||
position: static !important;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
overflow-y: auto;
|
||||
|
||||
& > .empty,
|
||||
& > .hint {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-gray-60);
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
line-height: 150%;
|
||||
}
|
||||
}
|
||||
}
|
24
packages/excalidraw/components/ScrollableList.tsx
Normal file
24
packages/excalidraw/components/ScrollableList.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import clsx from "clsx";
|
||||
import { Children } from "react";
|
||||
|
||||
import "./ScrollableList.scss";
|
||||
|
||||
interface ScrollableListProps {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ScrollableList = ({
|
||||
className,
|
||||
placeholder,
|
||||
children,
|
||||
}: ScrollableListProps) => {
|
||||
const isEmpty = !Children.count(children);
|
||||
|
||||
return (
|
||||
<div className={clsx("ScrollableList__wrapper", className)} role="menu">
|
||||
{isEmpty ? <div className="empty">{placeholder}</div> : children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -1,54 +0,0 @@
|
|||
@import "../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Stats {
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 12px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.close {
|
||||
float: right;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
th {
|
||||
border-bottom: 1px solid var(--input-border-color);
|
||||
padding: 4px;
|
||||
}
|
||||
tr {
|
||||
td:nth-child(2) {
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 12px;
|
||||
right: initial;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px 24px;
|
||||
}
|
||||
.close {
|
||||
float: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,108 +0,0 @@
|
|||
import React from "react";
|
||||
import { getCommonBounds } from "../element/bounds";
|
||||
import type { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { getTargetElements } from "../scene";
|
||||
import type { ExcalidrawProps, UIAppState } from "../types";
|
||||
import { CloseIcon } from "./icons";
|
||||
import { Island } from "./Island";
|
||||
import "./Stats.scss";
|
||||
|
||||
export const Stats = (props: {
|
||||
appState: UIAppState;
|
||||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}) => {
|
||||
const boundingBox = getCommonBounds(props.elements);
|
||||
const selectedElements = getTargetElements(props.elements, props.appState);
|
||||
const selectedBoundingBox = getCommonBounds(selectedElements);
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={2}>
|
||||
<div className="close" onClick={props.onClose}>
|
||||
{CloseIcon}
|
||||
</div>
|
||||
<h3>{t("stats.title")}</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.scene")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.elements")}</td>
|
||||
<td>{props.elements.length}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.width")}</td>
|
||||
<td>{Math.round(boundingBox[2]) - Math.round(boundingBox[0])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.height")}</td>
|
||||
<td>{Math.round(boundingBox[3]) - Math.round(boundingBox[1])}</td>
|
||||
</tr>
|
||||
|
||||
{selectedElements.length === 1 && (
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.element")}</th>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{selectedElements.length > 1 && (
|
||||
<>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.selected")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.elements")}</td>
|
||||
<td>{selectedElements.length}</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{selectedElements.length > 0 && (
|
||||
<>
|
||||
<tr>
|
||||
<td>{"x"}</td>
|
||||
<td>{Math.round(selectedBoundingBox[0])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{"y"}</td>
|
||||
<td>{Math.round(selectedBoundingBox[1])}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.width")}</td>
|
||||
<td>
|
||||
{Math.round(
|
||||
selectedBoundingBox[2] - selectedBoundingBox[0],
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.height")}</td>
|
||||
<td>
|
||||
{Math.round(
|
||||
selectedBoundingBox[3] - selectedBoundingBox[1],
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{selectedElements.length === 1 && (
|
||||
<tr>
|
||||
<td>{t("stats.angle")}</td>
|
||||
<td>
|
||||
{`${Math.round(
|
||||
(selectedElements[0].angle * 180) / Math.PI,
|
||||
)}°`}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{props.renderCustomStats?.(props.elements, props.appState)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Island>
|
||||
</div>
|
||||
);
|
||||
};
|
93
packages/excalidraw/components/Stats/Angle.tsx
Normal file
93
packages/excalidraw/components/Stats/Angle.tsx
Normal file
|
@ -0,0 +1,93 @@
|
|||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import { angleIcon } from "../icons";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable, updateBindings } from "./utils";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface AngleProps {
|
||||
element: ExcalidrawElement;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
property: "angle";
|
||||
}
|
||||
|
||||
const STEP_SIZE = 15;
|
||||
|
||||
const handleDegreeChange: DragInputCallbackType<AngleProps["property"]> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, elementsMap);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const originalAngleInDegrees =
|
||||
Math.round(radianToDegree(origElement.angle) * 100) / 100;
|
||||
const changeInDegrees = Math.round(accumulatedChange);
|
||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||
if (shouldChangeByStepSize) {
|
||||
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
||||
}
|
||||
|
||||
nextAngleInDegrees =
|
||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||
|
||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||
|
||||
mutateElement(latestElement, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
updateBindings(latestElement, elementsMap);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const Angle = ({ element, scene, appState, property }: AngleProps) => {
|
||||
return (
|
||||
<DragInput
|
||||
label="A"
|
||||
icon={angleIcon}
|
||||
value={Math.round((radianToDegree(element.angle) % 360) * 100) / 100}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={isPropertyEditable(element, "angle")}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Angle;
|
39
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
39
packages/excalidraw/components/Stats/Collapsible.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { InlineIcon } from "../InlineIcon";
|
||||
import { collapseDownIcon, collapseUpIcon } from "../icons";
|
||||
|
||||
interface CollapsibleProps {
|
||||
label: React.ReactNode;
|
||||
// having it controlled so that the state is managed outside
|
||||
// this is to keep the user's previous choice even when the
|
||||
// Collapsible is unmounted
|
||||
open: boolean;
|
||||
openTrigger: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Collapsible = ({
|
||||
label,
|
||||
open,
|
||||
openTrigger,
|
||||
children,
|
||||
}: CollapsibleProps) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
onClick={openTrigger}
|
||||
>
|
||||
{label}
|
||||
<InlineIcon icon={open ? collapseUpIcon : collapseDownIcon} />
|
||||
</div>
|
||||
{open && <>{children}</>}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collapsible;
|
134
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
134
packages/excalidraw/components/Stats/Dimension.tsx
Normal file
|
@ -0,0 +1,134 @@
|
|||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable, resizeElement } from "./utils";
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
property: "width" | "height";
|
||||
element: ExcalidrawElement;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
||||
return element.type === "image";
|
||||
};
|
||||
|
||||
const handleDimensionChange: DragInputCallbackType<
|
||||
DimensionDragInputProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
property,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const keepAspectRatio =
|
||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(origElement);
|
||||
const aspectRatio = origElement.width / origElement.height;
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextWidth = Math.max(
|
||||
property === "width"
|
||||
? nextValue
|
||||
: keepAspectRatio
|
||||
? nextValue * aspectRatio
|
||||
: origElement.width,
|
||||
MIN_WIDTH_OR_HEIGHT,
|
||||
);
|
||||
const nextHeight = Math.max(
|
||||
property === "height"
|
||||
? nextValue
|
||||
: keepAspectRatio
|
||||
? nextValue / aspectRatio
|
||||
: origElement.height,
|
||||
MIN_WIDTH_OR_HEIGHT,
|
||||
);
|
||||
|
||||
resizeElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
keepAspectRatio,
|
||||
origElement,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
if (keepAspectRatio) {
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
|
||||
resizeElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
keepAspectRatio,
|
||||
origElement,
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const DimensionDragInput = ({
|
||||
property,
|
||||
element,
|
||||
scene,
|
||||
appState,
|
||||
}: DimensionDragInputProps) => {
|
||||
const value =
|
||||
Math.round((property === "width" ? element.width : element.height) * 100) /
|
||||
100;
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={isPropertyEditable(element, property)}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DimensionDragInput;
|
75
packages/excalidraw/components/Stats/DragInput.scss
Normal file
75
packages/excalidraw/components/Stats/DragInput.scss
Normal file
|
@ -0,0 +1,75 @@
|
|||
.excalidraw {
|
||||
.drag-input-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: 0 0 0 1px var(--color-primary-darkest);
|
||||
border-radius: var(--border-radius-lg);
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-input-label {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-right: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--popup-text-color);
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||
border-right: 1px solid var(--default-border-color);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.drag-input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
background-color: transparent;
|
||||
color: var(--text-primary-color);
|
||||
border: 0;
|
||||
outline: none;
|
||||
height: 2rem;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-left: 0;
|
||||
letter-spacing: 0.4px;
|
||||
|
||||
:root[dir="ltr"] & {
|
||||
border-radius: 0 var(--border-radius-lg) var(--border-radius-lg) 0;
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
border-radius: var(--border-radius-lg) 0 0 var(--border-radius-lg);
|
||||
border-left: 1px solid var(--default-border-color);
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
padding: 0.5rem;
|
||||
padding-left: 0.25rem;
|
||||
appearance: none;
|
||||
|
||||
&:focus-visible {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
311
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
311
packages/excalidraw/components/Stats/DragInput.tsx
Normal file
|
@ -0,0 +1,311 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { EVENT } from "../../constants";
|
||||
import { KEYS } from "../../keys";
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { deepCopyElement } from "../../element/newElement";
|
||||
import clsx from "clsx";
|
||||
import { useApp } from "../App";
|
||||
import { InlineIcon } from "../InlineIcon";
|
||||
import type { StatsInputProperty } from "./utils";
|
||||
import { SMALLEST_DELTA } from "./utils";
|
||||
import { StoreAction } from "../../store";
|
||||
import type Scene from "../../scene/Scene";
|
||||
|
||||
import "./DragInput.scss";
|
||||
import type { AppState } from "../../types";
|
||||
import { cloneJSON } from "../../utils";
|
||||
|
||||
export type DragInputCallbackType<
|
||||
P extends StatsInputProperty,
|
||||
E = ExcalidrawElement,
|
||||
> = (props: {
|
||||
accumulatedChange: number;
|
||||
instantChange: number;
|
||||
originalElements: readonly E[];
|
||||
originalElementsMap: ElementsMap;
|
||||
shouldKeepAspectRatio: boolean;
|
||||
shouldChangeByStepSize: boolean;
|
||||
nextValue?: number;
|
||||
property: P;
|
||||
scene: Scene;
|
||||
originalAppState: AppState;
|
||||
}) => void;
|
||||
|
||||
interface StatsDragInputProps<
|
||||
T extends StatsInputProperty,
|
||||
E = ExcalidrawElement,
|
||||
> {
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
value: number | "Mixed";
|
||||
elements: readonly E[];
|
||||
editable?: boolean;
|
||||
shouldKeepAspectRatio?: boolean;
|
||||
dragInputCallback: DragInputCallbackType<T, E>;
|
||||
property: T;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const StatsDragInput = <
|
||||
T extends StatsInputProperty,
|
||||
E extends ExcalidrawElement = ExcalidrawElement,
|
||||
>({
|
||||
label,
|
||||
icon,
|
||||
dragInputCallback,
|
||||
value,
|
||||
elements,
|
||||
editable = true,
|
||||
shouldKeepAspectRatio,
|
||||
property,
|
||||
scene,
|
||||
appState,
|
||||
}: StatsDragInputProps<T, E>) => {
|
||||
const app = useApp();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [inputValue, setInputValue] = useState(value.toString());
|
||||
|
||||
const stateRef = useRef<{
|
||||
originalAppState: AppState;
|
||||
originalElements: readonly E[];
|
||||
lastUpdatedValue: string;
|
||||
updatePending: boolean;
|
||||
}>(null!);
|
||||
if (!stateRef.current) {
|
||||
stateRef.current = {
|
||||
originalAppState: cloneJSON(appState),
|
||||
originalElements: elements,
|
||||
lastUpdatedValue: inputValue,
|
||||
updatePending: false,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const inputValue = value.toString();
|
||||
setInputValue(inputValue);
|
||||
stateRef.current.lastUpdatedValue = inputValue;
|
||||
}, [value]);
|
||||
|
||||
const handleInputValue = (
|
||||
updatedValue: string,
|
||||
elements: readonly E[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
if (!stateRef.current.updatePending) {
|
||||
return false;
|
||||
}
|
||||
stateRef.current.updatePending = false;
|
||||
|
||||
const parsed = Number(updatedValue);
|
||||
if (isNaN(parsed)) {
|
||||
setInputValue(value.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
const rounded = Number(parsed.toFixed(2));
|
||||
const original = Number(value);
|
||||
|
||||
// only update when
|
||||
// 1. original was "Mixed" and we have a new value
|
||||
// 2. original was not "Mixed" and the difference between a new value and previous value is greater
|
||||
// than the smallest delta allowed, which is 0.01
|
||||
// reason: idempotent to avoid unnecessary
|
||||
if (isNaN(original) || Math.abs(rounded - original) >= SMALLEST_DELTA) {
|
||||
stateRef.current.lastUpdatedValue = updatedValue;
|
||||
dragInputCallback({
|
||||
accumulatedChange: 0,
|
||||
instantChange: 0,
|
||||
originalElements: elements,
|
||||
originalElementsMap: app.scene.getNonDeletedElementsMap(),
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: false,
|
||||
nextValue: rounded,
|
||||
property,
|
||||
scene,
|
||||
originalAppState: appState,
|
||||
});
|
||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputValueRef = useRef(handleInputValue);
|
||||
handleInputValueRef.current = handleInputValue;
|
||||
|
||||
// make sure that clicking on canvas (which umounts the component)
|
||||
// updates current input value (blur isn't triggered)
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
return () => {
|
||||
const nextValue = input?.value;
|
||||
if (nextValue) {
|
||||
handleInputValueRef.current(
|
||||
nextValue,
|
||||
stateRef.current.originalElements,
|
||||
stateRef.current.originalAppState,
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
// we need to track change of `editable` state as mount/unmount
|
||||
// because react doesn't trigger `blur` when a an input is blurred due
|
||||
// to being disabled (https://github.com/facebook/react/issues/9142).
|
||||
// As such, if we keep rendering disabled inputs, then change in selection
|
||||
// to an element that has a given property as non-editable would not trigger
|
||||
// blur/unmount and wouldn't update the value.
|
||||
editable,
|
||||
]);
|
||||
|
||||
if (!editable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("drag-input-container", !editable && "disabled")}
|
||||
data-testid={label}
|
||||
>
|
||||
<div
|
||||
className="drag-input-label"
|
||||
ref={labelRef}
|
||||
onPointerDown={(event) => {
|
||||
if (inputRef.current && editable) {
|
||||
let startValue = Number(inputRef.current.value);
|
||||
if (isNaN(startValue)) {
|
||||
startValue = 0;
|
||||
}
|
||||
|
||||
let lastPointer: {
|
||||
x: number;
|
||||
y: number;
|
||||
} | null = null;
|
||||
|
||||
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
||||
app.scene
|
||||
.getNonDeletedElements()
|
||||
.reduce((acc: ElementsMap, element) => {
|
||||
acc.set(element.id, deepCopyElement(element));
|
||||
return acc;
|
||||
}, new Map());
|
||||
|
||||
let originalElements: readonly E[] | null = elements.map(
|
||||
(element) => originalElementsMap!.get(element.id) as E,
|
||||
);
|
||||
|
||||
const originalAppState: AppState = cloneJSON(appState);
|
||||
|
||||
let accumulatedChange: number | null = null;
|
||||
|
||||
document.body.classList.add("excalidraw-cursor-resize");
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!accumulatedChange) {
|
||||
accumulatedChange = 0;
|
||||
}
|
||||
|
||||
if (
|
||||
lastPointer &&
|
||||
originalElementsMap !== null &&
|
||||
originalElements !== null &&
|
||||
accumulatedChange !== null
|
||||
) {
|
||||
const instantChange = event.clientX - lastPointer.x;
|
||||
accumulatedChange += instantChange;
|
||||
|
||||
dragInputCallback({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: event.shiftKey,
|
||||
property,
|
||||
scene,
|
||||
originalAppState,
|
||||
});
|
||||
}
|
||||
|
||||
lastPointer = {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
};
|
||||
|
||||
window.addEventListener(EVENT.POINTER_MOVE, onPointerMove, false);
|
||||
window.addEventListener(
|
||||
EVENT.POINTER_UP,
|
||||
() => {
|
||||
window.removeEventListener(
|
||||
EVENT.POINTER_MOVE,
|
||||
onPointerMove,
|
||||
false,
|
||||
);
|
||||
|
||||
app.syncActionResult({ storeAction: StoreAction.CAPTURE });
|
||||
|
||||
lastPointer = null;
|
||||
accumulatedChange = null;
|
||||
originalElements = null;
|
||||
originalElementsMap = null;
|
||||
|
||||
document.body.classList.remove("excalidraw-cursor-resize");
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (labelRef.current) {
|
||||
labelRef.current.style.cursor = "ew-resize";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{icon ? <InlineIcon icon={icon} /> : label}
|
||||
</div>
|
||||
<input
|
||||
className="drag-input"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
onKeyDown={(event) => {
|
||||
if (editable) {
|
||||
const eventTarget = event.target;
|
||||
if (
|
||||
eventTarget instanceof HTMLInputElement &&
|
||||
event.key === KEYS.ENTER
|
||||
) {
|
||||
handleInputValue(eventTarget.value, elements, appState);
|
||||
app.focusContainer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
stateRef.current.updatePending = true;
|
||||
setInputValue(event.target.value);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
stateRef.current.originalElements = elements;
|
||||
stateRef.current.originalAppState = cloneJSON(appState);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
if (!inputValue) {
|
||||
setInputValue(value.toString());
|
||||
} else if (editable) {
|
||||
handleInputValue(
|
||||
event.target.value,
|
||||
stateRef.current.originalElements,
|
||||
stateRef.current.originalAppState,
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={!editable}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDragInput;
|
99
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
99
packages/excalidraw/components/Stats/FontSize.tsx
Normal file
|
@ -0,0 +1,99 @@
|
|||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../../element/types";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
import { fontSizeIcon } from "../icons";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
|
||||
interface FontSizeProps {
|
||||
element: ExcalidrawElement;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
property: "fontSize";
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const STEP_SIZE = 4;
|
||||
|
||||
const handleFontSizeChange: DragInputCallbackType<
|
||||
FontSizeProps["property"],
|
||||
ExcalidrawTextElement
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
const origElement = originalElements[0];
|
||||
if (origElement) {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement || !isTextElement(latestElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let nextFontSize;
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
} else if (origElement.type === "text") {
|
||||
const originalFontSize = Math.round(origElement.fontSize);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
if (nextFontSize) {
|
||||
mutateElement(latestElement, {
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
redrawTextBoundingBox(
|
||||
latestElement,
|
||||
scene.getContainerElement(latestElement),
|
||||
scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const FontSize = ({ element, scene, appState, property }: FontSizeProps) => {
|
||||
const _element = isTextElement(element)
|
||||
? element
|
||||
: hasBoundTextElement(element)
|
||||
? getBoundTextElement(element, scene.getNonDeletedElementsMap())
|
||||
: null;
|
||||
|
||||
if (!_element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
value={Math.round(_element.fontSize * 10) / 10}
|
||||
elements={[_element]}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
icon={fontSizeIcon}
|
||||
appState={appState}
|
||||
scene={scene}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSize;
|
135
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
135
packages/excalidraw/components/Stats/MultiAngle.tsx
Normal file
|
@ -0,0 +1,135 @@
|
|||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
import { isInGroup } from "../../groups";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import { angleIcon } from "../icons";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface MultiAngleProps {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
property: "angle";
|
||||
}
|
||||
|
||||
const STEP_SIZE = 15;
|
||||
|
||||
const handleDegreeChange: DragInputCallbackType<
|
||||
MultiAngleProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
property,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const editableLatestIndividualElements = originalElements
|
||||
.map((el) => elementsMap.get(el.id))
|
||||
.filter((el) => el && !isInGroup(el) && isPropertyEditable(el, property));
|
||||
const editableOriginalIndividualElements = originalElements.filter(
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, property),
|
||||
);
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
|
||||
for (const element of editableLatestIndividualElements) {
|
||||
if (!element) {
|
||||
continue;
|
||||
}
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
angle: nextAngle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < editableLatestIndividualElements.length; i++) {
|
||||
const latestElement = editableLatestIndividualElements[i];
|
||||
if (!latestElement) {
|
||||
continue;
|
||||
}
|
||||
const originalElement = editableOriginalIndividualElements[i];
|
||||
const originalAngleInDegrees =
|
||||
Math.round(radianToDegree(originalElement.angle) * 100) / 100;
|
||||
const changeInDegrees = Math.round(accumulatedChange);
|
||||
let nextAngleInDegrees = (originalAngleInDegrees + changeInDegrees) % 360;
|
||||
if (shouldChangeByStepSize) {
|
||||
nextAngleInDegrees = getStepSizedValue(nextAngleInDegrees, STEP_SIZE);
|
||||
}
|
||||
|
||||
nextAngleInDegrees =
|
||||
nextAngleInDegrees < 0 ? nextAngleInDegrees + 360 : nextAngleInDegrees;
|
||||
|
||||
const nextAngle = degreeToRadian(nextAngleInDegrees);
|
||||
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
angle: nextAngle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(latestElement)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle }, false);
|
||||
}
|
||||
}
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
const MultiAngle = ({
|
||||
elements,
|
||||
scene,
|
||||
appState,
|
||||
property,
|
||||
}: MultiAngleProps) => {
|
||||
const editableLatestIndividualElements = elements.filter(
|
||||
(el) => !isInGroup(el) && isPropertyEditable(el, "angle"),
|
||||
);
|
||||
const angles = editableLatestIndividualElements.map(
|
||||
(el) => Math.round((radianToDegree(el.angle) % 360) * 100) / 100,
|
||||
);
|
||||
const value = new Set(angles).size === 1 ? angles[0] : "Mixed";
|
||||
|
||||
const editable = editableLatestIndividualElements.some((el) =>
|
||||
isPropertyEditable(el, "angle"),
|
||||
);
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label="A"
|
||||
icon={angleIcon}
|
||||
value={value}
|
||||
elements={elements}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={editable}
|
||||
appState={appState}
|
||||
scene={scene}
|
||||
property={property}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiAngle;
|
382
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
382
packages/excalidraw/components/Stats/MultiDimension.tsx
Normal file
|
@ -0,0 +1,382 @@
|
|||
import { useMemo } from "react";
|
||||
import { getCommonBounds, isTextElement } from "../../element";
|
||||
import { updateBoundElements } from "../../element/binding";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { rescalePointsInElement } from "../../element/resizeElements";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
handleBindTextResize,
|
||||
} from "../../element/textElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState, Point } from "../../types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getElementsInAtomicUnit, resizeElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import { MIN_WIDTH_OR_HEIGHT } from "../../constants";
|
||||
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: NonDeletedSceneElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const getResizedUpdates = (
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
scale: number,
|
||||
origElement: ExcalidrawElement,
|
||||
) => {
|
||||
const offsetX = origElement.x - anchorX;
|
||||
const offsetY = origElement.y - anchorY;
|
||||
const nextWidth = origElement.width * scale;
|
||||
const nextHeight = origElement.height * scale;
|
||||
const x = anchorX + offsetX * scale;
|
||||
const y = anchorY + offsetY * scale;
|
||||
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x,
|
||||
y,
|
||||
...rescalePointsInElement(origElement, nextWidth, nextHeight, false),
|
||||
...(isTextElement(origElement)
|
||||
? { fontSize: origElement.fontSize * scale }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeElementInGroup = (
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
property: MultiDimensionProps["property"],
|
||||
scale: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||
|
||||
mutateElement(latestElement, updates, false);
|
||||
const boundTextElement = getBoundTextElement(
|
||||
origElement,
|
||||
originalElementsMap,
|
||||
);
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
updateBoundElements(latestElement, elementsMap, {
|
||||
newSize: { width: updates.width, height: updates.height },
|
||||
});
|
||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||
if (latestBoundTextElement && isTextElement(latestBoundTextElement)) {
|
||||
mutateElement(
|
||||
latestBoundTextElement,
|
||||
{
|
||||
fontSize: newFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
elementsMap,
|
||||
property === "width" ? "e" : "s",
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resizeGroup = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
initialHeight: number,
|
||||
aspectRatio: number,
|
||||
anchor: Point,
|
||||
property: MultiDimensionProps["property"],
|
||||
latestElements: ExcalidrawElement[],
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
// keep aspect ratio for groups
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
}
|
||||
|
||||
const scale = nextHeight / initialHeight;
|
||||
|
||||
for (let i = 0; i < originalElements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
const latestElement = latestElements[i];
|
||||
|
||||
resizeElementInGroup(
|
||||
anchor[0],
|
||||
anchor[1],
|
||||
property,
|
||||
scale,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDimensionChange: DragInputCallbackType<
|
||||
MultiDimensionProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
originalAppState,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
scene,
|
||||
property,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const atomicUnits = getAtomicUnits(originalElements, originalAppState);
|
||||
if (nextValue !== undefined) {
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
const elementsInUnit = getElementsInAtomicUnit(
|
||||
atomicUnit,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
if (elementsInUnit.length > 1) {
|
||||
const latestElements = elementsInUnit.map((el) => el.latest!);
|
||||
const originalElements = elementsInUnit.map((el) => el.original!);
|
||||
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
||||
const initialWidth = x2 - x1;
|
||||
const initialHeight = y2 - y1;
|
||||
const aspectRatio = initialWidth / initialHeight;
|
||||
const nextWidth = Math.max(
|
||||
MIN_WIDTH_OR_HEIGHT,
|
||||
property === "width" ? Math.max(0, nextValue) : initialWidth,
|
||||
);
|
||||
const nextHeight = Math.max(
|
||||
MIN_WIDTH_OR_HEIGHT,
|
||||
property === "height" ? Math.max(0, nextValue) : initialHeight,
|
||||
);
|
||||
|
||||
resizeGroup(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
[x1, y1],
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
} else {
|
||||
const [el] = elementsInUnit;
|
||||
const latestElement = el?.latest;
|
||||
const origElement = el?.original;
|
||||
|
||||
if (
|
||||
latestElement &&
|
||||
origElement &&
|
||||
isPropertyEditable(latestElement, property)
|
||||
) {
|
||||
let nextWidth =
|
||||
property === "width" ? Math.max(0, nextValue) : latestElement.width;
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight =
|
||||
property === "height"
|
||||
? Math.max(0, nextValue)
|
||||
: latestElement.height;
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
|
||||
resizeElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
false,
|
||||
origElement,
|
||||
elementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
for (const atomicUnit of atomicUnits) {
|
||||
const elementsInUnit = getElementsInAtomicUnit(
|
||||
atomicUnit,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
if (elementsInUnit.length > 1) {
|
||||
const latestElements = elementsInUnit.map((el) => el.latest!);
|
||||
const originalElements = elementsInUnit.map((el) => el.original!);
|
||||
|
||||
const [x1, y1, x2, y2] = getCommonBounds(originalElements);
|
||||
const initialWidth = x2 - x1;
|
||||
const initialHeight = y2 - y1;
|
||||
const aspectRatio = initialWidth / initialHeight;
|
||||
let nextWidth = Math.max(0, initialWidth + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight = Math.max(0, initialHeight + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
|
||||
resizeGroup(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
initialHeight,
|
||||
aspectRatio,
|
||||
[x1, y1],
|
||||
property,
|
||||
latestElements,
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
} else {
|
||||
const [el] = elementsInUnit;
|
||||
const latestElement = el?.latest;
|
||||
const origElement = el?.original;
|
||||
|
||||
if (
|
||||
latestElement &&
|
||||
origElement &&
|
||||
isPropertyEditable(latestElement, property)
|
||||
) {
|
||||
let nextWidth = Math.max(0, origElement.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight = Math.max(0, origElement.height + changeInHeight);
|
||||
if (property === "height") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextHeight = getStepSizedValue(nextHeight, STEP_SIZE);
|
||||
} else {
|
||||
nextHeight = Math.round(nextHeight);
|
||||
}
|
||||
}
|
||||
|
||||
nextWidth = Math.max(MIN_WIDTH_OR_HEIGHT, nextWidth);
|
||||
nextHeight = Math.max(MIN_WIDTH_OR_HEIGHT, nextHeight);
|
||||
|
||||
resizeElement(nextWidth, nextHeight, false, origElement, elementsMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
const MultiDimension = ({
|
||||
property,
|
||||
elements,
|
||||
elementsMap,
|
||||
atomicUnits,
|
||||
scene,
|
||||
appState,
|
||||
}: MultiDimensionProps) => {
|
||||
const sizes = useMemo(
|
||||
() =>
|
||||
atomicUnits.map((atomicUnit) => {
|
||||
const elementsInUnit = getElementsInAtomicUnit(atomicUnit, elementsMap);
|
||||
|
||||
if (elementsInUnit.length > 1) {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(
|
||||
elementsInUnit.map((el) => el.latest),
|
||||
);
|
||||
return (
|
||||
Math.round((property === "width" ? x2 - x1 : y2 - y1) * 100) / 100
|
||||
);
|
||||
}
|
||||
const [el] = elementsInUnit;
|
||||
|
||||
return (
|
||||
Math.round(
|
||||
(property === "width" ? el.latest.width : el.latest.height) * 100,
|
||||
) / 100
|
||||
);
|
||||
}),
|
||||
[elementsMap, atomicUnits, property],
|
||||
);
|
||||
|
||||
const value =
|
||||
new Set(sizes).size === 1 ? Math.round(sizes[0] * 100) / 100 : "Mixed";
|
||||
|
||||
const editable = sizes.length > 0;
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={elements}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
appState={appState}
|
||||
property={property}
|
||||
scene={scene}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiDimension;
|
164
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
164
packages/excalidraw/components/Stats/MultiFontSize.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
import { isTextElement, redrawTextBoundingBox } from "../../element";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { hasBoundTextElement } from "../../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import { isInGroup } from "../../groups";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import { fontSizeIcon } from "../icons";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
|
||||
interface MultiFontSizeProps {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
scene: Scene;
|
||||
elementsMap: NonDeletedSceneElementsMap;
|
||||
appState: AppState;
|
||||
property: "fontSize";
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const STEP_SIZE = 4;
|
||||
|
||||
const getApplicableTextElements = (
|
||||
elements: readonly (ExcalidrawElement | undefined)[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
) =>
|
||||
elements.reduce(
|
||||
(acc: ExcalidrawTextElement[], el) => {
|
||||
if (!el || isInGroup(el)) {
|
||||
return acc;
|
||||
}
|
||||
if (isTextElement(el)) {
|
||||
acc.push(el);
|
||||
return acc;
|
||||
}
|
||||
if (hasBoundTextElement(el)) {
|
||||
const boundTextElement = getBoundTextElement(el, elementsMap);
|
||||
if (boundTextElement) {
|
||||
acc.push(boundTextElement);
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFontSizeChange: DragInputCallbackType<
|
||||
MultiFontSizeProps["property"],
|
||||
ExcalidrawTextElement
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const latestTextElements = originalElements.map((el) =>
|
||||
elementsMap.get(el.id),
|
||||
) as ExcalidrawTextElement[];
|
||||
|
||||
let nextFontSize;
|
||||
|
||||
if (nextValue) {
|
||||
nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
for (const textElement of latestTextElements) {
|
||||
mutateElement(
|
||||
textElement,
|
||||
{
|
||||
fontSize: nextFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(
|
||||
textElement,
|
||||
scene.getContainerElement(textElement),
|
||||
elementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
} else {
|
||||
const originalTextElements = originalElements as ExcalidrawTextElement[];
|
||||
|
||||
for (let i = 0; i < latestTextElements.length; i++) {
|
||||
const latestElement = latestTextElements[i];
|
||||
const originalElement = originalTextElements[i];
|
||||
|
||||
const originalFontSize = Math.round(originalElement.fontSize);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
let nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
fontSize: nextFontSize,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
redrawTextBoundingBox(
|
||||
latestElement,
|
||||
scene.getContainerElement(latestElement),
|
||||
elementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const MultiFontSize = ({
|
||||
elements,
|
||||
scene,
|
||||
appState,
|
||||
property,
|
||||
elementsMap,
|
||||
}: MultiFontSizeProps) => {
|
||||
const latestTextElements = getApplicableTextElements(elements, elementsMap);
|
||||
|
||||
if (!latestTextElements.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fontSizes = latestTextElements.map(
|
||||
(textEl) => Math.round(textEl.fontSize * 10) / 10,
|
||||
);
|
||||
const value = new Set(fontSizes).size === 1 ? fontSizes[0] : "Mixed";
|
||||
const editable = fontSizes.length > 0;
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
icon={fontSizeIcon}
|
||||
elements={latestTextElements}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
value={value}
|
||||
editable={editable}
|
||||
scene={scene}
|
||||
property={property}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiFontSize;
|
259
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
259
packages/excalidraw/components/Stats/MultiPosition.tsx
Normal file
|
@ -0,0 +1,259 @@
|
|||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getAtomicUnits, getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { getCommonBounds, isTextElement } from "../../element";
|
||||
import { useMemo } from "react";
|
||||
import { getElementsInAtomicUnit, moveElement } from "./utils";
|
||||
import type { AtomicUnit } from "./utils";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface MultiPositionProps {
|
||||
property: "x" | "y";
|
||||
elements: readonly ExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
atomicUnits: AtomicUnit[];
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const moveElements = (
|
||||
property: MultiPositionProps["property"],
|
||||
changeInTopX: number,
|
||||
changeInTopY: number,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
originalElements: readonly ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
) => {
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
|
||||
const [cx, cy] = [
|
||||
origElement.x + origElement.width / 2,
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
origElement.x,
|
||||
origElement.y,
|
||||
cx,
|
||||
cy,
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
const newTopLeftX =
|
||||
property === "x" ? Math.round(topLeftX + changeInTopX) : topLeftX;
|
||||
|
||||
const newTopLeftY =
|
||||
property === "y" ? Math.round(topLeftY + changeInTopY) : topLeftY;
|
||||
|
||||
moveElement(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const moveGroupTo = (
|
||||
nextX: number,
|
||||
nextY: number,
|
||||
originalElements: ExcalidrawElement[],
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const [x1, y1, ,] = getCommonBounds(originalElements);
|
||||
const offsetX = nextX - x1;
|
||||
const offsetY = nextY - y1;
|
||||
|
||||
for (let i = 0; i < originalElements.length; i++) {
|
||||
const origElement = originalElements[i];
|
||||
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// bound texts are moved with their containers
|
||||
if (!isTextElement(latestElement) || !latestElement.containerId) {
|
||||
const [cx, cy] = [
|
||||
latestElement.x + latestElement.width / 2,
|
||||
latestElement.y + latestElement.height / 2,
|
||||
];
|
||||
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
latestElement.x,
|
||||
latestElement.y,
|
||||
cx,
|
||||
cy,
|
||||
latestElement.angle,
|
||||
);
|
||||
|
||||
moveElement(
|
||||
topLeftX + offsetX,
|
||||
topLeftY + offsetY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePositionChange: DragInputCallbackType<
|
||||
MultiPositionProps["property"]
|
||||
> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
property,
|
||||
scene,
|
||||
originalAppState,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
for (const atomicUnit of getAtomicUnits(
|
||||
originalElements,
|
||||
originalAppState,
|
||||
)) {
|
||||
const elementsInUnit = getElementsInAtomicUnit(
|
||||
atomicUnit,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
if (elementsInUnit.length > 1) {
|
||||
const [x1, y1, ,] = getCommonBounds(
|
||||
elementsInUnit.map((el) => el.latest!),
|
||||
);
|
||||
const newTopLeftX = property === "x" ? nextValue : x1;
|
||||
const newTopLeftY = property === "y" ? nextValue : y1;
|
||||
|
||||
moveGroupTo(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
elementsInUnit.map((el) => el.original),
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
scene,
|
||||
);
|
||||
} else {
|
||||
const origElement = elementsInUnit[0]?.original;
|
||||
const latestElement = elementsInUnit[0]?.latest;
|
||||
if (
|
||||
origElement &&
|
||||
latestElement &&
|
||||
isPropertyEditable(latestElement, property)
|
||||
) {
|
||||
const [cx, cy] = [
|
||||
origElement.x + origElement.width / 2,
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
origElement.x,
|
||||
origElement.y,
|
||||
cx,
|
||||
cy,
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
||||
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
||||
moveElement(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scene.triggerUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const change = shouldChangeByStepSize
|
||||
? getStepSizedValue(accumulatedChange, STEP_SIZE)
|
||||
: accumulatedChange;
|
||||
|
||||
const changeInTopX = property === "x" ? change : 0;
|
||||
const changeInTopY = property === "y" ? change : 0;
|
||||
|
||||
moveElements(
|
||||
property,
|
||||
changeInTopX,
|
||||
changeInTopY,
|
||||
originalElements,
|
||||
originalElements,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
scene.triggerUpdate();
|
||||
};
|
||||
|
||||
const MultiPosition = ({
|
||||
property,
|
||||
elements,
|
||||
elementsMap,
|
||||
atomicUnits,
|
||||
scene,
|
||||
appState,
|
||||
}: MultiPositionProps) => {
|
||||
const positions = useMemo(
|
||||
() =>
|
||||
atomicUnits.map((atomicUnit) => {
|
||||
const elementsInUnit = Object.keys(atomicUnit)
|
||||
.map((id) => elementsMap.get(id))
|
||||
.filter((el) => el !== undefined) as ExcalidrawElement[];
|
||||
|
||||
// we're dealing with a group
|
||||
if (elementsInUnit.length > 1) {
|
||||
const [x1, y1] = getCommonBounds(elementsInUnit);
|
||||
return Math.round((property === "x" ? x1 : y1) * 100) / 100;
|
||||
}
|
||||
const [el] = elementsInUnit;
|
||||
const [cx, cy] = [el.x + el.width / 2, el.y + el.height / 2];
|
||||
|
||||
const [topLeftX, topLeftY] = rotate(el.x, el.y, cx, cy, el.angle);
|
||||
|
||||
return Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||
}),
|
||||
[atomicUnits, elementsMap, property],
|
||||
);
|
||||
|
||||
const value = new Set(positions).size === 1 ? positions[0] : "Mixed";
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={elements}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
property={property}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiPosition;
|
115
packages/excalidraw/components/Stats/Position.tsx
Normal file
115
packages/excalidraw/components/Stats/Position.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { rotate } from "../../math";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, moveElement } from "./utils";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import type { AppState } from "../../types";
|
||||
|
||||
interface PositionProps {
|
||||
property: "x" | "y";
|
||||
element: ExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
scene: Scene;
|
||||
appState: AppState;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const handlePositionChange: DragInputCallbackType<"x" | "y"> = ({
|
||||
accumulatedChange,
|
||||
originalElements,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
property,
|
||||
scene,
|
||||
}) => {
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const origElement = originalElements[0];
|
||||
const [cx, cy] = [
|
||||
origElement.x + origElement.width / 2,
|
||||
origElement.y + origElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
origElement.x,
|
||||
origElement.y,
|
||||
cx,
|
||||
cy,
|
||||
origElement.angle,
|
||||
);
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const newTopLeftX = property === "x" ? nextValue : topLeftX;
|
||||
const newTopLeftY = property === "y" ? nextValue : topLeftY;
|
||||
moveElement(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const changeInTopX = property === "x" ? accumulatedChange : 0;
|
||||
const changeInTopY = property === "y" ? accumulatedChange : 0;
|
||||
|
||||
const newTopLeftX =
|
||||
property === "x"
|
||||
? Math.round(
|
||||
shouldChangeByStepSize
|
||||
? getStepSizedValue(origElement.x + changeInTopX, STEP_SIZE)
|
||||
: topLeftX + changeInTopX,
|
||||
)
|
||||
: topLeftX;
|
||||
|
||||
const newTopLeftY =
|
||||
property === "y"
|
||||
? Math.round(
|
||||
shouldChangeByStepSize
|
||||
? getStepSizedValue(origElement.y + changeInTopY, STEP_SIZE)
|
||||
: topLeftY + changeInTopY,
|
||||
)
|
||||
: topLeftY;
|
||||
|
||||
moveElement(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
const Position = ({
|
||||
property,
|
||||
element,
|
||||
elementsMap,
|
||||
scene,
|
||||
appState,
|
||||
}: PositionProps) => {
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
element.x,
|
||||
element.y,
|
||||
element.x + element.width / 2,
|
||||
element.y + element.height / 2,
|
||||
element.angle,
|
||||
);
|
||||
const value =
|
||||
Math.round((property === "x" ? topLeftX : topLeftY) * 100) / 100;
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label={property === "x" ? "X" : "Y"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handlePositionChange}
|
||||
value={value}
|
||||
property={property}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Position;
|
302
packages/excalidraw/components/Stats/index.tsx
Normal file
302
packages/excalidraw/components/Stats/index.tsx
Normal file
|
@ -0,0 +1,302 @@
|
|||
import { useEffect, useMemo, useState, memo } from "react";
|
||||
import { getCommonBounds } from "../../element/bounds";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import type { AppState, ExcalidrawProps } from "../../types";
|
||||
import { CloseIcon } from "../icons";
|
||||
import { Island } from "../Island";
|
||||
import { throttle } from "lodash";
|
||||
import Dimension from "./Dimension";
|
||||
import Angle from "./Angle";
|
||||
|
||||
import FontSize from "./FontSize";
|
||||
import MultiDimension from "./MultiDimension";
|
||||
import { elementsAreInSameGroup } from "../../groups";
|
||||
import MultiAngle from "./MultiAngle";
|
||||
import MultiFontSize from "./MultiFontSize";
|
||||
import Position from "./Position";
|
||||
import MultiPosition from "./MultiPosition";
|
||||
import Collapsible from "./Collapsible";
|
||||
import type Scene from "../../scene/Scene";
|
||||
import { useExcalidrawAppState, useExcalidrawSetAppState } from "../App";
|
||||
import { getAtomicUnits } from "./utils";
|
||||
import { STATS_PANELS } from "../../constants";
|
||||
|
||||
interface StatsProps {
|
||||
scene: Scene;
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}
|
||||
|
||||
const STATS_TIMEOUT = 50;
|
||||
|
||||
export const Stats = (props: StatsProps) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const sceneNonce = props.scene.getSceneNonce() || 1;
|
||||
const selectedElements = props.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<StatsInner
|
||||
{...props}
|
||||
appState={appState}
|
||||
sceneNonce={sceneNonce}
|
||||
selectedElements={selectedElements}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const StatsInner = memo(
|
||||
({
|
||||
scene,
|
||||
onClose,
|
||||
renderCustomStats,
|
||||
selectedElements,
|
||||
appState,
|
||||
sceneNonce,
|
||||
}: StatsProps & {
|
||||
sceneNonce: number;
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
appState: AppState;
|
||||
}) => {
|
||||
const elements = scene.getNonDeletedElements();
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const singleElement =
|
||||
selectedElements.length === 1 ? selectedElements[0] : null;
|
||||
|
||||
const multipleElements =
|
||||
selectedElements.length > 1 ? selectedElements : null;
|
||||
|
||||
const [sceneDimension, setSceneDimension] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
}>({
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
|
||||
const throttledSetSceneDimension = useMemo(
|
||||
() =>
|
||||
throttle((elements: readonly NonDeletedExcalidrawElement[]) => {
|
||||
const boundingBox = getCommonBounds(elements);
|
||||
setSceneDimension({
|
||||
width: Math.round(boundingBox[2]) - Math.round(boundingBox[0]),
|
||||
height: Math.round(boundingBox[3]) - Math.round(boundingBox[1]),
|
||||
});
|
||||
}, STATS_TIMEOUT),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
throttledSetSceneDimension(elements);
|
||||
}, [sceneNonce, elements, throttledSetSceneDimension]);
|
||||
|
||||
useEffect(
|
||||
() => () => throttledSetSceneDimension.cancel(),
|
||||
[throttledSetSceneDimension],
|
||||
);
|
||||
|
||||
const atomicUnits = useMemo(() => {
|
||||
return getAtomicUnits(selectedElements, appState);
|
||||
}, [selectedElements, appState]);
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={3}>
|
||||
<div className="title">
|
||||
<h2>{t("stats.title")}</h2>
|
||||
<div className="close" onClick={onClose}>
|
||||
{CloseIcon}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Collapsible
|
||||
label={<h3>{t("stats.generalStats")}</h3>}
|
||||
open={!!(appState.stats.panels & STATS_PANELS.generalStats)}
|
||||
openTrigger={() =>
|
||||
setAppState((state) => {
|
||||
return {
|
||||
...state,
|
||||
stats: {
|
||||
open: true,
|
||||
panels: state.stats.panels ^ STATS_PANELS.generalStats,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th colSpan={2}>{t("stats.scene")}</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.elements")}</td>
|
||||
<td>{elements.length}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.width")}</td>
|
||||
<td>{sceneDimension.width}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{t("stats.height")}</td>
|
||||
<td>{sceneDimension.height}</td>
|
||||
</tr>
|
||||
{renderCustomStats?.(elements, appState)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Collapsible>
|
||||
|
||||
{selectedElements.length > 0 && (
|
||||
<div
|
||||
id="elementStats"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<Collapsible
|
||||
label={<h3>{t("stats.elementProperties")}</h3>}
|
||||
open={
|
||||
!!(appState.stats.panels & STATS_PANELS.elementProperties)
|
||||
}
|
||||
openTrigger={() =>
|
||||
setAppState((state) => {
|
||||
return {
|
||||
...state,
|
||||
stats: {
|
||||
open: true,
|
||||
panels:
|
||||
state.stats.panels ^ STATS_PANELS.elementProperties,
|
||||
},
|
||||
};
|
||||
})
|
||||
}
|
||||
>
|
||||
{singleElement && (
|
||||
<div className="sectionContent">
|
||||
<div className="elementType">
|
||||
{t(`element.${singleElement.type}`)}
|
||||
</div>
|
||||
|
||||
<div className="statsItem">
|
||||
<Position
|
||||
element={singleElement}
|
||||
property="x"
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<Position
|
||||
element={singleElement}
|
||||
property="y"
|
||||
elementsMap={elementsMap}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<Dimension
|
||||
property="width"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<Dimension
|
||||
property="height"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<Angle
|
||||
property="angle"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<FontSize
|
||||
property="fontSize"
|
||||
element={singleElement}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{multipleElements && (
|
||||
<div className="sectionContent">
|
||||
{elementsAreInSameGroup(multipleElements) && (
|
||||
<div className="elementType">{t("element.group")}</div>
|
||||
)}
|
||||
|
||||
<div className="elementsCount">
|
||||
<div>{t("stats.elements")}</div>
|
||||
<div>{selectedElements.length}</div>
|
||||
</div>
|
||||
|
||||
<div className="statsItem">
|
||||
<MultiPosition
|
||||
property="x"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiPosition
|
||||
property="y"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="width"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="height"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
atomicUnits={atomicUnits}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiAngle
|
||||
property="angle"
|
||||
elements={multipleElements}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
/>
|
||||
<MultiFontSize
|
||||
property="fontSize"
|
||||
elements={multipleElements}
|
||||
scene={scene}
|
||||
appState={appState}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Collapsible>
|
||||
</div>
|
||||
)}
|
||||
</Island>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) => {
|
||||
return (
|
||||
prev.sceneNonce === next.sceneNonce &&
|
||||
prev.selectedElements === next.selectedElements &&
|
||||
prev.appState.stats.panels === next.appState.stats.panels
|
||||
);
|
||||
},
|
||||
);
|
756
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
756
packages/excalidraw/components/Stats/stats.test.tsx
Normal file
|
@ -0,0 +1,756 @@
|
|||
import { fireEvent, queryByTestId } from "@testing-library/react";
|
||||
import { Keyboard, Pointer, UI } from "../../tests/helpers/ui";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
import {
|
||||
GlobalTestState,
|
||||
mockBoundingClientRect,
|
||||
render,
|
||||
restoreOriginalGetBoundingClientRect,
|
||||
} from "../../tests/test-utils";
|
||||
import * as StaticScene from "../../renderer/staticScene";
|
||||
import { vi } from "vitest";
|
||||
import { reseed } from "../../random";
|
||||
import { setDateTimeForTests } from "../../utils";
|
||||
import { Excalidraw, mutateElement } from "../..";
|
||||
import { t } from "../../i18n";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../../element/types";
|
||||
import { degreeToRadian, rotate } from "../../math";
|
||||
import { getTextEditor, updateTextEditor } from "../../tests/queries/dom";
|
||||
import { getCommonBounds, isTextElement } from "../../element";
|
||||
import { API } from "../../tests/helpers/api";
|
||||
import { actionGroup } from "../../actions";
|
||||
import { isInGroup } from "../../groups";
|
||||
import React from "react";
|
||||
|
||||
const { h } = window;
|
||||
const mouse = new Pointer("mouse");
|
||||
const renderStaticScene = vi.spyOn(StaticScene, "renderStaticScene");
|
||||
let stats: HTMLElement | null = null;
|
||||
let elementStats: HTMLElement | null | undefined = null;
|
||||
|
||||
const editInput = (input: HTMLInputElement, value: string) => {
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value } });
|
||||
input.blur();
|
||||
};
|
||||
|
||||
const getStatsProperty = (label: string) => {
|
||||
const elementStats = UI.queryStats()?.querySelector("#elementStats");
|
||||
|
||||
if (elementStats) {
|
||||
const properties = elementStats?.querySelector(".statsItem");
|
||||
return (
|
||||
properties?.querySelector?.(
|
||||
`.drag-input-container[data-testid="${label}"]`,
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const testInputProperty = (
|
||||
element: ExcalidrawElement,
|
||||
property: "x" | "y" | "width" | "height" | "angle" | "fontSize",
|
||||
label: string,
|
||||
initialValue: number,
|
||||
nextValue: number,
|
||||
) => {
|
||||
const input = getStatsProperty(label)?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
expect(input.value).toBe(initialValue.toString());
|
||||
editInput(input, String(nextValue));
|
||||
if (property === "angle") {
|
||||
expect(element[property]).toBe(degreeToRadian(Number(nextValue)));
|
||||
} else if (property === "fontSize" && isTextElement(element)) {
|
||||
expect(element[property]).toBe(Number(nextValue));
|
||||
} else if (property !== "fontSize") {
|
||||
expect(element[property]).toBe(Number(nextValue));
|
||||
}
|
||||
};
|
||||
|
||||
describe("step sized value", () => {
|
||||
it("should return edge values correctly", () => {
|
||||
const steps = [10, 15, 20, 25, 30];
|
||||
const values = [10, 15, 20, 25, 30];
|
||||
|
||||
steps.forEach((step, idx) => {
|
||||
expect(getStepSizedValue(values[idx], step)).toEqual(values[idx]);
|
||||
});
|
||||
});
|
||||
|
||||
it("step sized value lies in the middle", () => {
|
||||
let stepSize = 15;
|
||||
let values = [7.5, 9, 12, 14.99, 15, 22.49];
|
||||
|
||||
values.forEach((value) => {
|
||||
expect(getStepSizedValue(value, stepSize)).toEqual(15);
|
||||
});
|
||||
|
||||
stepSize = 10;
|
||||
values = [-5, 4.99, 0, 1.23];
|
||||
values.forEach((value) => {
|
||||
expect(getStepSizedValue(value, stepSize)).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("binding with linear elements", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(19);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
h.elements = [];
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
stats = UI.queryStats();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(200, 100);
|
||||
|
||||
UI.clickTool("arrow");
|
||||
mouse.down(5, 0);
|
||||
mouse.up(300, 50);
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should remain bound to linear element on small position change", async () => {
|
||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||
const inputX = getStatsProperty("X")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
expect(inputX).not.toBeNull();
|
||||
editInput(inputX, String("204"));
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should remain bound to linear element on small angle change", async () => {
|
||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
editInput(inputAngle, String("1"));
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
});
|
||||
|
||||
it("should unbind linear element on large position change", async () => {
|
||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||
const inputX = getStatsProperty("X")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
expect(inputX).not.toBeNull();
|
||||
editInput(inputX, String("254"));
|
||||
expect(linear.startBinding).toBe(null);
|
||||
});
|
||||
|
||||
it("should remain bound to linear element on small angle change", async () => {
|
||||
const linear = h.elements[1] as ExcalidrawLinearElement;
|
||||
const inputAngle = getStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(linear.startBinding).not.toBe(null);
|
||||
editInput(inputAngle, String("45"));
|
||||
expect(linear.startBinding).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
// single element
|
||||
describe("stats for a generic element", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
h.elements = [];
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
stats = UI.queryStats();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(200, 100);
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should open stats", () => {
|
||||
expect(stats).toBeDefined();
|
||||
expect(elementStats).toBeDefined();
|
||||
|
||||
// title
|
||||
const title = elementStats?.querySelector("h3");
|
||||
expect(title?.lastChild?.nodeValue)?.toBe(t("stats.elementProperties"));
|
||||
|
||||
// element type
|
||||
const elementType = elementStats?.querySelector(".elementType");
|
||||
expect(elementType).toBeDefined();
|
||||
expect(elementType?.lastChild?.nodeValue).toBe(t("element.rectangle"));
|
||||
|
||||
// properties
|
||||
const properties = elementStats?.querySelector(".statsItem");
|
||||
expect(properties?.childNodes).toBeDefined();
|
||||
["X", "Y", "W", "H", "A"].forEach((label) => () => {
|
||||
expect(
|
||||
properties?.querySelector?.(
|
||||
`.drag-input-container[data-testid="${label}"]`,
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should be able to edit all properties for a general element", () => {
|
||||
const rectangle = h.elements[0];
|
||||
const initialX = rectangle.x;
|
||||
const initialY = rectangle.y;
|
||||
|
||||
testInputProperty(rectangle, "width", "W", 200, 100);
|
||||
testInputProperty(rectangle, "height", "H", 100, 200);
|
||||
testInputProperty(rectangle, "x", "X", initialX, 230);
|
||||
testInputProperty(rectangle, "y", "Y", initialY, 220);
|
||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||
});
|
||||
|
||||
it("should keep only two decimal places", () => {
|
||||
const rectangle = h.elements[0];
|
||||
const rectangleId = rectangle.id;
|
||||
|
||||
const input = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
expect(input.value).toBe(rectangle.width.toString());
|
||||
editInput(input, "123.123");
|
||||
expect(h.elements.length).toBe(1);
|
||||
expect(rectangle.id).toBe(rectangleId);
|
||||
expect(input.value).toBe("123.12");
|
||||
expect(rectangle.width).toBe(123.12);
|
||||
|
||||
editInput(input, "88.98766");
|
||||
expect(input.value).toBe("88.99");
|
||||
expect(rectangle.width).toBe(88.99);
|
||||
});
|
||||
|
||||
it("should update input x and y when angle is changed", () => {
|
||||
const rectangle = h.elements[0];
|
||||
const [cx, cy] = [
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
const xInput = getStatsProperty("X")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
const yInput = getStatsProperty("Y")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(xInput.value).toBe(topLeftX.toString());
|
||||
expect(yInput.value).toBe(topLeftY.toString());
|
||||
|
||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||
|
||||
let [newTopLeftX, newTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
|
||||
|
||||
testInputProperty(rectangle, "angle", "A", 45, 66);
|
||||
|
||||
[newTopLeftX, newTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(newTopLeftX.toString()).not.toEqual(xInput.value);
|
||||
expect(newTopLeftY.toString()).not.toEqual(yInput.value);
|
||||
});
|
||||
|
||||
it("should fix top left corner when width or height is changed", () => {
|
||||
const rectangle = h.elements[0];
|
||||
|
||||
testInputProperty(rectangle, "angle", "A", 0, 45);
|
||||
let [cx, cy] = [
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
rectangle.angle,
|
||||
);
|
||||
testInputProperty(rectangle, "width", "W", rectangle.width, 400);
|
||||
[cx, cy] = [
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
let [currentTopLeftX, currentTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
rectangle.angle,
|
||||
);
|
||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
|
||||
|
||||
testInputProperty(rectangle, "height", "H", rectangle.height, 400);
|
||||
[cx, cy] = [
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
];
|
||||
[currentTopLeftX, currentTopLeftY] = rotate(
|
||||
rectangle.x,
|
||||
rectangle.y,
|
||||
cx,
|
||||
cy,
|
||||
rectangle.angle,
|
||||
);
|
||||
|
||||
expect(currentTopLeftX).toBeCloseTo(topLeftX, 4);
|
||||
expect(currentTopLeftY).toBeCloseTo(topLeftY, 4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stats for a non-generic element", () => {
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
h.elements = [];
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
stats = UI.queryStats();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("text element", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
editor.blur();
|
||||
|
||||
const text = h.elements[0] as ExcalidrawTextElement;
|
||||
mouse.clickOn(text);
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
// can change font size
|
||||
const input = getStatsProperty("F")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeDefined();
|
||||
expect(input.value).toBe(text.fontSize.toString());
|
||||
editInput(input, "36");
|
||||
expect(text.fontSize).toBe(36);
|
||||
|
||||
// cannot change width or height
|
||||
const width = getStatsProperty("W")?.querySelector(".drag-input");
|
||||
expect(width).toBeUndefined();
|
||||
const height = getStatsProperty("H")?.querySelector(".drag-input");
|
||||
expect(height).toBeUndefined();
|
||||
|
||||
// min font size is 4
|
||||
editInput(input, "0");
|
||||
expect(text.fontSize).not.toBe(0);
|
||||
expect(text.fontSize).toBe(4);
|
||||
});
|
||||
|
||||
it("frame element", () => {
|
||||
const frame = API.createElement({
|
||||
id: "id0",
|
||||
type: "frame",
|
||||
x: 150,
|
||||
width: 150,
|
||||
});
|
||||
h.elements = [frame];
|
||||
h.setState({
|
||||
selectedElementIds: {
|
||||
[frame.id]: true,
|
||||
},
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
expect(elementStats).toBeDefined();
|
||||
|
||||
// cannot change angle
|
||||
const angle = getStatsProperty("A")?.querySelector(".drag-input");
|
||||
expect(angle).toBeUndefined();
|
||||
|
||||
// can change width or height
|
||||
testInputProperty(frame, "width", "W", frame.width, 250);
|
||||
testInputProperty(frame, "height", "H", frame.height, 500);
|
||||
});
|
||||
|
||||
it("image element", () => {
|
||||
const image = API.createElement({ type: "image", width: 200, height: 100 });
|
||||
h.elements = [image];
|
||||
mouse.clickOn(image);
|
||||
h.setState({
|
||||
selectedElementIds: {
|
||||
[image.id]: true,
|
||||
},
|
||||
});
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
expect(elementStats).toBeDefined();
|
||||
const widthToHeight = image.width / image.height;
|
||||
|
||||
// when width or height is changed, the aspect ratio is preserved
|
||||
testInputProperty(image, "width", "W", image.width, 400);
|
||||
expect(image.width).toBe(400);
|
||||
expect(image.width / image.height).toBe(widthToHeight);
|
||||
|
||||
testInputProperty(image, "height", "H", image.height, 80);
|
||||
expect(image.height).toBe(80);
|
||||
expect(image.width / image.height).toBe(widthToHeight);
|
||||
});
|
||||
|
||||
it("should display fontSize for bound text", () => {
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
width: 200,
|
||||
height: 100,
|
||||
containerId: container.id,
|
||||
fontSize: 20,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: [{ type: "text", id: text.id }],
|
||||
});
|
||||
h.elements = [container, text];
|
||||
|
||||
API.setSelectedElements([container]);
|
||||
const fontSize = getStatsProperty("F")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).toBeDefined();
|
||||
|
||||
editInput(fontSize, "40");
|
||||
|
||||
expect(text.fontSize).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
// multiple elements
|
||||
describe("stats for multiple elements", () => {
|
||||
beforeEach(async () => {
|
||||
mouse.reset();
|
||||
localStorage.clear();
|
||||
renderStaticScene.mockClear();
|
||||
reseed(7);
|
||||
setDateTimeForTests("201933152653");
|
||||
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
|
||||
h.elements = [];
|
||||
|
||||
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
|
||||
button: 2,
|
||||
clientX: 1,
|
||||
clientY: 1,
|
||||
});
|
||||
const contextMenu = UI.queryContextMenu();
|
||||
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
|
||||
stats = UI.queryStats();
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should display MIXED for elements with different values", () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(200, 100);
|
||||
|
||||
UI.clickTool("ellipse");
|
||||
mouse.down(50, 50);
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("diamond");
|
||||
mouse.down(-100, -100);
|
||||
mouse.up(125, 145);
|
||||
|
||||
h.setState({
|
||||
selectedElementIds: h.elements.reduce((acc, el) => {
|
||||
acc[el.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, true>),
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
const width = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width?.value).toBe("Mixed");
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height?.value).toBe("Mixed");
|
||||
const angle = getStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(angle.value).toBe("0");
|
||||
|
||||
editInput(width, "250");
|
||||
h.elements.forEach((el) => {
|
||||
expect(el.width).toBe(250);
|
||||
});
|
||||
|
||||
editInput(height, "450");
|
||||
h.elements.forEach((el) => {
|
||||
expect(el.height).toBe(450);
|
||||
});
|
||||
});
|
||||
|
||||
it("should display a property when one of the elements is editable for that property", async () => {
|
||||
// text, rectangle, frame
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const textEditorSelector = ".excalidraw-textEditorContainer > textarea";
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
editor.blur();
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(200, 100);
|
||||
|
||||
const frame = API.createElement({
|
||||
type: "frame",
|
||||
x: 150,
|
||||
width: 150,
|
||||
});
|
||||
|
||||
h.elements = [...h.elements, frame];
|
||||
|
||||
const text = h.elements.find((el) => el.type === "text");
|
||||
const rectangle = h.elements.find((el) => el.type === "rectangle");
|
||||
|
||||
h.setState({
|
||||
selectedElementIds: h.elements.reduce((acc, el) => {
|
||||
acc[el.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, true>),
|
||||
});
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
const width = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
expect(width.value).toBe("Mixed");
|
||||
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
expect(height.value).toBe("Mixed");
|
||||
|
||||
const angle = getStatsProperty("A")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(angle).toBeDefined();
|
||||
expect(angle.value).toBe("0");
|
||||
|
||||
const fontSize = getStatsProperty("F")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(fontSize).toBeDefined();
|
||||
|
||||
// changing width does not affect text
|
||||
editInput(width, "200");
|
||||
|
||||
expect(rectangle?.width).toBe(200);
|
||||
expect(frame.width).toBe(200);
|
||||
expect(text?.width).not.toBe(200);
|
||||
|
||||
editInput(angle, "40");
|
||||
|
||||
const angleInRadian = degreeToRadian(40);
|
||||
expect(rectangle?.angle).toBeCloseTo(angleInRadian, 4);
|
||||
expect(text?.angle).toBeCloseTo(angleInRadian, 4);
|
||||
expect(frame.angle).toBe(0);
|
||||
});
|
||||
|
||||
it("should treat groups as single units", () => {
|
||||
const createAndSelectGroup = () => {
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down();
|
||||
mouse.up(100, 100);
|
||||
|
||||
UI.clickTool("rectangle");
|
||||
mouse.down(0, 0);
|
||||
mouse.up(100, 100);
|
||||
|
||||
mouse.reset();
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.click();
|
||||
});
|
||||
|
||||
h.app.actionManager.executeAction(actionGroup);
|
||||
};
|
||||
|
||||
createAndSelectGroup();
|
||||
|
||||
const elementsInGroup = h.elements.filter((el) => isInGroup(el));
|
||||
let [x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
|
||||
elementStats = stats?.querySelector("#elementStats");
|
||||
|
||||
const x = getStatsProperty("X")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(x).toBeDefined();
|
||||
expect(Number(x.value)).toBe(x1);
|
||||
|
||||
editInput(x, "300");
|
||||
|
||||
expect(h.elements[0].x).toBe(300);
|
||||
expect(h.elements[1].x).toBe(400);
|
||||
expect(x.value).toBe("300");
|
||||
|
||||
const y = getStatsProperty("Y")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
|
||||
expect(y).toBeDefined();
|
||||
expect(Number(y.value)).toBe(y1);
|
||||
|
||||
editInput(y, "200");
|
||||
|
||||
expect(h.elements[0].y).toBe(200);
|
||||
expect(h.elements[1].y).toBe(300);
|
||||
expect(y.value).toBe("200");
|
||||
|
||||
const width = getStatsProperty("W")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(width).toBeDefined();
|
||||
expect(Number(width.value)).toBe(200);
|
||||
|
||||
const height = getStatsProperty("H")?.querySelector(
|
||||
".drag-input",
|
||||
) as HTMLInputElement;
|
||||
expect(height).toBeDefined();
|
||||
expect(Number(height.value)).toBe(200);
|
||||
|
||||
editInput(width, "400");
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
let newGroupWidth = x2 - x1;
|
||||
|
||||
expect(newGroupWidth).toBeCloseTo(400, 4);
|
||||
|
||||
editInput(width, "300");
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
newGroupWidth = x2 - x1;
|
||||
expect(newGroupWidth).toBeCloseTo(300, 4);
|
||||
|
||||
editInput(height, "500");
|
||||
|
||||
[x1, y1, x2, y2] = getCommonBounds(elementsInGroup);
|
||||
const newGroupHeight = y2 - y1;
|
||||
expect(newGroupHeight).toBeCloseTo(500, 4);
|
||||
});
|
||||
});
|
301
packages/excalidraw/components/Stats/utils.ts
Normal file
301
packages/excalidraw/components/Stats/utils.ts
Normal file
|
@ -0,0 +1,301 @@
|
|||
import {
|
||||
bindOrUnbindLinearElements,
|
||||
updateBoundElements,
|
||||
} from "../../element/binding";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import {
|
||||
measureFontSizeFromWidth,
|
||||
rescalePointsInElement,
|
||||
} from "../../element/resizeElements";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextMaxWidth,
|
||||
handleBindTextResize,
|
||||
} from "../../element/textElement";
|
||||
import {
|
||||
isFrameLikeElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
} from "../../element/typeChecks";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
getElementsInGroup,
|
||||
isInGroup,
|
||||
} from "../../groups";
|
||||
import { rotate } from "../../math";
|
||||
import type { AppState } from "../../types";
|
||||
import { getFontString } from "../../utils";
|
||||
|
||||
export type StatsInputProperty =
|
||||
| "x"
|
||||
| "y"
|
||||
| "width"
|
||||
| "height"
|
||||
| "angle"
|
||||
| "fontSize";
|
||||
|
||||
export const SMALLEST_DELTA = 0.01;
|
||||
|
||||
export const isPropertyEditable = (
|
||||
element: ExcalidrawElement,
|
||||
property: keyof ExcalidrawElement,
|
||||
) => {
|
||||
if (property === "height" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "width" && isTextElement(element)) {
|
||||
return false;
|
||||
}
|
||||
if (property === "angle" && isFrameLikeElement(element)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getStepSizedValue = (value: number, stepSize: number) => {
|
||||
const v = value + stepSize / 2;
|
||||
return v - (v % stepSize);
|
||||
};
|
||||
|
||||
export type AtomicUnit = Record<string, true>;
|
||||
export const getElementsInAtomicUnit = (
|
||||
atomicUnit: AtomicUnit,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap?: ElementsMap,
|
||||
) => {
|
||||
return Object.keys(atomicUnit)
|
||||
.map((id) => ({
|
||||
original: (originalElementsMap ?? elementsMap).get(id),
|
||||
latest: elementsMap.get(id),
|
||||
}))
|
||||
.filter((el) => el.original !== undefined && el.latest !== undefined) as {
|
||||
original: NonDeletedExcalidrawElement;
|
||||
latest: NonDeletedExcalidrawElement;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const newOrigin = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
w1: number,
|
||||
h1: number,
|
||||
w2: number,
|
||||
h2: number,
|
||||
angle: number,
|
||||
) => {
|
||||
/**
|
||||
* The formula below is the result of solving
|
||||
* rotate(x1, y1, cx1, cy1, angle) = rotate(x2, y2, cx2, cy2, angle)
|
||||
* where rotate is the function defined in math.ts
|
||||
*
|
||||
* This is so that the new origin (x2, y2),
|
||||
* when rotated against the new center (cx2, cy2),
|
||||
* coincides with (x1, y1) rotated against (cx1, cy1)
|
||||
*
|
||||
* The reason for doing this computation is so the element's top left corner
|
||||
* on the canvas remains fixed after any changes in its dimension.
|
||||
*/
|
||||
|
||||
return {
|
||||
x:
|
||||
x1 +
|
||||
(w1 - w2) / 2 +
|
||||
((w2 - w1) / 2) * Math.cos(angle) +
|
||||
((h1 - h2) / 2) * Math.sin(angle),
|
||||
y:
|
||||
y1 +
|
||||
(h1 - h2) / 2 +
|
||||
((w2 - w1) / 2) * Math.sin(angle) +
|
||||
((h2 - h1) / 2) * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
export const resizeElement = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
keepAspectRatio: boolean,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
const latestElement = elementsMap.get(origElement.id);
|
||||
if (!latestElement) {
|
||||
return;
|
||||
}
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(latestElement, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
nextWidth = Math.max(nextWidth, minWidth);
|
||||
nextHeight = Math.max(nextHeight, minHeight);
|
||||
}
|
||||
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
...newOrigin(
|
||||
latestElement.x,
|
||||
latestElement.y,
|
||||
latestElement.width,
|
||||
latestElement.height,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
latestElement.angle,
|
||||
),
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
...rescalePointsInElement(origElement, nextWidth, nextHeight, true),
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
updateBindings(latestElement, elementsMap, {
|
||||
newSize: {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
},
|
||||
});
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextFont = {
|
||||
fontSize: boundTextElement.fontSize,
|
||||
};
|
||||
if (keepAspectRatio) {
|
||||
const updatedElement = {
|
||||
...latestElement,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
};
|
||||
|
||||
const nextFont = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
getBoundTextMaxWidth(updatedElement, boundTextElement),
|
||||
);
|
||||
boundTextFont = {
|
||||
fontSize: nextFont?.size ?? boundTextElement.fontSize,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(latestElement, elementsMap, "e", keepAspectRatio);
|
||||
};
|
||||
|
||||
export const moveElement = (
|
||||
newTopLeftX: number,
|
||||
newTopLeftY: number,
|
||||
originalElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
shouldInformMutation = true,
|
||||
) => {
|
||||
const latestElement = elementsMap.get(originalElement.id);
|
||||
if (!latestElement) {
|
||||
return;
|
||||
}
|
||||
const [cx, cy] = [
|
||||
originalElement.x + originalElement.width / 2,
|
||||
originalElement.y + originalElement.height / 2,
|
||||
];
|
||||
const [topLeftX, topLeftY] = rotate(
|
||||
originalElement.x,
|
||||
originalElement.y,
|
||||
cx,
|
||||
cy,
|
||||
originalElement.angle,
|
||||
);
|
||||
|
||||
const changeInX = newTopLeftX - topLeftX;
|
||||
const changeInY = newTopLeftY - topLeftY;
|
||||
|
||||
const [x, y] = rotate(
|
||||
newTopLeftX,
|
||||
newTopLeftY,
|
||||
cx + changeInX,
|
||||
cy + changeInY,
|
||||
-originalElement.angle,
|
||||
);
|
||||
|
||||
mutateElement(
|
||||
latestElement,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
updateBindings(latestElement, elementsMap);
|
||||
|
||||
const boundTextElement = getBoundTextElement(
|
||||
originalElement,
|
||||
originalElementsMap,
|
||||
);
|
||||
if (boundTextElement) {
|
||||
const latestBoundTextElement = elementsMap.get(boundTextElement.id);
|
||||
latestBoundTextElement &&
|
||||
mutateElement(
|
||||
latestBoundTextElement,
|
||||
{
|
||||
x: boundTextElement.x + changeInX,
|
||||
y: boundTextElement.y + changeInY,
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const getAtomicUnits = (
|
||||
targetElements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedGroupIds = getSelectedGroupIds(appState);
|
||||
const _atomicUnits = selectedGroupIds.map((gid) => {
|
||||
return getElementsInGroup(targetElements, gid).reduce((acc, el) => {
|
||||
acc[el.id] = true;
|
||||
return acc;
|
||||
}, {} as AtomicUnit);
|
||||
});
|
||||
targetElements
|
||||
.filter((el) => !isInGroup(el))
|
||||
.forEach((el) => {
|
||||
_atomicUnits.push({
|
||||
[el.id]: true,
|
||||
});
|
||||
});
|
||||
return _atomicUnits;
|
||||
};
|
||||
|
||||
export const updateBindings = (
|
||||
latestElement: ExcalidrawElement,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
options?: {
|
||||
simultaneouslyUpdated?: readonly ExcalidrawElement[];
|
||||
newSize?: { width: number; height: number };
|
||||
},
|
||||
) => {
|
||||
if (isLinearElement(latestElement)) {
|
||||
bindOrUnbindLinearElements([latestElement], elementsMap, true, []);
|
||||
} else {
|
||||
updateBoundElements(latestElement, elementsMap, options);
|
||||
}
|
||||
};
|
|
@ -139,7 +139,7 @@ $verticalBreakpoint: 861px;
|
|||
|
||||
.ttd-dialog-output-error {
|
||||
color: red;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
font-size: 30px;
|
||||
word-break: break-word;
|
||||
overflow: auto;
|
||||
|
|
|
@ -1,10 +1,6 @@
|
|||
import type { MermaidOptions } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { MermaidConfig } from "@excalidraw/mermaid-to-excalidraw";
|
||||
import type { MermaidToExcalidrawResult } from "@excalidraw/mermaid-to-excalidraw/dist/interfaces";
|
||||
import {
|
||||
DEFAULT_EXPORT_PADDING,
|
||||
DEFAULT_FONT_SIZE,
|
||||
EDITOR_LS_KEYS,
|
||||
} from "../../constants";
|
||||
import { DEFAULT_EXPORT_PADDING, EDITOR_LS_KEYS } from "../../constants";
|
||||
import { convertToExcalidrawElements, exportToCanvas } from "../../index";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import type { AppClassProperties, BinaryFiles } from "../../types";
|
||||
|
@ -38,7 +34,7 @@ export interface MermaidToExcalidrawLibProps {
|
|||
api: Promise<{
|
||||
parseMermaidToExcalidraw: (
|
||||
definition: string,
|
||||
options: MermaidOptions,
|
||||
config?: MermaidConfig,
|
||||
) => Promise<MermaidToExcalidrawResult>;
|
||||
}>;
|
||||
}
|
||||
|
@ -78,15 +74,10 @@ export const convertMermaidToExcalidraw = async ({
|
|||
|
||||
let ret;
|
||||
try {
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition, {
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
});
|
||||
ret = await api.parseMermaidToExcalidraw(mermaidDefinition);
|
||||
} catch (err: any) {
|
||||
ret = await api.parseMermaidToExcalidraw(
|
||||
mermaidDefinition.replace(/"/g, "'"),
|
||||
{
|
||||
fontSize: DEFAULT_FONT_SIZE,
|
||||
},
|
||||
);
|
||||
}
|
||||
const { elements, files } = ret;
|
||||
|
|
|
@ -5,10 +5,11 @@
|
|||
--avatarList-gap: 0.625rem;
|
||||
--userList-padding: var(--space-factor);
|
||||
|
||||
.UserList-wrapper {
|
||||
.UserList__wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
|
@ -21,10 +22,6 @@
|
|||
align-items: center;
|
||||
gap: var(--avatarList-gap);
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
--max-size: calc(
|
||||
|
@ -157,66 +154,7 @@
|
|||
}
|
||||
|
||||
.UserList__collaborators {
|
||||
position: static;
|
||||
top: auto;
|
||||
margin-top: 0;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-top: 1px solid var(--userlist-collaborators-border-color);
|
||||
border-bottom: 1px solid var(--userlist-collaborators-border-color);
|
||||
|
||||
&__empty {
|
||||
color: var(--color-gray-60);
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.UserList__hint {
|
||||
padding: 0.5rem 0.75rem;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
color: var(--userlist-hint-text-color);
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
}
|
||||
|
||||
.UserList__search-wrapper {
|
||||
position: relative;
|
||||
height: 2.5rem;
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--color-gray-40);
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.UserList__search {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
font-size: 0.875rem;
|
||||
padding-left: 2.5rem !important;
|
||||
padding-right: 0.75rem !important;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-gray-40);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,12 @@ import type { ActionManager } from "../actions/manager";
|
|||
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Island } from "./Island";
|
||||
import { searchIcon } from "./icons";
|
||||
import { QuickSearch } from "./QuickSearch";
|
||||
import { t } from "../i18n";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { supportsResizeObserver } from "../constants";
|
||||
import type { MarkRequired } from "../utility-types";
|
||||
import { ScrollableList } from "./ScrollableList";
|
||||
|
||||
export type GoToCollaboratorComponentProps = {
|
||||
socketId: SocketId;
|
||||
|
@ -40,7 +41,7 @@ const ConditionalTooltipWrapper = ({
|
|||
shouldWrap ? (
|
||||
<Tooltip label={username || "Unknown user"}>{children}</Tooltip>
|
||||
) : (
|
||||
<React.Fragment>{children}</React.Fragment>
|
||||
<>{children}</>
|
||||
);
|
||||
|
||||
const renderCollaborator = ({
|
||||
|
@ -128,6 +129,10 @@ export const UserList = React.memo(
|
|||
).filter((collaborator) => collaborator.username?.trim());
|
||||
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const filteredCollaborators = uniqueCollaboratorsArray.filter(
|
||||
(collaborator) =>
|
||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
|
||||
const userListWrapper = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
|
@ -161,14 +166,6 @@ export const UserList = React.memo(
|
|||
|
||||
const [maxAvatars, setMaxAvatars] = React.useState(DEFAULT_MAX_AVATARS);
|
||||
|
||||
const searchTermNormalized = searchTerm.trim().toLowerCase();
|
||||
|
||||
const filteredCollaborators = searchTermNormalized
|
||||
? uniqueCollaboratorsArray.filter((collaborator) =>
|
||||
collaborator.username?.toLowerCase().includes(searchTerm),
|
||||
)
|
||||
: uniqueCollaboratorsArray;
|
||||
|
||||
const firstNCollaborators = uniqueCollaboratorsArray.slice(
|
||||
0,
|
||||
maxAvatars - 1,
|
||||
|
@ -197,7 +194,7 @@ export const UserList = React.memo(
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="UserList-wrapper" ref={userListWrapper}>
|
||||
<div className="UserList__wrapper" ref={userListWrapper}>
|
||||
<div
|
||||
className={clsx("UserList", className)}
|
||||
style={{ [`--max-avatars` as any]: maxAvatars }}
|
||||
|
@ -205,13 +202,7 @@ export const UserList = React.memo(
|
|||
{firstNAvatarsJSX}
|
||||
|
||||
{uniqueCollaboratorsArray.length > maxAvatars - 1 && (
|
||||
<Popover.Root
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
setSearchTerm("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger className="UserList__more">
|
||||
+{uniqueCollaboratorsArray.length - maxAvatars + 1}
|
||||
</Popover.Trigger>
|
||||
|
@ -224,41 +215,43 @@ export const UserList = React.memo(
|
|||
align="end"
|
||||
sideOffset={10}
|
||||
>
|
||||
<Island style={{ overflow: "hidden" }}>
|
||||
<Island padding={2}>
|
||||
{uniqueCollaboratorsArray.length >=
|
||||
SHOW_COLLABORATORS_FILTER_AT && (
|
||||
<div className="UserList__search-wrapper">
|
||||
{searchIcon}
|
||||
<input
|
||||
className="UserList__search"
|
||||
type="text"
|
||||
placeholder={t("userList.search.placeholder")}
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<QuickSearch
|
||||
placeholder={t("quickSearch.placeholder")}
|
||||
onChange={setSearchTerm}
|
||||
/>
|
||||
)}
|
||||
<div className="dropdown-menu UserList__collaborators">
|
||||
{filteredCollaborators.length === 0 && (
|
||||
<div className="UserList__collaborators__empty">
|
||||
{t("userList.search.empty")}
|
||||
</div>
|
||||
)}
|
||||
<div className="UserList__hint">
|
||||
{t("userList.hint.text")}
|
||||
</div>
|
||||
{filteredCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
withName: true,
|
||||
isBeingFollowed: collaborator.socketId === userToFollow,
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
<ScrollableList
|
||||
className={"dropdown-menu UserList__collaborators"}
|
||||
placeholder={t("userList.empty")}
|
||||
>
|
||||
{/* The list checks for `Children.count()`, hence defensively returning empty list */}
|
||||
{filteredCollaborators.length > 0
|
||||
? [
|
||||
<div className="hint">{t("userList.hint.text")}</div>,
|
||||
filteredCollaborators.map((collaborator) =>
|
||||
renderCollaborator({
|
||||
actionManager,
|
||||
collaborator,
|
||||
socketId: collaborator.socketId,
|
||||
withName: true,
|
||||
isBeingFollowed:
|
||||
collaborator.socketId === userToFollow,
|
||||
}),
|
||||
),
|
||||
]
|
||||
: []}
|
||||
</ScrollableList>
|
||||
<Popover.Arrow
|
||||
width={20}
|
||||
height={10}
|
||||
style={{
|
||||
fill: "var(--popup-bg-color)",
|
||||
filter: "drop-shadow(rgba(0, 0, 0, 0.05) 0px 3px 2px)",
|
||||
}}
|
||||
/>
|
||||
</Island>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
|
|
@ -9,7 +9,10 @@ import type {
|
|||
RenderableElementsMap,
|
||||
RenderInteractiveSceneCallback,
|
||||
} from "../../scene/types";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import type {
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeletedSceneElementsMap,
|
||||
} from "../../element/types";
|
||||
import { isRenderThrottlingEnabled } from "../../reactUtils";
|
||||
import { renderInteractiveScene } from "../../renderer/interactiveScene";
|
||||
|
||||
|
@ -19,7 +22,8 @@ type InteractiveCanvasProps = {
|
|||
elementsMap: RenderableElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
sceneNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
|
@ -122,6 +126,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
|
|||
elementsMap: props.elementsMap,
|
||||
visibleElements: props.visibleElements,
|
||||
selectedElements: props.selectedElements,
|
||||
allElementsMap: props.allElementsMap,
|
||||
scale: window.devicePixelRatio,
|
||||
appState: props.appState,
|
||||
renderConfig: {
|
||||
|
@ -197,6 +202,7 @@ const getRelevantAppStateProps = (
|
|||
activeEmbeddable: appState.activeEmbeddable,
|
||||
snapLines: appState.snapLines,
|
||||
zenModeEnabled: appState.zenModeEnabled,
|
||||
editingElement: appState.editingElement,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
@ -206,10 +212,10 @@ const areEqual = (
|
|||
// This could be further optimised if needed, as we don't have to render interactive canvas on each scene mutation
|
||||
if (
|
||||
prevProps.selectionNonce !== nextProps.selectionNonce ||
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on elementsMap because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements ||
|
||||
|
|
|
@ -19,7 +19,7 @@ type StaticCanvasProps = {
|
|||
elementsMap: RenderableElementsMap;
|
||||
allElementsMap: NonDeletedSceneElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
sceneNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: StaticCanvasAppState;
|
||||
|
@ -105,6 +105,7 @@ const getRelevantAppStateProps = (
|
|||
selectedElementIds: appState.selectedElementIds,
|
||||
frameToHighlight: appState.frameToHighlight,
|
||||
editingGroupId: appState.editingGroupId,
|
||||
currentHoveredFontFamily: appState.currentHoveredFontFamily,
|
||||
});
|
||||
|
||||
const areEqual = (
|
||||
|
@ -112,10 +113,10 @@ const areEqual = (
|
|||
nextProps: StaticCanvasProps,
|
||||
) => {
|
||||
if (
|
||||
prevProps.versionNonce !== nextProps.versionNonce ||
|
||||
prevProps.sceneNonce !== nextProps.sceneNonce ||
|
||||
prevProps.scale !== nextProps.scale ||
|
||||
// we need to memoize on elementsMap because they may have renewed
|
||||
// even if versionNonce didn't change (e.g. we filter elements out based
|
||||
// even if sceneNonce didn't change (e.g. we filter elements out based
|
||||
// on appState)
|
||||
prevProps.elementsMap !== nextProps.elementsMap ||
|
||||
prevProps.visibleElements !== nextProps.visibleElements
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
.dropdown-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
margin-top: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
&--mobile {
|
||||
left: 0;
|
||||
|
@ -35,21 +35,69 @@
|
|||
|
||||
.dropdown-menu-item-base {
|
||||
display: flex;
|
||||
padding: 0 0.625rem;
|
||||
column-gap: 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-on-surface);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-weight: normal;
|
||||
font-weight: 400;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
&.manual-hover {
|
||||
// disable built-in hover due to keyboard navigation
|
||||
.dropdown-menu-item {
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&--hovered {
|
||||
background-color: var(--button-hover-bg) !important;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background-color: var(--color-primary-light) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.fonts {
|
||||
margin-top: 1rem;
|
||||
// display max 7 items per list, where each has 2rem (2.25) height and 1px margin top & bottom
|
||||
// count in 2 groups, where each allocates 1.3*0.75rem font-size and 0.5rem margin bottom, plus one extra 1rem margin top
|
||||
max-height: calc(7 * (2rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem);
|
||||
|
||||
@media screen and (min-width: 1921px) {
|
||||
max-height: calc(
|
||||
7 * (2.25rem + 2px) + 2 * (0.5rem + 1.3 * 0.75rem) + 1rem
|
||||
);
|
||||
}
|
||||
|
||||
.dropdown-menu-item-base {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.dropdown-menu-group:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.dropdown-menu-group-title {
|
||||
font-size: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: 400;
|
||||
margin: 0 0 0.5rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu-item {
|
||||
height: 2rem;
|
||||
margin: 1px;
|
||||
padding: 0 0.5rem;
|
||||
width: calc(100% - 2px);
|
||||
background-color: transparent;
|
||||
border: 1px solid transparent;
|
||||
align-items: center;
|
||||
height: 2rem;
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-md);
|
||||
|
||||
|
@ -57,11 +105,6 @@
|
|||
height: 2.25rem;
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -83,6 +126,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
&--selected {
|
||||
background: var(--color-primary-light);
|
||||
--icon-fill-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--button-hover-bg);
|
||||
text-decoration: none;
|
||||
|
|
|
@ -1,37 +1,62 @@
|
|||
import React from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import {
|
||||
getDropdownMenuItemClassName,
|
||||
useHandleDropdownMenuItemClick,
|
||||
} from "./common";
|
||||
import MenuItemContent from "./DropdownMenuItemContent";
|
||||
import { useExcalidrawAppState } from "../App";
|
||||
import { THEME } from "../../constants";
|
||||
import type { ValueOf } from "../../utility-types";
|
||||
|
||||
const DropdownMenuItem = ({
|
||||
icon,
|
||||
onSelect,
|
||||
value,
|
||||
order,
|
||||
children,
|
||||
shortcut,
|
||||
className,
|
||||
hovered,
|
||||
selected,
|
||||
textStyle,
|
||||
onSelect,
|
||||
onClick,
|
||||
...rest
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
onSelect: (event: Event) => void;
|
||||
value?: string | number | undefined;
|
||||
order?: number;
|
||||
onSelect?: (event: Event) => void;
|
||||
children: React.ReactNode;
|
||||
shortcut?: string;
|
||||
hovered?: boolean;
|
||||
selected?: boolean;
|
||||
textStyle?: React.CSSProperties;
|
||||
className?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const handleClick = useHandleDropdownMenuItemClick(rest.onClick, onSelect);
|
||||
const handleClick = useHandleDropdownMenuItemClick(onClick, onSelect);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered) {
|
||||
if (order === 0) {
|
||||
// scroll into the first item differently, so it's visible what is above (i.e. group title)
|
||||
ref.current?.scrollIntoView({ block: "end" });
|
||||
} else {
|
||||
ref.current?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [hovered, order]);
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={value}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
className={getDropdownMenuItemClassName(className, selected)}
|
||||
className={getDropdownMenuItemClassName(className, selected, hovered)}
|
||||
title={rest.title ?? rest["aria-label"]}
|
||||
>
|
||||
<MenuItemContent icon={icon} shortcut={shortcut}>
|
||||
<MenuItemContent textStyle={textStyle} icon={icon} shortcut={shortcut}>
|
||||
{children}
|
||||
</MenuItemContent>
|
||||
</button>
|
||||
|
@ -39,24 +64,53 @@ const DropdownMenuItem = ({
|
|||
};
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem";
|
||||
|
||||
export const DropDownMenuItemBadgeType = {
|
||||
GREEN: "green",
|
||||
RED: "red",
|
||||
BLUE: "blue",
|
||||
} as const;
|
||||
|
||||
export const DropDownMenuItemBadge = ({
|
||||
type = DropDownMenuItemBadgeType.BLUE,
|
||||
children,
|
||||
}: {
|
||||
type?: ValueOf<typeof DropDownMenuItemBadgeType>;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
padding: "2px 4px",
|
||||
const { theme } = useExcalidrawAppState();
|
||||
const style = {
|
||||
display: "inline-flex",
|
||||
marginLeft: "auto",
|
||||
padding: "2px 4px",
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontFamily: "Cascadia, monospace",
|
||||
border: theme === THEME.LIGHT ? "1.5px solid white" : "none",
|
||||
};
|
||||
|
||||
switch (type) {
|
||||
case DropDownMenuItemBadgeType.GREEN:
|
||||
Object.assign(style, {
|
||||
backgroundColor: "var(--background-color-badge)",
|
||||
color: "var(--color-badge)",
|
||||
});
|
||||
break;
|
||||
case DropDownMenuItemBadgeType.RED:
|
||||
Object.assign(style, {
|
||||
backgroundColor: "pink",
|
||||
color: "darkred",
|
||||
});
|
||||
break;
|
||||
case DropDownMenuItemBadgeType.BLUE:
|
||||
default:
|
||||
Object.assign(style, {
|
||||
background: "var(--color-promo)",
|
||||
color: "var(--color-surface-lowest)",
|
||||
borderRadius: 6,
|
||||
fontSize: 9,
|
||||
fontFamily: "Cascadia, monospace",
|
||||
}}
|
||||
>
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="DropDownMenuItemBadge" style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,19 +1,23 @@
|
|||
import { useDevice } from "../App";
|
||||
|
||||
const MenuItemContent = ({
|
||||
textStyle,
|
||||
icon,
|
||||
shortcut,
|
||||
children,
|
||||
}: {
|
||||
icon?: JSX.Element;
|
||||
shortcut?: string;
|
||||
textStyle?: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
<div className="dropdown-menu-item__icon">{icon}</div>
|
||||
<div className="dropdown-menu-item__text">{children}</div>
|
||||
{icon && <div className="dropdown-menu-item__icon">{icon}</div>}
|
||||
<div style={textStyle} className="dropdown-menu-item__text">
|
||||
{children}
|
||||
</div>
|
||||
{shortcut && !device.editor.isMobile && (
|
||||
<div className="dropdown-menu-item__shortcut">{shortcut}</div>
|
||||
)}
|
||||
|
|
|
@ -9,9 +9,11 @@ export const DropdownMenuContentPropsContext = React.createContext<{
|
|||
export const getDropdownMenuItemClassName = (
|
||||
className = "",
|
||||
selected = false,
|
||||
hovered = false,
|
||||
) => {
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className} ${
|
||||
selected ? "dropdown-menu-item--selected" : ""
|
||||
return `dropdown-menu-item dropdown-menu-item-base ${className}
|
||||
${selected ? "dropdown-menu-item--selected" : ""} ${
|
||||
hovered ? "dropdown-menu-item--hovered" : ""
|
||||
}`.trim();
|
||||
};
|
||||
|
||||
|
|
|
@ -698,14 +698,18 @@ export const BringForwardIcon = createIcon(arrownNarrowUpJSX, tablerIconProps);
|
|||
|
||||
export const SendBackwardIcon = createIcon(arrownNarrowUpJSX, {
|
||||
...tablerIconProps,
|
||||
transform: "rotate(180)",
|
||||
style: {
|
||||
transform: "rotate(180deg)",
|
||||
},
|
||||
});
|
||||
|
||||
export const BringToFrontIcon = createIcon(arrowBarToTopJSX, tablerIconProps);
|
||||
|
||||
export const SendToBackIcon = createIcon(arrowBarToTopJSX, {
|
||||
...tablerIconProps,
|
||||
transform: "rotate(180)",
|
||||
style: {
|
||||
transform: "rotate(180deg)",
|
||||
},
|
||||
});
|
||||
|
||||
//
|
||||
|
@ -1434,6 +1438,27 @@ export const fontSizeIcon = createIcon(
|
|||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const FontFamilyHeadingIcon = createIcon(
|
||||
<>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M7 12h10" />
|
||||
<path d="M7 5v14" />
|
||||
<path d="M17 5v14" />
|
||||
<path d="M15 19h4" />
|
||||
<path d="M15 5h4" />
|
||||
<path d="M5 19h4" />
|
||||
<path d="M5 5h4" />
|
||||
</g>
|
||||
</>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const FontFamilyNormalIcon = createIcon(
|
||||
<>
|
||||
<g
|
||||
|
@ -1569,6 +1594,18 @@ export const TextAlignMiddleIcon = React.memo(({ theme }: { theme: Theme }) =>
|
|||
),
|
||||
);
|
||||
|
||||
export const angleIcon = createIcon(
|
||||
<g>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M21 19h-18l9 -15" />
|
||||
<path d="M20.615 15.171h.015" />
|
||||
<path d="M19.515 11.771h.015" />
|
||||
<path d="M17.715 8.671h.015" />
|
||||
<path d="M15.415 5.971h.015" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const publishIcon = createIcon(
|
||||
<path
|
||||
d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"
|
||||
|
@ -2057,3 +2094,19 @@ export const lineEditorIcon = createIcon(
|
|||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const collapseDownIcon = createIcon(
|
||||
<g>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 9l6 6l6 -6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const collapseUpIcon = createIcon(
|
||||
<g>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M6 15l6 -6l6 6" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
|
|
@ -109,7 +109,7 @@ Center.displayName = "Center";
|
|||
|
||||
const Logo = ({ children }: { children?: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center__logo virgil welcome-screen-decor">
|
||||
<div className="welcome-screen-center__logo excalifont welcome-screen-decor">
|
||||
{children || <ExcalidrawLogo withText />}
|
||||
</div>
|
||||
);
|
||||
|
@ -118,7 +118,7 @@ Logo.displayName = "Logo";
|
|||
|
||||
const Heading = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<div className="welcome-screen-center__heading welcome-screen-decor virgil">
|
||||
<div className="welcome-screen-center__heading welcome-screen-decor excalifont">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,7 +10,7 @@ const MenuHint = ({ children }: { children?: React.ReactNode }) => {
|
|||
const { WelcomeScreenMenuHintTunnel } = useTunnels();
|
||||
return (
|
||||
<WelcomeScreenMenuHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--menu">
|
||||
{WelcomeScreenMenuArrow}
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.menuHint")}
|
||||
|
@ -25,7 +25,7 @@ const ToolbarHint = ({ children }: { children?: React.ReactNode }) => {
|
|||
const { WelcomeScreenToolbarHintTunnel } = useTunnels();
|
||||
return (
|
||||
<WelcomeScreenToolbarHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--toolbar">
|
||||
<div className="welcome-screen-decor-hint__label">
|
||||
{children || t("welcomeScreen.defaults.toolbarHint")}
|
||||
</div>
|
||||
|
@ -40,7 +40,7 @@ const HelpHint = ({ children }: { children?: React.ReactNode }) => {
|
|||
const { WelcomeScreenHelpHintTunnel } = useTunnels();
|
||||
return (
|
||||
<WelcomeScreenHelpHintTunnel.In>
|
||||
<div className="virgil welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div className="excalifont welcome-screen-decor welcome-screen-decor-hint welcome-screen-decor-hint--help">
|
||||
<div>{children || t("welcomeScreen.defaults.helpHint")}</div>
|
||||
{WelcomeScreenHelpArrow}
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
.excalidraw {
|
||||
.virgil {
|
||||
font-family: "Virgil";
|
||||
.excalifont {
|
||||
font-family: "Excalifont";
|
||||
}
|
||||
|
||||
// WelcomeSreen common
|
||||
|
|
|
@ -25,6 +25,11 @@ export const supportsResizeObserver =
|
|||
|
||||
export const APP_NAME = "Excalidraw";
|
||||
|
||||
// distance when creating text before it's considered `autoResize: false`
|
||||
// we're using higher threshold so that clicks that end up being drags
|
||||
// don't unintentionally create text elements that are wrapped to a few chars
|
||||
// (happens a lot with fast clicks with the text tool)
|
||||
export const TEXT_AUTOWRAP_THRESHOLD = 36; // px
|
||||
export const DRAGGING_THRESHOLD = 10; // px
|
||||
export const LINE_CONFIRM_THRESHOLD = 8; // px
|
||||
export const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
|
@ -109,12 +114,24 @@ export const CLASSES = {
|
|||
SHAPE_ACTIONS_MENU: "App-menu__left",
|
||||
};
|
||||
|
||||
// 1-based in case we ever do `if(element.fontFamily)`
|
||||
/**
|
||||
* // TODO: shouldn't be really `const`, likely neither have integers as values, due to value for the custom fonts, which should likely be some hash.
|
||||
*
|
||||
* Let's think this through and consider:
|
||||
* - https://developer.mozilla.org/en-US/docs/Web/CSS/generic-family
|
||||
* - https://drafts.csswg.org/css-fonts-4/#font-family-prop
|
||||
* - https://learn.microsoft.com/en-us/typography/opentype/spec/ibmfc
|
||||
*/
|
||||
export const FONT_FAMILY = {
|
||||
Virgil: 1,
|
||||
Helvetica: 2,
|
||||
Cascadia: 3,
|
||||
Assistant: 4,
|
||||
// leave 4 unused as it was historically used for Assistant (which we don't use anymore) or custom font (Obsidian)
|
||||
Excalifont: 5,
|
||||
Nunito: 6,
|
||||
"Lilita One": 7,
|
||||
"Comic Shanns": 8,
|
||||
"Liberation Sans": 9,
|
||||
};
|
||||
|
||||
export const THEME = {
|
||||
|
@ -142,7 +159,7 @@ export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
|||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Excalifont;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
export const DEFAULT_VERTICAL_ALIGN = "top";
|
||||
export const DEFAULT_VERSION = "{version}";
|
||||
|
@ -269,7 +286,7 @@ export const DEFAULT_EXPORT_PADDING = 10; // px
|
|||
|
||||
export const DEFAULT_MAX_IMAGE_WIDTH_OR_HEIGHT = 1440;
|
||||
|
||||
export const MAX_ALLOWED_FILE_BYTES = 2 * 1024 * 1024;
|
||||
export const MAX_ALLOWED_FILE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
|
@ -400,3 +417,7 @@ export const EDITOR_LS_KEYS = {
|
|||
* where filename is optional and we can't retrieve name from app state
|
||||
*/
|
||||
export const DEFAULT_FILENAME = "Untitled";
|
||||
|
||||
export const STATS_PANELS = { generalStats: 1, elementProperties: 2 } as const;
|
||||
|
||||
export const MIN_WIDTH_OR_HEIGHT = 1;
|
||||
|
|
|
@ -22,6 +22,12 @@
|
|||
--sat: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
body.excalidraw-cursor-resize,
|
||||
body.excalidraw-cursor-resize a:hover,
|
||||
body.excalidraw-cursor-resize * {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
|
@ -146,7 +152,7 @@
|
|||
margin-bottom: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-primary-color);
|
||||
font-weight: normal;
|
||||
font-weight: 400;
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
@ -221,14 +227,7 @@
|
|||
label,
|
||||
button,
|
||||
.zIndexButton {
|
||||
@include outlineButtonStyles;
|
||||
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
@include outlineButtonIconStyles;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -388,7 +387,7 @@
|
|||
.App-menu__left {
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
width: 202px;
|
||||
width: 200px;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
}
|
||||
|
@ -579,7 +578,7 @@
|
|||
// use custom, minimalistic scrollbar
|
||||
// (doesn't work in Firefox)
|
||||
::-webkit-scrollbar {
|
||||
width: 3px;
|
||||
width: 4px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
|
@ -658,6 +657,10 @@
|
|||
--button-hover-bg: #363541;
|
||||
--button-bg: var(--color-surface-high);
|
||||
}
|
||||
|
||||
.buttonList {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__paragraph {
|
||||
|
@ -751,7 +754,7 @@
|
|||
padding: 1rem 1.6rem;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.6px;
|
||||
font-family: "Assistant";
|
||||
}
|
||||
|
|
|
@ -151,6 +151,9 @@
|
|||
--color-border-outline-variant: #c5c5d0;
|
||||
--color-surface-primary-container: #e0dfff;
|
||||
|
||||
--color-badge: #0b6513;
|
||||
--background-color-badge: #d3ffd2;
|
||||
|
||||
&.theme--dark {
|
||||
&.theme--dark-background-none {
|
||||
background: none;
|
||||
|
|
|
@ -124,6 +124,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
@mixin outlineButtonIconStyles {
|
||||
@include outlineButtonStyles;
|
||||
padding: 0;
|
||||
|
||||
svg {
|
||||
width: var(--default-icon-size);
|
||||
height: var(--default-icon-size);
|
||||
}
|
||||
}
|
||||
|
||||
@mixin avatarStyles {
|
||||
width: var(--avatar-size, 1.5rem);
|
||||
height: var(--avatar-size, 1.5rem);
|
||||
|
@ -135,7 +145,7 @@
|
|||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--color-gray-90);
|
||||
flex: 0 0 auto;
|
||||
|
|
|
@ -228,6 +228,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing s
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
|
@ -238,7 +239,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -273,6 +274,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
|
@ -283,7 +285,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -378,12 +380,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to existing t
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to existing text elements when start / end provided with ids 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id48",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -478,12 +481,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to shapes whe
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to shapes when start / end provided without ids 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id37",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -652,12 +656,13 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id41",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -692,6 +697,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 3`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
|
@ -702,7 +708,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -737,6 +743,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||
exports[`Test Transform > Test arrow bindings > should bind arrows to text when start / end provided without ids 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [
|
||||
{
|
||||
|
@ -747,7 +754,7 @@ exports[`Test Transform > Test arrow bindings > should bind arrows to text when
|
|||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -1194,12 +1201,13 @@ exports[`Test Transform > should transform regular shapes 6`] = `
|
|||
exports[`Test Transform > should transform text element 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -1234,12 +1242,13 @@ exports[`Test Transform > should transform text element 1`] = `
|
|||
exports[`Test Transform > should transform text element 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": null,
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -1566,12 +1575,13 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 7`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "B",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
|
@ -1608,12 +1618,13 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 8`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "A",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
|
@ -1650,12 +1661,13 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 9`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "Alice",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
|
@ -1692,12 +1704,13 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 10`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "Bob",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [
|
||||
|
@ -1734,12 +1747,13 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 11`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_Alice",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -1774,12 +1788,13 @@ exports[`Test Transform > should transform the elements correctly when linear el
|
|||
exports[`Test Transform > should transform the elements correctly when linear elements have single point 12`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "Bob_B",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2022,12 +2037,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 5`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id25",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2062,12 +2078,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 6`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id26",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2102,12 +2119,13 @@ exports[`Test Transform > should transform to labelled arrows when label provide
|
|||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 7`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id27",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2143,12 +2161,13 @@ LABELLED ARROW",
|
|||
exports[`Test Transform > should transform to labelled arrows when label provided for arrows 8`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id28",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2406,12 +2425,13 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||
exports[`Test Transform > should transform to text containers when label provided 7`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id13",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2446,12 +2466,13 @@ exports[`Test Transform > should transform to text containers when label provide
|
|||
exports[`Test Transform > should transform to text containers when label provided 8`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id14",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2487,12 +2508,13 @@ CONTAINER",
|
|||
exports[`Test Transform > should transform to text containers when label provided 9`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id15",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2530,12 +2552,13 @@ CONTAINER",
|
|||
exports[`Test Transform > should transform to text containers when label provided 10`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id16",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2571,12 +2594,13 @@ TEXT CONTAINER",
|
|||
exports[`Test Transform > should transform to text containers when label provided 11`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id17",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
@ -2613,12 +2637,13 @@ CONTAINER",
|
|||
exports[`Test Transform > should transform to text containers when label provided 12`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id18",
|
||||
"customData": undefined,
|
||||
"fillStyle": "solid",
|
||||
"fontFamily": 1,
|
||||
"fontFamily": 5,
|
||||
"fontSize": 20,
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawElementType,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
|
@ -21,7 +22,12 @@ import {
|
|||
isInvisiblySmallElement,
|
||||
refreshTextDimensions,
|
||||
} from "../element";
|
||||
import { isTextElement, isUsingAdaptiveRadius } from "../element/typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isLinearElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "../element/typeChecks";
|
||||
import { randomId } from "../random";
|
||||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
|
@ -38,13 +44,11 @@ import { bumpVersion } from "../element/mutateElement";
|
|||
import { getUpdatedTimestamp, updateActiveTool } from "../utils";
|
||||
import { arrayToMap } from "../utils";
|
||||
import type { MarkOptional, Mutable } from "../utility-types";
|
||||
import {
|
||||
detectLineHeight,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
} from "../element/textElement";
|
||||
import { detectLineHeight, getContainerElement } from "../element/textElement";
|
||||
import { normalizeLink } from "./url";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getSizeFromPoints } from "../points";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
|
@ -203,7 +207,7 @@ const restoreElement = (
|
|||
detectLineHeight(element)
|
||||
: // no element height likely means programmatic use, so default
|
||||
// to a fixed line height
|
||||
getDefaultLineHeight(element.fontFamily));
|
||||
getLineHeight(element.fontFamily));
|
||||
element = restoreElementWithProperties(element, {
|
||||
fontSize,
|
||||
fontFamily,
|
||||
|
@ -212,7 +216,7 @@ const restoreElement = (
|
|||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: element.containerId ?? null,
|
||||
originalText: element.originalText || text,
|
||||
|
||||
autoResize: element.autoResize ?? true,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
|
@ -274,6 +278,7 @@ const restoreElement = (
|
|||
points,
|
||||
x,
|
||||
y,
|
||||
...getSizeFromPoints(points),
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -462,6 +467,23 @@ export const restoreElements = (
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isLinearElement(element)) {
|
||||
if (
|
||||
element.startBinding &&
|
||||
(!restoredElementsMap.has(element.startBinding.elementId) ||
|
||||
!isArrowElement(element))
|
||||
) {
|
||||
(element as Mutable<ExcalidrawLinearElement>).startBinding = null;
|
||||
}
|
||||
if (
|
||||
element.endBinding &&
|
||||
(!restoredElementsMap.has(element.endBinding.elementId) ||
|
||||
!isArrowElement(element))
|
||||
) {
|
||||
(element as Mutable<ExcalidrawLinearElement>).endBinding = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return restoredElements;
|
||||
|
|
|
@ -18,11 +18,7 @@ import {
|
|||
newMagicFrameElement,
|
||||
newTextElement,
|
||||
} from "../element/newElement";
|
||||
import {
|
||||
getDefaultLineHeight,
|
||||
measureText,
|
||||
normalizeText,
|
||||
} from "../element/textElement";
|
||||
import { measureText, normalizeText } from "../element/textElement";
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
|
@ -54,6 +50,7 @@ import {
|
|||
import { getSizeFromPoints } from "../points";
|
||||
import { randomId } from "../random";
|
||||
import { syncInvalidIndices } from "../fractionalIndex";
|
||||
import { getLineHeight } from "../fonts";
|
||||
|
||||
export type ValidLinearElement = {
|
||||
type: "arrow" | "line";
|
||||
|
@ -568,8 +565,7 @@ export const convertToExcalidrawElements = (
|
|||
case "text": {
|
||||
const fontFamily = element?.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = element?.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight =
|
||||
element?.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const lineHeight = element?.lineHeight || getLineHeight(fontFamily);
|
||||
const text = element.text ?? "";
|
||||
const normalizedText = normalizeText(text);
|
||||
const metrics = measureText(
|
||||
|
|
|
@ -25,7 +25,7 @@ import type {
|
|||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import type { AppClassProperties, AppState, Point } from "../types";
|
||||
import type { AppState, Point } from "../types";
|
||||
import { isPointOnShape } from "../../utils/collision";
|
||||
import { getElementAtPosition } from "../scene";
|
||||
import {
|
||||
|
@ -43,6 +43,7 @@ import { LinearElementEditor } from "./linearElementEditor";
|
|||
import { arrayToMap, tupleToCoors } from "../utils";
|
||||
import { KEYS } from "../keys";
|
||||
import { getBoundTextElement, handleBindTextResize } from "./textElement";
|
||||
import { getElementShape } from "../shapes";
|
||||
|
||||
export type SuggestedBinding =
|
||||
| NonDeleted<ExcalidrawBindableElement>
|
||||
|
@ -179,19 +180,19 @@ const bindOrUnbindLinearElementEdge = (
|
|||
const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
edge: "start" | "end",
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): NonDeleted<ExcalidrawElement> | null => {
|
||||
const elementsMap = app.scene.getNonDeletedElementsMap();
|
||||
const coors = getLinearElementEdgeCoors(linearElement, edge, elementsMap);
|
||||
const elementId =
|
||||
edge === "start"
|
||||
? linearElement.startBinding?.elementId
|
||||
: linearElement.endBinding?.elementId;
|
||||
if (elementId) {
|
||||
const element = elementsMap.get(
|
||||
elementId,
|
||||
) as NonDeleted<ExcalidrawBindableElement>;
|
||||
if (bindingBorderTest(element, coors, app)) {
|
||||
const element = elementsMap.get(elementId);
|
||||
if (
|
||||
isBindableElement(element) &&
|
||||
bindingBorderTest(element, coors, elementsMap)
|
||||
) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
@ -201,13 +202,13 @@ const getOriginalBindingIfStillCloseOfLinearElementEdge = (
|
|||
|
||||
const getOriginalBindingsIfStillCloseToArrowEnds = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): (NonDeleted<ExcalidrawElement> | null)[] =>
|
||||
["start", "end"].map((edge) =>
|
||||
getOriginalBindingIfStillCloseOfLinearElementEdge(
|
||||
linearElement,
|
||||
edge as "start" | "end",
|
||||
app,
|
||||
elementsMap,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -215,7 +216,7 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
isBindingEnabled: boolean,
|
||||
draggingPoints: readonly number[],
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||
const startIdx = 0;
|
||||
const endIdx = selectedElement.points.length - 1;
|
||||
|
@ -223,37 +224,57 @@ const getBindingStrategyForDraggingArrowEndpoints = (
|
|||
const endDragged = draggingPoints.findIndex((i) => i === endIdx) > -1;
|
||||
const start = startDragged
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
)
|
||||
: null // If binding is disabled and start is dragged, break all binds
|
||||
: // We have to update the focus and gap of the binding, so let's rebind
|
||||
getElligibleElementForBindingElement(selectedElement, "start", app);
|
||||
getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
);
|
||||
const end = endDragged
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
)
|
||||
: null // If binding is disabled and end is dragged, break all binds
|
||||
: // We have to update the focus and gap of the binding, so let's rebind
|
||||
getElligibleElementForBindingElement(selectedElement, "end", app);
|
||||
getElligibleElementForBindingElement(selectedElement, "end", elementsMap);
|
||||
|
||||
return [start, end];
|
||||
};
|
||||
|
||||
const getBindingStrategyForDraggingArrowOrJoints = (
|
||||
selectedElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
isBindingEnabled: boolean,
|
||||
): (NonDeleted<ExcalidrawBindableElement> | null | "keep")[] => {
|
||||
const [startIsClose, endIsClose] = getOriginalBindingsIfStillCloseToArrowEnds(
|
||||
selectedElement,
|
||||
app,
|
||||
elementsMap,
|
||||
);
|
||||
const start = startIsClose
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(selectedElement, "start", app)
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"start",
|
||||
elementsMap,
|
||||
)
|
||||
: null
|
||||
: null;
|
||||
const end = endIsClose
|
||||
? isBindingEnabled
|
||||
? getElligibleElementForBindingElement(selectedElement, "end", app)
|
||||
? getElligibleElementForBindingElement(
|
||||
selectedElement,
|
||||
"end",
|
||||
elementsMap,
|
||||
)
|
||||
: null
|
||||
: null;
|
||||
|
||||
|
@ -262,7 +283,7 @@ const getBindingStrategyForDraggingArrowOrJoints = (
|
|||
|
||||
export const bindOrUnbindLinearElements = (
|
||||
selectedElements: NonDeleted<ExcalidrawLinearElement>[],
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
isBindingEnabled: boolean,
|
||||
draggingPoints: readonly number[] | null,
|
||||
): void => {
|
||||
|
@ -273,27 +294,22 @@ export const bindOrUnbindLinearElements = (
|
|||
selectedElement,
|
||||
isBindingEnabled,
|
||||
draggingPoints ?? [],
|
||||
app,
|
||||
elementsMap,
|
||||
)
|
||||
: // The arrow itself (the shaft) or the inner joins are dragged
|
||||
getBindingStrategyForDraggingArrowOrJoints(
|
||||
selectedElement,
|
||||
app,
|
||||
elementsMap,
|
||||
isBindingEnabled,
|
||||
);
|
||||
|
||||
bindOrUnbindLinearElement(
|
||||
selectedElement,
|
||||
start,
|
||||
end,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
bindOrUnbindLinearElement(selectedElement, start, end, elementsMap);
|
||||
});
|
||||
};
|
||||
|
||||
export const getSuggestedBindingsForArrows = (
|
||||
selectedElements: NonDeleted<ExcalidrawElement>[],
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): SuggestedBinding[] => {
|
||||
// HOT PATH: Bail out if selected elements list is too large
|
||||
if (selectedElements.length > 50) {
|
||||
|
@ -304,7 +320,7 @@ export const getSuggestedBindingsForArrows = (
|
|||
selectedElements
|
||||
.filter(isLinearElement)
|
||||
.flatMap((element) =>
|
||||
getOriginalBindingsIfStillCloseToArrowEnds(element, app),
|
||||
getOriginalBindingsIfStillCloseToArrowEnds(element, elementsMap),
|
||||
)
|
||||
.filter(
|
||||
(element): element is NonDeleted<ExcalidrawBindableElement> =>
|
||||
|
@ -326,17 +342,20 @@ export const maybeBindLinearElement = (
|
|||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
appState: AppState,
|
||||
pointerCoords: { x: number; y: number },
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): void => {
|
||||
if (appState.startBoundElement != null) {
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
appState.startBoundElement,
|
||||
"start",
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
elementsMap,
|
||||
);
|
||||
}
|
||||
const hoveredElement = getHoveredElementForBinding(pointerCoords, app);
|
||||
const hoveredElement = getHoveredElementForBinding(
|
||||
pointerCoords,
|
||||
elementsMap,
|
||||
);
|
||||
if (
|
||||
hoveredElement != null &&
|
||||
!isLinearElementSimpleAndAlreadyBoundOnOppositeEdge(
|
||||
|
@ -345,12 +364,7 @@ export const maybeBindLinearElement = (
|
|||
"end",
|
||||
)
|
||||
) {
|
||||
bindLinearElement(
|
||||
linearElement,
|
||||
hoveredElement,
|
||||
"end",
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
bindLinearElement(linearElement, hoveredElement, "end", elementsMap);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -360,6 +374,9 @@ export const bindLinearElement = (
|
|||
startOrEnd: "start" | "end",
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): void => {
|
||||
if (!isArrowElement(linearElement)) {
|
||||
return;
|
||||
}
|
||||
mutateElement(linearElement, {
|
||||
[startOrEnd === "start" ? "startBinding" : "endBinding"]: {
|
||||
elementId: hoveredElement.id,
|
||||
|
@ -431,13 +448,13 @@ export const getHoveredElementForBinding = (
|
|||
x: number;
|
||||
y: number;
|
||||
},
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
const hoveredElement = getElementAtPosition(
|
||||
app.scene.getNonDeletedElements(),
|
||||
[...elementsMap].map(([_, value]) => value),
|
||||
(element) =>
|
||||
isBindableElement(element, false) &&
|
||||
bindingBorderTest(element, pointerCoords, app),
|
||||
bindingBorderTest(element, pointerCoords, elementsMap),
|
||||
);
|
||||
return hoveredElement as NonDeleted<ExcalidrawBindableElement> | null;
|
||||
};
|
||||
|
@ -661,15 +678,11 @@ const maybeCalculateNewGapWhenScaling = (
|
|||
const getElligibleElementForBindingElement = (
|
||||
linearElement: NonDeleted<ExcalidrawLinearElement>,
|
||||
startOrEnd: "start" | "end",
|
||||
app: AppClassProperties,
|
||||
elementsMap: NonDeletedSceneElementsMap,
|
||||
): NonDeleted<ExcalidrawBindableElement> | null => {
|
||||
return getHoveredElementForBinding(
|
||||
getLinearElementEdgeCoors(
|
||||
linearElement,
|
||||
startOrEnd,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
app,
|
||||
getLinearElementEdgeCoors(linearElement, startOrEnd, elementsMap),
|
||||
elementsMap,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -706,6 +719,9 @@ export const fixBindingsAfterDuplication = (
|
|||
const allBoundElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const allBindableElementIds: Set<ExcalidrawElement["id"]> = new Set();
|
||||
const shouldReverseRoles = duplicatesServeAsOld === "duplicatesServeAsOld";
|
||||
const duplicateIdToOldId = new Map(
|
||||
[...oldIdToDuplicatedId].map(([key, value]) => [value, key]),
|
||||
);
|
||||
oldElements.forEach((oldElement) => {
|
||||
const { boundElements } = oldElement;
|
||||
if (boundElements != null && boundElements.length > 0) {
|
||||
|
@ -755,7 +771,11 @@ export const fixBindingsAfterDuplication = (
|
|||
sceneElements
|
||||
.filter(({ id }) => allBindableElementIds.has(id))
|
||||
.forEach((bindableElement) => {
|
||||
const { boundElements } = bindableElement;
|
||||
const oldElementId = duplicateIdToOldId.get(bindableElement.id);
|
||||
const { boundElements } = sceneElements.find(
|
||||
({ id }) => id === oldElementId,
|
||||
)!;
|
||||
|
||||
if (boundElements != null && boundElements.length > 0) {
|
||||
mutateElement(bindableElement, {
|
||||
boundElements: boundElements.map((boundElement) =>
|
||||
|
@ -826,10 +846,10 @@ const newBoundElements = (
|
|||
const bindingBorderTest = (
|
||||
element: NonDeleted<ExcalidrawBindableElement>,
|
||||
{ x, y }: { x: number; y: number },
|
||||
app: AppClassProperties,
|
||||
elementsMap: ElementsMap,
|
||||
): boolean => {
|
||||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const shape = app.getElementShape(element);
|
||||
const shape = getElementShape(element, elementsMap);
|
||||
return isPointOnShape([x, y], shape, threshold);
|
||||
};
|
||||
|
||||
|
|
|
@ -4,11 +4,17 @@ import { getCommonBounds } from "./bounds";
|
|||
import { mutateElement } from "./mutateElement";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import type { NonDeletedExcalidrawElement } from "./types";
|
||||
import type { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import type { AppState, NormalizedZoomValue, PointerDownState } from "../types";
|
||||
import { getBoundTextElement, getMinTextElementWidth } from "./textElement";
|
||||
import { getGridPoint } from "../math";
|
||||
import type Scene from "../scene/Scene";
|
||||
import { isArrowElement, isFrameLikeElement } from "./typeChecks";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFrameLikeElement,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { getFontString } from "../utils";
|
||||
import { TEXT_AUTOWRAP_THRESHOLD } from "../constants";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
|
@ -140,6 +146,7 @@ export const dragNewElement = (
|
|||
height: number,
|
||||
shouldMaintainAspectRatio: boolean,
|
||||
shouldResizeFromCenter: boolean,
|
||||
zoom: NormalizedZoomValue,
|
||||
/** whether to keep given aspect ratio when `isResizeWithSidesSameLength` is
|
||||
true */
|
||||
widthAspectRatio?: number | null,
|
||||
|
@ -185,12 +192,41 @@ export const dragNewElement = (
|
|||
newY = originY - height / 2;
|
||||
}
|
||||
|
||||
let textAutoResize = null;
|
||||
|
||||
// NOTE this should apply only to creating text elements, not existing
|
||||
// (once we rewrite appState.draggingElement to actually mean dragging
|
||||
// elements)
|
||||
if (isTextElement(draggingElement)) {
|
||||
height = draggingElement.height;
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: draggingElement.fontSize,
|
||||
fontFamily: draggingElement.fontFamily,
|
||||
}),
|
||||
draggingElement.lineHeight,
|
||||
);
|
||||
width = Math.max(width, minWidth);
|
||||
|
||||
if (Math.abs(x - originX) > TEXT_AUTOWRAP_THRESHOLD / zoom) {
|
||||
textAutoResize = {
|
||||
autoResize: false,
|
||||
};
|
||||
}
|
||||
|
||||
newY = originY;
|
||||
if (shouldResizeFromCenter) {
|
||||
newX = originX - width / 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (width !== 0 && height !== 0) {
|
||||
mutateElement(draggingElement, {
|
||||
x: newX + (originOffset?.x ?? 0),
|
||||
y: newY + (originOffset?.y ?? 0),
|
||||
width,
|
||||
height,
|
||||
...textAutoResize,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks";
|
|||
export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newImageElement,
|
||||
|
|
|
@ -381,7 +381,7 @@ export class LinearElementEditor {
|
|||
elementsMap,
|
||||
),
|
||||
),
|
||||
app,
|
||||
elementsMap,
|
||||
)
|
||||
: null;
|
||||
|
||||
|
@ -715,7 +715,10 @@ export class LinearElementEditor {
|
|||
},
|
||||
selectedPointsIndices: [element.points.length - 1],
|
||||
lastUncommittedPoint: null,
|
||||
endBindingElement: getHoveredElementForBinding(scenePointer, app),
|
||||
endBindingElement: getHoveredElementForBinding(
|
||||
scenePointer,
|
||||
elementsMap,
|
||||
),
|
||||
};
|
||||
|
||||
ret.didAddPoint = true;
|
||||
|
@ -1165,7 +1168,7 @@ export class LinearElementEditor {
|
|||
const nextPoints = points.map((point, idx) => {
|
||||
const selectedPointData = targetPoints.find((p) => p.index === idx);
|
||||
if (selectedPointData) {
|
||||
if (selectedOriginPoint) {
|
||||
if (selectedPointData.index === 0) {
|
||||
return point;
|
||||
}
|
||||
|
||||
|
@ -1174,7 +1177,10 @@ export class LinearElementEditor {
|
|||
const deltaY =
|
||||
selectedPointData.point[1] - points[selectedPointData.index][1];
|
||||
|
||||
return [point[0] + deltaX, point[1] + deltaY] as const;
|
||||
return [
|
||||
point[0] + deltaX - offsetX,
|
||||
point[1] + deltaY - offsetY,
|
||||
] as const;
|
||||
}
|
||||
return offsetX || offsetY
|
||||
? ([point[0] - offsetX, point[1] - offsetY] as const)
|
||||
|
|
|
@ -115,7 +115,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||
}
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
Scene.getScene(element)?.triggerUpdate();
|
||||
}
|
||||
|
||||
return element;
|
||||
|
@ -124,6 +124,8 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
|||
export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
element: TElement,
|
||||
updates: ElementUpdate<TElement>,
|
||||
/** pass `true` to always regenerate */
|
||||
force = false,
|
||||
): TElement => {
|
||||
let didChange = false;
|
||||
let increment = false;
|
||||
|
@ -143,7 +145,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
|||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
if (!didChange && !force) {
|
||||
return element;
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,6 @@ import {
|
|||
normalizeText,
|
||||
wrapTextElement,
|
||||
getBoundTextMaxWidth,
|
||||
getDefaultLineHeight,
|
||||
} from "./textElement";
|
||||
import {
|
||||
DEFAULT_ELEMENT_PROPS,
|
||||
|
@ -42,6 +41,7 @@ import {
|
|||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import type { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { getSubtypeMethods, isValidSubtype } from "./subtypes";
|
||||
|
||||
export const maybeGetSubtypeProps = (
|
||||
|
@ -241,6 +241,7 @@ const getTextElementPositionOffsets = (
|
|||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
originalText?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
|
@ -248,11 +249,12 @@ export const newTextElement = (
|
|||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
autoResize?: ExcalidrawTextElement["autoResize"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
const fontSize = opts.fontSize || DEFAULT_FONT_SIZE;
|
||||
const lineHeight = opts.lineHeight || getDefaultLineHeight(fontFamily);
|
||||
const lineHeight = opts.lineHeight || getLineHeight(fontFamily);
|
||||
const text = normalizeText(opts.text);
|
||||
const metrics = measureTextElement(
|
||||
{ ...opts, fontSize, fontFamily, lineHeight },
|
||||
|
@ -268,24 +270,28 @@ export const newTextElement = (
|
|||
metrics,
|
||||
);
|
||||
|
||||
const textElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
x: opts.x - offsets.x,
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
},
|
||||
const textElementProps: ExcalidrawTextElement = {
|
||||
..._newElementBase<ExcalidrawTextElement>("text", opts),
|
||||
text,
|
||||
fontSize,
|
||||
fontFamily,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
x: opts.x - offsets.x,
|
||||
y: opts.y - offsets.y,
|
||||
width: metrics.width,
|
||||
height: metrics.height,
|
||||
containerId: opts.containerId || null,
|
||||
originalText: opts.originalText ?? text,
|
||||
autoResize: opts.autoResize ?? true,
|
||||
lineHeight,
|
||||
};
|
||||
|
||||
const textElement: ExcalidrawTextElement = newElementWith(
|
||||
textElementProps,
|
||||
{},
|
||||
);
|
||||
|
||||
return textElement;
|
||||
};
|
||||
|
||||
|
@ -299,16 +305,23 @@ const getAdjustedDimensions = (
|
|||
width: number;
|
||||
height: number;
|
||||
} => {
|
||||
const { width: nextWidth, height: nextHeight } = measureTextElement(element, {
|
||||
let { width: nextWidth, height: nextHeight } = measureTextElement(element, {
|
||||
text: nextText,
|
||||
});
|
||||
|
||||
// wrapped text
|
||||
if (!element.autoResize) {
|
||||
nextWidth = element.width;
|
||||
}
|
||||
|
||||
const { textAlign, verticalAlign } = element;
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (
|
||||
textAlign === "center" &&
|
||||
verticalAlign === VERTICAL_ALIGN.MIDDLE &&
|
||||
!element.containerId
|
||||
!element.containerId &&
|
||||
element.autoResize
|
||||
) {
|
||||
const prevMetrics = measureTextElement(element);
|
||||
const offsets = getTextElementPositionOffsets(element, {
|
||||
|
@ -365,10 +378,12 @@ export const refreshTextDimensions = (
|
|||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
if (container) {
|
||||
if (container || !textElement.autoResize) {
|
||||
text = wrapTextElement(
|
||||
textElement,
|
||||
getBoundTextMaxWidth(container, textElement),
|
||||
container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width,
|
||||
{
|
||||
text,
|
||||
},
|
||||
|
@ -378,27 +393,6 @@ export const refreshTextDimensions = (
|
|||
return { text, ...dimensions };
|
||||
};
|
||||
|
||||
export const updateTextElement = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawTextContainer | null,
|
||||
elementsMap: ElementsMap,
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
originalText,
|
||||
}: {
|
||||
text: string;
|
||||
isDeleted?: boolean;
|
||||
originalText: string;
|
||||
},
|
||||
): ExcalidrawTextElement => {
|
||||
return newElementWith(textElement, {
|
||||
originalText,
|
||||
isDeleted: isDeleted ?? textElement.isDeleted,
|
||||
...refreshTextDimensions(textElement, container, elementsMap, originalText),
|
||||
});
|
||||
};
|
||||
|
||||
export const newFreeDrawElement = (
|
||||
opts: {
|
||||
type: "freedraw";
|
||||
|
@ -550,7 +544,7 @@ export const regenerateId = (
|
|||
if (
|
||||
window.h?.app
|
||||
?.getSceneElementsIncludingDeleted()
|
||||
.find((el) => el.id === nextId)
|
||||
.find((el: ExcalidrawElement) => el.id === nextId)
|
||||
) {
|
||||
nextId += "_copy";
|
||||
}
|
||||
|
|
|
@ -45,6 +45,9 @@ import {
|
|||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
getApproxMinLineHeight,
|
||||
wrapText,
|
||||
measureText,
|
||||
getMinTextElementWidth,
|
||||
} from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
|
@ -84,14 +87,9 @@ export const transformElements = (
|
|||
shouldRotateWithDiscreteAngle,
|
||||
);
|
||||
updateBoundElements(element, elementsMap);
|
||||
} else if (
|
||||
isTextElement(element) &&
|
||||
(transformHandleType === "nw" ||
|
||||
transformHandleType === "ne" ||
|
||||
transformHandleType === "sw" ||
|
||||
transformHandleType === "se")
|
||||
) {
|
||||
} else if (isTextElement(element) && transformHandleType) {
|
||||
resizeSingleTextElement(
|
||||
originalElements,
|
||||
element,
|
||||
elementsMap,
|
||||
transformHandleType,
|
||||
|
@ -180,7 +178,7 @@ const rotateSingleElement = (
|
|||
}
|
||||
};
|
||||
|
||||
const rescalePointsInElement = (
|
||||
export const rescalePointsInElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
|
@ -197,7 +195,7 @@ const rescalePointsInElement = (
|
|||
}
|
||||
: {};
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
export const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
elementsMap: ElementsMap,
|
||||
nextWidth: number,
|
||||
|
@ -223,9 +221,10 @@ const measureFontSizeFromWidth = (
|
|||
};
|
||||
|
||||
const resizeSingleTextElement = (
|
||||
originalElements: PointerDownState["originalElements"],
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
elementsMap: ElementsMap,
|
||||
transformHandleType: "nw" | "ne" | "sw" | "se",
|
||||
transformHandleType: TransformHandleDirection,
|
||||
shouldResizeFromCenter: boolean,
|
||||
pointerX: number,
|
||||
pointerY: number,
|
||||
|
@ -245,17 +244,19 @@ const resizeSingleTextElement = (
|
|||
let scaleX = 0;
|
||||
let scaleY = 0;
|
||||
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("n")) {
|
||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||
}
|
||||
if (transformHandleType.includes("s")) {
|
||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||
if (transformHandleType !== "e" && transformHandleType !== "w") {
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedX - x1) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (x2 - rotatedX) / (x2 - x1);
|
||||
}
|
||||
if (transformHandleType.includes("n")) {
|
||||
scaleY = (y2 - rotatedY) / (y2 - y1);
|
||||
}
|
||||
if (transformHandleType.includes("s")) {
|
||||
scaleY = (rotatedY - y1) / (y2 - y1);
|
||||
}
|
||||
}
|
||||
|
||||
const scale = Math.max(scaleX, scaleY);
|
||||
|
@ -318,6 +319,107 @@ const resizeSingleTextElement = (
|
|||
y: nextY,
|
||||
});
|
||||
}
|
||||
|
||||
if (transformHandleType === "e" || transformHandleType === "w") {
|
||||
const stateAtResizeStart = originalElements.get(element.id)!;
|
||||
const [x1, y1, x2, y2] = getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
stateAtResizeStart.width,
|
||||
stateAtResizeStart.height,
|
||||
true,
|
||||
);
|
||||
const startTopLeft: Point = [x1, y1];
|
||||
const startBottomRight: Point = [x2, y2];
|
||||
const startCenter: Point = centerPoint(startTopLeft, startBottomRight);
|
||||
|
||||
const rotatedPointer = rotatePoint(
|
||||
[pointerX, pointerY],
|
||||
startCenter,
|
||||
-stateAtResizeStart.angle,
|
||||
);
|
||||
|
||||
const [esx1, , esx2] = getResizedElementAbsoluteCoords(
|
||||
element,
|
||||
element.width,
|
||||
element.height,
|
||||
true,
|
||||
);
|
||||
|
||||
const boundsCurrentWidth = esx2 - esx1;
|
||||
|
||||
const atStartBoundsWidth = startBottomRight[0] - startTopLeft[0];
|
||||
const minWidth = getMinTextElementWidth(
|
||||
getFontString({
|
||||
fontSize: element.fontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
}),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
let scaleX = atStartBoundsWidth / boundsCurrentWidth;
|
||||
|
||||
if (transformHandleType.includes("e")) {
|
||||
scaleX = (rotatedPointer[0] - startTopLeft[0]) / boundsCurrentWidth;
|
||||
}
|
||||
if (transformHandleType.includes("w")) {
|
||||
scaleX = (startBottomRight[0] - rotatedPointer[0]) / boundsCurrentWidth;
|
||||
}
|
||||
|
||||
const newWidth =
|
||||
element.width * scaleX < minWidth ? minWidth : element.width * scaleX;
|
||||
|
||||
const text = wrapText(
|
||||
element.originalText,
|
||||
getFontString(element),
|
||||
Math.abs(newWidth),
|
||||
);
|
||||
const metrics = measureText(
|
||||
text,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
const eleNewHeight = metrics.height;
|
||||
|
||||
const [newBoundsX1, newBoundsY1, newBoundsX2, newBoundsY2] =
|
||||
getResizedElementAbsoluteCoords(
|
||||
stateAtResizeStart,
|
||||
newWidth,
|
||||
eleNewHeight,
|
||||
true,
|
||||
);
|
||||
const newBoundsWidth = newBoundsX2 - newBoundsX1;
|
||||
const newBoundsHeight = newBoundsY2 - newBoundsY1;
|
||||
|
||||
let newTopLeft = [...startTopLeft] as [number, number];
|
||||
if (["n", "w", "nw"].includes(transformHandleType)) {
|
||||
newTopLeft = [
|
||||
startBottomRight[0] - Math.abs(newBoundsWidth),
|
||||
startTopLeft[1],
|
||||
];
|
||||
}
|
||||
|
||||
// adjust topLeft to new rotation point
|
||||
const angle = stateAtResizeStart.angle;
|
||||
const rotatedTopLeft = rotatePoint(newTopLeft, startCenter, angle);
|
||||
const newCenter: Point = [
|
||||
newTopLeft[0] + Math.abs(newBoundsWidth) / 2,
|
||||
newTopLeft[1] + Math.abs(newBoundsHeight) / 2,
|
||||
];
|
||||
const rotatedNewCenter = rotatePoint(newCenter, startCenter, angle);
|
||||
newTopLeft = rotatePoint(rotatedTopLeft, rotatedNewCenter, -angle);
|
||||
|
||||
const resizedElement: Partial<ExcalidrawTextElement> = {
|
||||
width: Math.abs(newWidth),
|
||||
height: Math.abs(metrics.height),
|
||||
x: newTopLeft[0],
|
||||
y: newTopLeft[1],
|
||||
text,
|
||||
autoResize: false,
|
||||
};
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
}
|
||||
};
|
||||
|
||||
export const resizeSingleElement = (
|
||||
|
@ -876,7 +978,7 @@ export const resizeMultipleElements = (
|
|||
}
|
||||
}
|
||||
|
||||
Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
|
||||
Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate();
|
||||
};
|
||||
|
||||
const rotateMultipleElements = (
|
||||
|
@ -938,7 +1040,7 @@ const rotateMultipleElements = (
|
|||
}
|
||||
});
|
||||
|
||||
Scene.getScene(elements[0])?.informMutation();
|
||||
Scene.getScene(elements[0])?.triggerUpdate();
|
||||
};
|
||||
|
||||
export const getResizeOffsetXY = (
|
||||
|
|
|
@ -87,12 +87,8 @@ export const resizeTest = (
|
|||
elementsMap,
|
||||
);
|
||||
|
||||
// Note that for a text element, when "resized" from the side
|
||||
// we should make it wrap/unwrap
|
||||
if (
|
||||
element.type !== "text" &&
|
||||
!(isLinearElement(element) && element.points.length <= 2)
|
||||
) {
|
||||
// do not resize from the sides for linear elements with only two points
|
||||
if (!(isLinearElement(element) && element.points.length <= 2)) {
|
||||
const SPACING = SIDE_RESIZING_THRESHOLD / zoom.value;
|
||||
const sides = getSelectionBorders(
|
||||
[x1 - SPACING, y1 - SPACING],
|
||||
|
|
|
@ -28,7 +28,6 @@ import {
|
|||
} from "../textElement";
|
||||
import { ShapeCache } from "../../scene/ShapeCache";
|
||||
import Scene from "../../scene/Scene";
|
||||
import type { RenderableElementsMap } from "../../scene/types";
|
||||
|
||||
// Use "let" instead of "const" so we can dynamically add subtypes
|
||||
let subtypeNames: readonly Subtype[] = [];
|
||||
|
@ -267,14 +266,14 @@ export type SubtypeMethods = {
|
|||
) => { width: number; height: number };
|
||||
render: (
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
elementsMap: RenderableElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
context: CanvasRenderingContext2D,
|
||||
) => void;
|
||||
renderSvg: (
|
||||
svgRoot: SVGElement,
|
||||
addToRoot: (node: SVGElement, element: ExcalidrawElement) => void,
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
elementsMap: RenderableElementsMap,
|
||||
elementsMap: ElementsMap,
|
||||
opt?: { offsetX?: number; offsetY?: number },
|
||||
) => void;
|
||||
wrapText: (
|
||||
|
@ -507,6 +506,7 @@ export const checkRefreshOnSubtypeLoad = (
|
|||
element,
|
||||
getContainerElement(element, elementsMap),
|
||||
elementsMap,
|
||||
false,
|
||||
);
|
||||
}
|
||||
refreshNeeded = true;
|
||||
|
@ -518,7 +518,7 @@ export const checkRefreshOnSubtypeLoad = (
|
|||
}
|
||||
});
|
||||
// Only inform each scene once
|
||||
scenes.forEach((scene) => scene.informMutation());
|
||||
scenes.forEach((scene) => scene.triggerUpdate());
|
||||
return refreshNeeded;
|
||||
};
|
||||
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
getBoundTextElement,
|
||||
getBoundTextMaxWidth,
|
||||
getContainerElement,
|
||||
getDefaultLineHeight,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
wrapText,
|
||||
|
@ -20,6 +19,7 @@ import type {
|
|||
ExcalidrawTextElement,
|
||||
NonDeleted,
|
||||
} from "../../../element/types";
|
||||
import { getLineHeight } from "../../../fonts";
|
||||
import { newElementWith } from "../../../element/mutateElement";
|
||||
import { getElementAbsoluteCoords } from "../../../element/bounds";
|
||||
import Scene from "../../../scene/Scene";
|
||||
|
@ -904,7 +904,7 @@ const cleanMathElementUpdate = function (updates) {
|
|||
}
|
||||
}
|
||||
(updates as any).fontFamily = FONT_FAMILY_MATH;
|
||||
(updates as any).lineHeight = getDefaultLineHeight(FONT_FAMILY_MATH);
|
||||
(updates as any).lineHeight = getLineHeight(FONT_FAMILY_MATH);
|
||||
return oldUpdates;
|
||||
} as SubtypeMethods["clean"];
|
||||
|
||||
|
@ -1025,7 +1025,7 @@ const renderMathElement = function (element, elementMap, context) {
|
|||
if (isMathJaxLoaded) {
|
||||
imageCache[imgKey] = img;
|
||||
}
|
||||
Scene.getScene(element)?.informMutation();
|
||||
Scene.getScene(element)?.triggerUpdate();
|
||||
};
|
||||
img.src = reader.result as string;
|
||||
},
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { BOUND_TEXT_PADDING, FONT_FAMILY } from "../constants";
|
||||
import { getLineHeight } from "../fonts";
|
||||
import { API } from "../tests/helpers/api";
|
||||
import {
|
||||
computeContainerDimensionForBoundText,
|
||||
|
@ -8,7 +9,6 @@ import {
|
|||
wrapText,
|
||||
detectLineHeight,
|
||||
getLineHeightInPx,
|
||||
getDefaultLineHeight,
|
||||
parseTokens,
|
||||
} from "./textElement";
|
||||
import type { ExcalidrawTextElementWithContainer, FontString } from "./types";
|
||||
|
@ -418,15 +418,15 @@ describe("Test getLineHeightInPx", () => {
|
|||
describe("Test getDefaultLineHeight", () => {
|
||||
it("should return line height using default font family when not passed", () => {
|
||||
//@ts-ignore
|
||||
expect(getDefaultLineHeight()).toBe(1.25);
|
||||
expect(getLineHeight()).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return line height using default font family for unknown font", () => {
|
||||
const UNKNOWN_FONT = 5;
|
||||
expect(getDefaultLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
expect(getLineHeight(UNKNOWN_FONT)).toBe(1.25);
|
||||
});
|
||||
|
||||
it("should return correct line height", () => {
|
||||
expect(getDefaultLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
expect(getLineHeight(FONT_FAMILY.Cascadia)).toBe(1.2);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,7 +8,6 @@ import type {
|
|||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
FontFamilyValues,
|
||||
FontString,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
@ -19,7 +18,6 @@ import {
|
|||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
FONT_FAMILY,
|
||||
TEXT_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
|
@ -32,7 +30,7 @@ import {
|
|||
resetOriginalContainerCache,
|
||||
updateOriginalContainerCache,
|
||||
} from "./containerCache";
|
||||
import type { ExtractSetType, MakeBrand } from "../utility-types";
|
||||
import type { ExtractSetType } from "../utility-types";
|
||||
|
||||
export const measureTextElement = function (element, next) {
|
||||
const map = getSubtypeMethods(element.subtype);
|
||||
|
@ -74,7 +72,7 @@ export const redrawTextBoundingBox = (
|
|||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
informMutation: boolean = true,
|
||||
informMutation = true,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
const boundTextUpdates = {
|
||||
|
@ -88,15 +86,21 @@ export const redrawTextBoundingBox = (
|
|||
|
||||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
if (container || !textElement.autoResize) {
|
||||
maxWidth = container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width;
|
||||
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
|
||||
const metrics = measureTextElement(textElement, {
|
||||
text: boundTextUpdates.text,
|
||||
});
|
||||
|
||||
boundTextUpdates.width = metrics.width;
|
||||
// Note: only update width for unwrapped text and bound texts (which always have autoResize set to true)
|
||||
if (textElement.autoResize) {
|
||||
boundTextUpdates.width = metrics.width;
|
||||
}
|
||||
boundTextUpdates.height = metrics.height;
|
||||
|
||||
// Maintain coordX for non left-aligned text in case the width has changed
|
||||
|
@ -335,24 +339,6 @@ export const getLineHeightInPx = (
|
|||
return fontSize * lineHeight;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates vertical offset for a text with alphabetic baseline.
|
||||
*/
|
||||
export const getVerticalOffset = (
|
||||
fontFamily: ExcalidrawTextElement["fontFamily"],
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
lineHeightPx: number,
|
||||
) => {
|
||||
const { unitsPerEm, ascender, descender } =
|
||||
FONT_METRICS[fontFamily] || FONT_METRICS[FONT_FAMILY.Helvetica];
|
||||
|
||||
const fontSizeEm = fontSize / unitsPerEm;
|
||||
const lineGap = lineHeightPx - fontSizeEm * ascender + fontSizeEm * descender;
|
||||
|
||||
const verticalOffset = fontSizeEm * ascender + lineGap;
|
||||
return verticalOffset;
|
||||
};
|
||||
|
||||
// FIXME rename to getApproxMinContainerHeight
|
||||
export const getApproxMinLineHeight = (
|
||||
fontSize: ExcalidrawTextElement["fontSize"],
|
||||
|
@ -363,29 +349,72 @@ export const getApproxMinLineHeight = (
|
|||
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
|
||||
const getLineWidth = (text: string, font: FontString) => {
|
||||
/**
|
||||
* @param forceAdvanceWidth use to force retrieve the "advance width" ~ `metrics.width`, instead of the actual boundind box width.
|
||||
*
|
||||
* > The advance width is the distance between the glyph's initial pen position and the next glyph's initial pen position.
|
||||
*
|
||||
* We need to use the advance width as that's the closest thing to the browser wrapping algo, hence using it for:
|
||||
* - text wrapping
|
||||
* - wysiwyg editor (+padding)
|
||||
*
|
||||
* Everything else should be based on the actual bounding box width.
|
||||
*
|
||||
* `Math.ceil` of the final width adds additional buffer which stabilizes slight wrapping incosistencies.
|
||||
*/
|
||||
const getLineWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
if (!canvas) {
|
||||
canvas = document.createElement("canvas");
|
||||
}
|
||||
const canvas2dContext = canvas.getContext("2d")!;
|
||||
canvas2dContext.font = font;
|
||||
const width = canvas2dContext.measureText(text).width;
|
||||
const metrics = canvas2dContext.measureText(text);
|
||||
|
||||
const advanceWidth = metrics.width;
|
||||
|
||||
// retrieve the actual bounding box width if these metrics are available (as of now > 95% coverage)
|
||||
if (
|
||||
!forceAdvanceWidth &&
|
||||
window.TextMetrics &&
|
||||
"actualBoundingBoxLeft" in window.TextMetrics.prototype &&
|
||||
"actualBoundingBoxRight" in window.TextMetrics.prototype
|
||||
) {
|
||||
// could be negative, therefore getting the absolute value
|
||||
const actualWidth =
|
||||
Math.abs(metrics.actualBoundingBoxLeft) +
|
||||
Math.abs(metrics.actualBoundingBoxRight);
|
||||
|
||||
// fallback to advance width if the actual width is zero, i.e. on text editing start
|
||||
// or when actual width does not respect whitespace chars, i.e. spaces
|
||||
// otherwise actual width should always be bigger
|
||||
return Math.max(actualWidth, advanceWidth);
|
||||
}
|
||||
|
||||
// since in test env the canvas measureText algo
|
||||
// doesn't measure text and instead just returns number of
|
||||
// characters hence we assume that each letteris 10px
|
||||
if (isTestEnv()) {
|
||||
return width * 10;
|
||||
return advanceWidth * 10;
|
||||
}
|
||||
return width;
|
||||
|
||||
return advanceWidth;
|
||||
};
|
||||
|
||||
export const getTextWidth = (text: string, font: FontString) => {
|
||||
export const getTextWidth = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
forceAdvanceWidth?: true,
|
||||
) => {
|
||||
const lines = splitIntoLines(text);
|
||||
let width = 0;
|
||||
lines.forEach((line) => {
|
||||
width = Math.max(width, getLineWidth(line, font));
|
||||
width = Math.max(width, getLineWidth(line, font, forceAdvanceWidth));
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
|
||||
|
@ -416,7 +445,11 @@ export const parseTokens = (text: string) => {
|
|||
return words.join(" ").split(" ");
|
||||
};
|
||||
|
||||
export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
||||
export const wrapText = (
|
||||
text: string,
|
||||
font: FontString,
|
||||
maxWidth: number,
|
||||
): string => {
|
||||
// if maxWidth is not finite or NaN which can happen in case of bugs in
|
||||
// computation, we need to make sure we don't continue as we'll end up
|
||||
// in an infinite loop
|
||||
|
@ -426,7 +459,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
|
||||
const lines: Array<string> = [];
|
||||
const originalLines = text.split("\n");
|
||||
const spaceWidth = getLineWidth(" ", font);
|
||||
const spaceAdvanceWidth = getLineWidth(" ", font, true);
|
||||
|
||||
let currentLine = "";
|
||||
let currentLineWidthTillNow = 0;
|
||||
|
@ -441,13 +474,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
currentLine = "";
|
||||
currentLineWidthTillNow = 0;
|
||||
};
|
||||
originalLines.forEach((originalLine) => {
|
||||
const currentLineWidth = getTextWidth(originalLine, font);
|
||||
|
||||
for (const originalLine of originalLines) {
|
||||
const currentLineWidth = getLineWidth(originalLine, font, true);
|
||||
|
||||
// Push the line if its <= maxWidth
|
||||
if (currentLineWidth <= maxWidth) {
|
||||
lines.push(originalLine);
|
||||
return; // continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const words = parseTokens(originalLine);
|
||||
|
@ -456,7 +490,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
let index = 0;
|
||||
|
||||
while (index < words.length) {
|
||||
const currentWordWidth = getLineWidth(words[index], font);
|
||||
const currentWordWidth = getLineWidth(words[index], font, true);
|
||||
|
||||
// This will only happen when single word takes entire width
|
||||
if (currentWordWidth === maxWidth) {
|
||||
|
@ -468,7 +502,6 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
else if (currentWordWidth > maxWidth) {
|
||||
// push current line since the current word exceeds the max width
|
||||
// so will be appended in next line
|
||||
|
||||
push(currentLine);
|
||||
|
||||
resetParams();
|
||||
|
@ -477,20 +510,26 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
const currentChar = String.fromCodePoint(
|
||||
words[index].codePointAt(0)!,
|
||||
);
|
||||
const width = charWidth.calculate(currentChar, font);
|
||||
currentLineWidthTillNow += width;
|
||||
|
||||
const line = currentLine + currentChar;
|
||||
// use advance width instead of the actual width as it's closest to the browser wapping algo
|
||||
// use width of the whole line instead of calculating individual chars to accomodate for kerning
|
||||
const lineAdvanceWidth = getLineWidth(line, font, true);
|
||||
const charAdvanceWidth = charWidth.calculate(currentChar, font);
|
||||
|
||||
currentLineWidthTillNow = lineAdvanceWidth;
|
||||
words[index] = words[index].slice(currentChar.length);
|
||||
|
||||
if (currentLineWidthTillNow >= maxWidth) {
|
||||
push(currentLine);
|
||||
currentLine = currentChar;
|
||||
currentLineWidthTillNow = width;
|
||||
currentLineWidthTillNow = charAdvanceWidth;
|
||||
} else {
|
||||
currentLine += currentChar;
|
||||
currentLine = line;
|
||||
}
|
||||
}
|
||||
// push current line if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
|
||||
push(currentLine);
|
||||
resetParams();
|
||||
// space needs to be appended before next word
|
||||
|
@ -499,14 +538,18 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
// with css word-wrap
|
||||
} else if (!currentLine.endsWith("-")) {
|
||||
currentLine += " ";
|
||||
currentLineWidthTillNow += spaceWidth;
|
||||
currentLineWidthTillNow += spaceAdvanceWidth;
|
||||
}
|
||||
index++;
|
||||
} else {
|
||||
// Start appending words in a line till max width reached
|
||||
while (currentLineWidthTillNow < maxWidth && index < words.length) {
|
||||
const word = words[index];
|
||||
currentLineWidthTillNow = getLineWidth(currentLine + word, font);
|
||||
currentLineWidthTillNow = getLineWidth(
|
||||
currentLine + word,
|
||||
font,
|
||||
true,
|
||||
);
|
||||
|
||||
if (currentLineWidthTillNow > maxWidth) {
|
||||
push(currentLine);
|
||||
|
@ -526,7 +569,7 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
}
|
||||
|
||||
// Push the word if appending space exceeds max width
|
||||
if (currentLineWidthTillNow + spaceWidth >= maxWidth) {
|
||||
if (currentLineWidthTillNow + spaceAdvanceWidth >= maxWidth) {
|
||||
if (shouldAppendSpace) {
|
||||
lines.push(currentLine.slice(0, -1));
|
||||
} else {
|
||||
|
@ -538,12 +581,14 @@ export const wrapText = (text: string, font: FontString, maxWidth: number) => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLine.slice(-1) === " ") {
|
||||
// only remove last trailing space which we have added when joining words
|
||||
currentLine = currentLine.slice(0, -1);
|
||||
push(currentLine);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
|
@ -556,7 +601,7 @@ export const charWidth = (() => {
|
|||
cachedCharWidth[font] = [];
|
||||
}
|
||||
if (!cachedCharWidth[font][ascii]) {
|
||||
const width = getLineWidth(char, font);
|
||||
const width = getLineWidth(char, font, true);
|
||||
cachedCharWidth[font][ascii] = width;
|
||||
}
|
||||
|
||||
|
@ -608,30 +653,6 @@ export const getMaxCharWidth = (font: FontString) => {
|
|||
return Math.max(...cacheWithOutEmpty);
|
||||
};
|
||||
|
||||
export const getApproxCharsToFitInWidth = (font: FontString, width: number) => {
|
||||
// Generally lower case is used so converting to lower case
|
||||
const dummyText = DUMMY_TEXT.toLocaleLowerCase();
|
||||
const batchLength = 6;
|
||||
let index = 0;
|
||||
let widthTillNow = 0;
|
||||
let str = "";
|
||||
while (widthTillNow <= width) {
|
||||
const batch = dummyText.substr(index, index + batchLength);
|
||||
str += batch;
|
||||
widthTillNow += getLineWidth(str, font);
|
||||
if (index === dummyText.length - 1) {
|
||||
index = 0;
|
||||
}
|
||||
index = index + batchLength;
|
||||
}
|
||||
|
||||
while (widthTillNow > width) {
|
||||
str = str.substr(0, str.length - 1);
|
||||
widthTillNow = getLineWidth(str, font);
|
||||
}
|
||||
return str.length;
|
||||
};
|
||||
|
||||
export const getBoundTextElementId = (container: ExcalidrawElement | null) => {
|
||||
return container?.boundElements?.length
|
||||
? container?.boundElements?.filter((ele) => ele.type === "text")[0]?.id ||
|
||||
|
@ -880,75 +901,9 @@ export const isMeasureTextSupported = () => {
|
|||
return width > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unitless line height
|
||||
*
|
||||
* In previous versions we used `normal` line height, which browsers interpret
|
||||
* differently, and based on font-family and font-size.
|
||||
*
|
||||
* To make line heights consistent across browsers we hardcode the values for
|
||||
* each of our fonts based on most common average line-heights.
|
||||
* See https://github.com/excalidraw/excalidraw/pull/6360#issuecomment-1477635971
|
||||
* where the values come from.
|
||||
*/
|
||||
const DEFAULT_LINE_HEIGHT = {
|
||||
// ~1.25 is the average for Virgil in WebKit and Blink.
|
||||
// Gecko (FF) uses ~1.28.
|
||||
[FONT_FAMILY.Virgil]: 1.25 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.15 is the average for Helvetica in WebKit and Blink.
|
||||
[FONT_FAMILY.Helvetica]: 1.15 as ExcalidrawTextElement["lineHeight"],
|
||||
// ~1.2 is the average for Cascadia in WebKit and Blink, and kinda Gecko too
|
||||
[FONT_FAMILY.Cascadia]: 1.2 as ExcalidrawTextElement["lineHeight"],
|
||||
};
|
||||
|
||||
/** OS/2 sTypoAscender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypoascender */
|
||||
type sTypoAscender = number & MakeBrand<"sTypoAscender">;
|
||||
|
||||
/** OS/2 sTypoDescender, https://learn.microsoft.com/en-us/typography/opentype/spec/os2#stypodescender */
|
||||
type sTypoDescender = number & MakeBrand<"sTypoDescender">;
|
||||
|
||||
/** head.unitsPerEm, usually either 1000 or 2048 */
|
||||
type unitsPerEm = number & MakeBrand<"unitsPerEm">;
|
||||
|
||||
/**
|
||||
* Hardcoded metrics for default fonts, read by https://opentype.js.org/font-inspector.html.
|
||||
* For custom fonts, read these metrics from OS/2 table and extend this object.
|
||||
*
|
||||
* WARN: opentype does NOT open WOFF2 correctly, make sure to convert WOFF2 to TTF first.
|
||||
*/
|
||||
export const FONT_METRICS: Record<
|
||||
number,
|
||||
{
|
||||
unitsPerEm: number;
|
||||
ascender: sTypoAscender;
|
||||
descender: sTypoDescender;
|
||||
}
|
||||
> = {
|
||||
[FONT_FAMILY.Virgil]: {
|
||||
unitsPerEm: 1000 as unitsPerEm,
|
||||
ascender: 886 as sTypoAscender,
|
||||
descender: -374 as sTypoDescender,
|
||||
},
|
||||
[FONT_FAMILY.Helvetica]: {
|
||||
unitsPerEm: 2048 as unitsPerEm,
|
||||
ascender: 1577 as sTypoAscender,
|
||||
descender: -471 as sTypoDescender,
|
||||
},
|
||||
[FONT_FAMILY.Cascadia]: {
|
||||
unitsPerEm: 2048 as unitsPerEm,
|
||||
ascender: 1977 as sTypoAscender,
|
||||
descender: -480 as sTypoDescender,
|
||||
},
|
||||
[FONT_FAMILY.Assistant]: {
|
||||
unitsPerEm: 1000 as unitsPerEm,
|
||||
ascender: 1021 as sTypoAscender,
|
||||
descender: -287 as sTypoDescender,
|
||||
},
|
||||
};
|
||||
|
||||
export const getDefaultLineHeight = (fontFamily: FontFamilyValues) => {
|
||||
if (fontFamily in DEFAULT_LINE_HEIGHT) {
|
||||
return DEFAULT_LINE_HEIGHT[fontFamily];
|
||||
}
|
||||
return DEFAULT_LINE_HEIGHT[DEFAULT_FONT_FAMILY];
|
||||
export const getMinTextElementWidth = (
|
||||
font: FontString,
|
||||
lineHeight: ExcalidrawTextElement["lineHeight"],
|
||||
) => {
|
||||
return measureText("", font, lineHeight).width + BOUND_TEXT_PADDING * 2;
|
||||
};
|
||||
|
|
|
@ -236,6 +236,117 @@ describe("textWysiwyg", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Test text wrapping", () => {
|
||||
const { h } = window;
|
||||
const dimensions = { height: 400, width: 800 };
|
||||
|
||||
beforeAll(() => {
|
||||
mockBoundingClientRect(dimensions);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await render(<Excalidraw handleKeyboardGlobally={true} />);
|
||||
// @ts-ignore
|
||||
h.app.refreshViewportBreakpoints();
|
||||
// @ts-ignore
|
||||
h.app.refreshEditorBreakpoints();
|
||||
|
||||
h.elements = [];
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreOriginalGetBoundingClientRect();
|
||||
});
|
||||
|
||||
it("should keep width when editing a wrapped text", async () => {
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Excalidraw\nEditor",
|
||||
});
|
||||
|
||||
h.elements = [text];
|
||||
|
||||
const prevWidth = text.width;
|
||||
const prevHeight = text.height;
|
||||
const prevText = text.text;
|
||||
|
||||
// text is wrapped
|
||||
UI.resize(text, "e", [-20, 0]);
|
||||
expect(text.width).not.toEqual(prevWidth);
|
||||
expect(text.height).not.toEqual(prevHeight);
|
||||
expect(text.text).not.toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
const wrappedWidth = text.width;
|
||||
const wrappedHeight = text.height;
|
||||
const wrappedText = text.text;
|
||||
|
||||
// edit text
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||
const editor = await getTextEditor(textEditorSelector);
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
const nextText = `${wrappedText} is great!`;
|
||||
updateTextEditor(editor, nextText);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
|
||||
expect(h.elements[0].width).toEqual(wrappedWidth);
|
||||
expect(h.elements[0].height).toBeGreaterThan(wrappedHeight);
|
||||
|
||||
// remove all texts and then add it back editing
|
||||
updateTextEditor(editor, "");
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
updateTextEditor(editor, nextText);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
|
||||
expect(h.elements[0].width).toEqual(wrappedWidth);
|
||||
});
|
||||
|
||||
it("should restore original text after unwrapping a wrapped text", async () => {
|
||||
const originalText = "Excalidraw\neditor\nis great!";
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: originalText,
|
||||
});
|
||||
h.elements = [text];
|
||||
|
||||
// wrap
|
||||
UI.resize(text, "e", [-40, 0]);
|
||||
// enter text editing mode
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||
const editor = await getTextEditor(textEditorSelector);
|
||||
editor.blur();
|
||||
// restore after unwrapping
|
||||
UI.resize(text, "e", [40, 0]);
|
||||
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
|
||||
|
||||
// wrap again and add a new line
|
||||
UI.resize(text, "e", [-30, 0]);
|
||||
const wrappedText = text.text;
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||
updateTextEditor(editor, `${wrappedText}\nA new line!`);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
// remove the newly added line
|
||||
UI.clickTool("selection");
|
||||
mouse.doubleClickAt(text.x + text.width / 2, text.y + text.height / 2);
|
||||
updateTextEditor(editor, wrappedText);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
// unwrap
|
||||
UI.resize(text, "e", [30, 0]);
|
||||
// expect the text to be restored the same
|
||||
expect((h.elements[0] as ExcalidrawTextElement).text).toBe(originalText);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Test container-unbound text", () => {
|
||||
const { h } = window;
|
||||
const dimensions = { height: 400, width: 800 };
|
||||
|
@ -465,7 +576,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("text should never go beyond max width", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
mouse.click(0, 0);
|
||||
|
||||
textarea = await getTextEditor(textEditorSelector, true);
|
||||
updateTextEditor(
|
||||
|
@ -800,29 +911,18 @@ describe("textWysiwyg", () => {
|
|||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = await getTextEditor(textEditorSelector, true);
|
||||
const editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
UI.clickTool("text");
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Excalifont);
|
||||
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = await getTextEditor(textEditorSelector, true);
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
|
||||
//undo
|
||||
Keyboard.withModifierKeys({ ctrl: true }, () => {
|
||||
|
@ -830,7 +930,7 @@ describe("textWysiwyg", () => {
|
|||
});
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Virgil);
|
||||
).toEqual(FONT_FAMILY.Excalifont);
|
||||
|
||||
//redo
|
||||
Keyboard.withModifierKeys({ ctrl: true, shift: true }, () => {
|
||||
|
@ -838,7 +938,7 @@ describe("textWysiwyg", () => {
|
|||
});
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
});
|
||||
|
||||
it("should wrap text and vertcially center align once text submitted", async () => {
|
||||
|
@ -964,7 +1064,7 @@ describe("textWysiwyg", () => {
|
|||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
85,
|
||||
4.999999999999986,
|
||||
"5.00000",
|
||||
]
|
||||
`);
|
||||
|
||||
|
@ -1009,8 +1109,8 @@ describe("textWysiwyg", () => {
|
|||
UI.resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
[
|
||||
374.99999999999994,
|
||||
-535.0000000000001,
|
||||
"375.00000",
|
||||
"-535.00000",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
@ -1230,14 +1330,14 @@ describe("textWysiwyg", () => {
|
|||
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/Very large/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontSize,
|
||||
).toEqual(36);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(97);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(100);
|
||||
});
|
||||
|
||||
it("should update line height when font family updated", async () => {
|
||||
|
@ -1257,18 +1357,18 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Cascadia);
|
||||
).toEqual(FONT_FAMILY["Comic Shanns"]);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.2);
|
||||
).toEqual(1.25);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/normal/i));
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).fontFamily,
|
||||
).toEqual(FONT_FAMILY.Helvetica);
|
||||
).toEqual(FONT_FAMILY.Nunito);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
).toEqual(1.15);
|
||||
).toEqual(1.35);
|
||||
});
|
||||
|
||||
describe("should align correctly", () => {
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES } from "../constants";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -90,19 +90,23 @@ export const textWysiwyg = ({
|
|||
canvas,
|
||||
excalidrawContainer,
|
||||
app,
|
||||
autoSelect = true,
|
||||
}: {
|
||||
id: ExcalidrawElement["id"];
|
||||
onChange?: (text: string) => void;
|
||||
onSubmit: (data: {
|
||||
text: string;
|
||||
viaKeyboard: boolean;
|
||||
originalText: string;
|
||||
}) => void;
|
||||
/**
|
||||
* textWysiwyg only deals with `originalText`
|
||||
*
|
||||
* Note: `text`, which can be wrapped and therefore different from `originalText`,
|
||||
* is derived from `originalText`
|
||||
*/
|
||||
onChange?: (nextOriginalText: string) => void;
|
||||
onSubmit: (data: { viaKeyboard: boolean; nextOriginalText: string }) => void;
|
||||
getViewportCoords: (x: number, y: number) => [number, number];
|
||||
element: ExcalidrawTextElement;
|
||||
canvas: HTMLCanvasElement;
|
||||
excalidrawContainer: HTMLDivElement | null;
|
||||
app: App;
|
||||
autoSelect?: boolean;
|
||||
}) => {
|
||||
const textPropertiesUpdated = (
|
||||
updatedTextElement: ExcalidrawTextElement,
|
||||
|
@ -141,7 +145,6 @@ export const textWysiwyg = ({
|
|||
updatedTextElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
|
||||
// Editing metrics
|
||||
const eMetrics = measureText(
|
||||
|
@ -156,11 +159,14 @@ export const textWysiwyg = ({
|
|||
updatedTextElement.lineHeight,
|
||||
);
|
||||
|
||||
let maxHeight = eMetrics.height;
|
||||
let textElementWidth = Math.max(updatedTextElement.width, eMetrics.width);
|
||||
let width = Math.max(updatedTextElement.width, eMetrics.width);
|
||||
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
const textElementHeight = Math.max(updatedTextElement.height, maxHeight);
|
||||
let height = Math.max(updatedTextElement.height, eMetrics.height);
|
||||
|
||||
let maxWidth = width;
|
||||
let maxHeight = height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
|
@ -202,9 +208,9 @@ export const textWysiwyg = ({
|
|||
);
|
||||
|
||||
// autogrow container height if text exceeds
|
||||
if (!isArrowElement(container) && textElementHeight > maxHeight) {
|
||||
if (!isArrowElement(container) && height > maxHeight) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
textElementHeight,
|
||||
height,
|
||||
container.type,
|
||||
);
|
||||
|
||||
|
@ -215,10 +221,10 @@ export const textWysiwyg = ({
|
|||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
container.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
height < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
textElementHeight,
|
||||
height,
|
||||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
|
@ -249,10 +255,11 @@ export const textWysiwyg = ({
|
|||
editable.selectionEnd = editable.value.length - diff;
|
||||
}
|
||||
|
||||
const transformWidth = updatedTextElement.width;
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
width = Math.min(width, maxWidth);
|
||||
} else {
|
||||
width += 0.5;
|
||||
}
|
||||
|
||||
// Horizontal offset in case updatedTextElement has a non-WYSIWYG subtype
|
||||
|
@ -269,34 +276,47 @@ export const textWysiwyg = ({
|
|||
: textAlign === "center"
|
||||
? offWidth / 2
|
||||
: 0;
|
||||
const { width: w, height: h } = updatedTextElement;
|
||||
let { width: w, height: h } = updatedTextElement;
|
||||
|
||||
// add 5% buffer otherwise it causes wysiwyg to jump
|
||||
height *= 1.05;
|
||||
h *= 1.05;
|
||||
|
||||
const transformOrigin =
|
||||
updatedTextElement.width !== eMetrics.width ||
|
||||
updatedTextElement.height !== eMetrics.height
|
||||
? { transformOrigin: `${w / 2}px ${h / 2}px` }
|
||||
: {};
|
||||
|
||||
const font = getFontString(updatedTextElement);
|
||||
|
||||
// adding left and right padding buffer, so that browser does not cut the glyphs (does not work in Safari)
|
||||
const padding = !isSafari
|
||||
? Math.ceil(updatedTextElement.fontSize / 2)
|
||||
: 0;
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
const editorMaxHeight =
|
||||
(appState.height - viewportY) / appState.zoom.value;
|
||||
Object.assign(editable.style, {
|
||||
font: getFontString(updatedTextElement),
|
||||
font,
|
||||
// must be defined *after* font ¯\_(ツ)_/¯
|
||||
lineHeight: updatedTextElement.lineHeight,
|
||||
width: `${Math.min(textElementWidth, maxWidth)}px`,
|
||||
height: `${textElementHeight}px`,
|
||||
left: `${viewportX}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${viewportX - padding}px`,
|
||||
top: `${viewportY}px`,
|
||||
...transformOrigin,
|
||||
transform: getTransform(
|
||||
offsetX,
|
||||
transformWidth,
|
||||
updatedTextElement.height,
|
||||
width,
|
||||
height,
|
||||
getTextElementAngle(updatedTextElement, container),
|
||||
appState,
|
||||
maxWidth,
|
||||
editorMaxHeight,
|
||||
),
|
||||
padding: `0 ${padding}px`,
|
||||
textAlign,
|
||||
verticalAlign,
|
||||
color: updatedTextElement.strokeColor,
|
||||
|
@ -310,6 +330,7 @@ export const textWysiwyg = ({
|
|||
if (isTestEnv()) {
|
||||
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||
}
|
||||
|
||||
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||
}
|
||||
};
|
||||
|
@ -326,7 +347,7 @@ export const textWysiwyg = ({
|
|||
let whiteSpace = "pre";
|
||||
let wordBreak = "normal";
|
||||
|
||||
if (isBoundToContainer(element)) {
|
||||
if (isBoundToContainer(element) || !element.autoResize) {
|
||||
whiteSpace = "pre-wrap";
|
||||
wordBreak = "break-word";
|
||||
}
|
||||
|
@ -336,7 +357,6 @@ export const textWysiwyg = ({
|
|||
minHeight: "1em",
|
||||
backfaceVisibility: "hidden",
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
border: 0,
|
||||
outline: 0,
|
||||
resize: "none",
|
||||
|
@ -383,7 +403,7 @@ export const textWysiwyg = ({
|
|||
font,
|
||||
getBoundTextMaxWidth(container, boundTextElement),
|
||||
);
|
||||
const width = getTextWidth(wrappedText, font);
|
||||
const width = getTextWidth(wrappedText, font, true);
|
||||
editable.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
@ -532,14 +552,21 @@ export const textWysiwyg = ({
|
|||
};
|
||||
|
||||
const stopEvent = (event: Event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.target instanceof HTMLCanvasElement) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// using a state variable instead of passing it to the handleSubmit callback
|
||||
// so that we don't need to create separate a callback for event handlers
|
||||
let submittedViaKeyboard = false;
|
||||
const handleSubmit = () => {
|
||||
// prevent double submit
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
isDestroyed = true;
|
||||
// cleanup must be run before onSubmit otherwise when app blurs the wysiwyg
|
||||
// it'd get stuck in an infinite loop of blur→onSubmit after we re-focus the
|
||||
// wysiwyg on update
|
||||
|
@ -550,14 +577,12 @@ export const textWysiwyg = ({
|
|||
if (!updateElement) {
|
||||
return;
|
||||
}
|
||||
let text = editable.value;
|
||||
const container = getContainerElement(
|
||||
updateElement,
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
if (container) {
|
||||
text = updateElement.text;
|
||||
if (editable.value.trim()) {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId || boundTextElementId !== element.id) {
|
||||
|
@ -589,17 +614,12 @@ export const textWysiwyg = ({
|
|||
}
|
||||
|
||||
onSubmit({
|
||||
text,
|
||||
viaKeyboard: submittedViaKeyboard,
|
||||
originalText: editable.value,
|
||||
nextOriginalText: editable.value,
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
if (isDestroyed) {
|
||||
return;
|
||||
}
|
||||
isDestroyed = true;
|
||||
// remove events to ensure they don't late-fire
|
||||
editable.onblur = null;
|
||||
editable.oninput = null;
|
||||
|
@ -628,46 +648,15 @@ export const textWysiwyg = ({
|
|||
// in that same tick.
|
||||
const target = event?.target;
|
||||
|
||||
const isTargetPickerTrigger =
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("active-color");
|
||||
target.classList.contains("properties-trigger");
|
||||
|
||||
setTimeout(() => {
|
||||
editable.onblur = handleSubmit;
|
||||
|
||||
if (isTargetPickerTrigger) {
|
||||
const callback = (
|
||||
mutationList: MutationRecord[],
|
||||
observer: MutationObserver,
|
||||
) => {
|
||||
const radixIsRemoved = mutationList.find(
|
||||
(mutation) =>
|
||||
mutation.removedNodes.length > 0 &&
|
||||
(mutation.removedNodes[0] as HTMLElement).dataset
|
||||
?.radixPopperContentWrapper !== undefined,
|
||||
);
|
||||
|
||||
if (radixIsRemoved) {
|
||||
// should work without this in theory
|
||||
// and i think it does actually but radix probably somewhere,
|
||||
// somehow sets the focus elsewhere
|
||||
setTimeout(() => {
|
||||
editable.focus();
|
||||
});
|
||||
|
||||
observer.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(callback);
|
||||
|
||||
observer.observe(document.querySelector(".excalidraw-container")!, {
|
||||
childList: true,
|
||||
});
|
||||
}
|
||||
|
||||
// case: clicking on the same property → no change → no update → no focus
|
||||
if (!isTargetPickerTrigger) {
|
||||
if (!isPropertiesTrigger) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
@ -675,32 +664,50 @@ export const textWysiwyg = ({
|
|||
|
||||
// prevent blur when changing properties from the menu
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
const isTargetPickerTrigger =
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target.classList.contains("active-color");
|
||||
const target = event?.target;
|
||||
|
||||
const isPropertiesTrigger =
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("properties-trigger");
|
||||
|
||||
if (
|
||||
((event.target instanceof HTMLElement ||
|
||||
event.target instanceof SVGElement) &&
|
||||
event.target.closest(`.${CLASSES.SHAPE_ACTIONS_MENU}`) &&
|
||||
!isWritableElement(event.target)) ||
|
||||
isTargetPickerTrigger
|
||||
isPropertiesTrigger
|
||||
) {
|
||||
editable.onblur = null;
|
||||
window.addEventListener("pointerup", bindBlurEvent);
|
||||
// handle edge-case where pointerup doesn't fire e.g. due to user
|
||||
// alt-tabbing away
|
||||
window.addEventListener("blur", handleSubmit);
|
||||
} else if (
|
||||
event.target instanceof HTMLElement &&
|
||||
event.target instanceof HTMLCanvasElement &&
|
||||
// Vitest simply ignores stopPropagation, capture-mode, or rAF
|
||||
// so without introducing crazier hacks, nothing we can do
|
||||
!isTestEnv()
|
||||
) {
|
||||
// On mobile, blur event doesn't seem to always fire correctly,
|
||||
// so we want to also submit on pointerdown outside the wysiwyg.
|
||||
// Done in the next frame to prevent pointerdown from creating a new text
|
||||
// immediately (if tools locked) so that users on mobile have chance
|
||||
// to submit first (to hide virtual keyboard).
|
||||
// Note: revisit if we want to differ this behavior on Desktop
|
||||
requestAnimationFrame(() => {
|
||||
handleSubmit();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// handle updates of textElement properties of editing element
|
||||
const unbindUpdate = Scene.getScene(element)!.addCallback(() => {
|
||||
const unbindUpdate = Scene.getScene(element)!.onUpdate(() => {
|
||||
updateWysiwygStyle();
|
||||
const isColorPickerActive = !!document.activeElement?.closest(
|
||||
".color-picker-content",
|
||||
const isPopupOpened = !!document.activeElement?.closest(
|
||||
".properties-content",
|
||||
);
|
||||
if (!isColorPickerActive) {
|
||||
if (!isPopupOpened) {
|
||||
editable.focus();
|
||||
}
|
||||
});
|
||||
|
@ -709,9 +716,11 @@ export const textWysiwyg = ({
|
|||
|
||||
let isDestroyed = false;
|
||||
|
||||
// select on init (focusing is done separately inside the bindBlurEvent()
|
||||
// because we need it to happen *after* the blur event from `pointerdown`)
|
||||
editable.select();
|
||||
if (autoSelect) {
|
||||
// select on init (focusing is done separately inside the bindBlurEvent()
|
||||
// because we need it to happen *after* the blur event from `pointerdown`)
|
||||
editable.select();
|
||||
}
|
||||
bindBlurEvent();
|
||||
|
||||
// reposition wysiwyg in case of canvas is resized. Using ResizeObserver
|
||||
|
@ -726,7 +735,13 @@ export const textWysiwyg = ({
|
|||
window.addEventListener("resize", updateWysiwygStyle);
|
||||
}
|
||||
|
||||
window.addEventListener("pointerdown", onPointerDown);
|
||||
editable.onpointerdown = (event) => event.stopPropagation();
|
||||
|
||||
// rAF (+ capture to by doubly sure) so we don't catch te pointerdown that
|
||||
// triggered the wysiwyg
|
||||
requestAnimationFrame(() => {
|
||||
window.addEventListener("pointerdown", onPointerDown, { capture: true });
|
||||
});
|
||||
window.addEventListener("wheel", stopEvent, {
|
||||
passive: false,
|
||||
capture: true,
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue