Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c68c2be44c | |||
| be65ac7f22 | |||
| 09e249ae57 | |||
| f0c1e9707a | |||
| 7f4659339b | |||
| 0987c5b770 | |||
| 0a529bd2ed | |||
| 794b2b21a7 | |||
| a71bb63d1f | |||
| 661d6a4a75 | |||
| defd34923a | |||
| c540bd68aa | |||
| eddbe55f50 | |||
| 2f9526da24 | |||
| 1b6e3fe05b | |||
| afe52c89a7 | |||
| be4e127f6c | |||
| ff0b4394b1 | |||
| 7d8b7fc14d | |||
| 971b4d4ae6 | |||
| cc4c51996c | |||
| 79257a1923 | |||
| dc66261c19 | |||
| 273ba803d9 | |||
| 301e83805d | |||
| ed5ce8d3de | |||
| 6e577d1308 | |||
| 80b9fd18b9 | |||
| dbc48cfee2 | |||
| 3fc89b716a | |||
| 30743ec726 | |||
| 86d49a273b | |||
| 92fe9b95d5 |
@@ -126,6 +126,38 @@ polyfill();
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
declare global {
|
||||
interface BeforeInstallPromptEventChoiceResult {
|
||||
outcome: "accepted" | "dismissed";
|
||||
}
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt(): Promise<void>;
|
||||
userChoice: Promise<BeforeInstallPromptEventChoiceResult>;
|
||||
}
|
||||
|
||||
interface WindowEventMap {
|
||||
beforeinstallprompt: BeforeInstallPromptEvent;
|
||||
}
|
||||
}
|
||||
|
||||
let pwaEvent: BeforeInstallPromptEvent | null = null;
|
||||
|
||||
// Adding a listener outside of the component as it may (?) need to be
|
||||
// subscribed early to catch the event.
|
||||
//
|
||||
// Also note that it will fire only if certain heuristics are met (user has
|
||||
// used the app for some time, etc.)
|
||||
window.addEventListener(
|
||||
"beforeinstallprompt",
|
||||
(event: BeforeInstallPromptEvent) => {
|
||||
// prevent Chrome <= 67 from automatically showing the prompt
|
||||
event.preventDefault();
|
||||
// cache for later use
|
||||
pwaEvent = event;
|
||||
},
|
||||
);
|
||||
|
||||
let isSelfEmbedding = false;
|
||||
|
||||
if (window.self !== window.top) {
|
||||
@@ -1100,6 +1132,21 @@ const ExcalidrawWrapper = () => {
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t("labels.installPWA"),
|
||||
category: DEFAULT_CATEGORIES.app,
|
||||
predicate: () => !!pwaEvent,
|
||||
perform: () => {
|
||||
if (pwaEvent) {
|
||||
pwaEvent.prompt();
|
||||
pwaEvent.userChoice.then(() => {
|
||||
// event cannot be reused, but we'll hopefully
|
||||
// grab new one as the event should be fired again
|
||||
pwaEvent = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Excalidraw>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
name="description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta name="image" content="https://excalidraw.com/og-image-2.png" />
|
||||
<meta name="image" content="https://excalidraw.com/og-image-3.png" />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:site_name" content="Excalidraw" />
|
||||
@@ -35,7 +35,7 @@
|
||||
property="og:description"
|
||||
content="Excalidraw is a virtual collaborative whiteboard tool that lets you easily sketch diagrams that have a hand-drawn feel to them."
|
||||
/>
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-2.png" />
|
||||
<meta property="og:image" content="https://excalidraw.com/og-image-3.png" />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
@@ -51,7 +51,7 @@
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://excalidraw.com/og-twitter-v2.png"
|
||||
content="https://excalidraw.com/og-image-3.png"
|
||||
/>
|
||||
|
||||
<!-- General tags -->
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"private": true,
|
||||
"name": "excalidraw-monorepo",
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"workspaces": [
|
||||
"excalidraw-app",
|
||||
"packages/excalidraw",
|
||||
|
||||
@@ -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";
|
||||
@@ -142,6 +142,7 @@ export const actionBindText = register({
|
||||
containerId: container.id,
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
});
|
||||
mutateElement(container, {
|
||||
boundElements: (container.boundElements || []).concat({
|
||||
@@ -296,6 +297,7 @@ export const actionWrapTextInContainer = register({
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
boundElements: null,
|
||||
textAlign: TEXT_ALIGN.CENTER,
|
||||
autoResize: true,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -167,7 +167,7 @@ const offsetElementAfterFontResize = (
|
||||
prevElement: ExcalidrawTextElement,
|
||||
nextElement: ExcalidrawTextElement,
|
||||
) => {
|
||||
if (isBoundToContainer(nextElement)) {
|
||||
if (isBoundToContainer(nextElement) || !nextElement.autoResize) {
|
||||
return nextElement;
|
||||
}
|
||||
return mutateElement(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -134,7 +134,9 @@ export type ActionName =
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer"
|
||||
| "commandPalette";
|
||||
| "commandPalette"
|
||||
| "autoResize"
|
||||
| "elementStats";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -468,6 +468,7 @@ export const ExitZenModeAction = ({
|
||||
showExitZenModeBtn: boolean;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx("disable-zen-mode", {
|
||||
"disable-zen-mode--visible": showExitZenModeBtn,
|
||||
})}
|
||||
|
||||
@@ -88,6 +88,7 @@ import {
|
||||
isIOS,
|
||||
supportsResizeObserver,
|
||||
DEFAULT_COLLISION_THRESHOLD,
|
||||
DEFAULT_TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import type { ExportedElements } from "../data";
|
||||
import { exportCanvas, loadFromBlob } from "../data";
|
||||
@@ -114,7 +115,7 @@ import {
|
||||
newTextElement,
|
||||
newImageElement,
|
||||
transformElements,
|
||||
updateTextElement,
|
||||
refreshTextDimensions,
|
||||
redrawTextBoundingBox,
|
||||
getElementAbsoluteCoords,
|
||||
} from "../element";
|
||||
@@ -331,6 +332,8 @@ import {
|
||||
getLineHeightInPx,
|
||||
isMeasureTextSupported,
|
||||
isValidTextContainer,
|
||||
measureText,
|
||||
wrapText,
|
||||
} from "../element/textElement";
|
||||
import {
|
||||
showHyperlinkTooltip,
|
||||
@@ -429,6 +432,9 @@ import {
|
||||
isPointHittingLinkIcon,
|
||||
} from "./hyperlink/helpers";
|
||||
import { getShortcutFromShortcutName } from "../actions/shortcuts";
|
||||
import { actionTextAutoResize } from "../actions/actionTextAutoResize";
|
||||
import { getVisibleSceneBounds } from "../element/bounds";
|
||||
import { Stats } from "./Stats";
|
||||
|
||||
const AppContext = React.createContext<AppClassProperties>(null!);
|
||||
const AppPropsContext = React.createContext<AppProps>(null!);
|
||||
@@ -540,7 +546,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public library: AppClassProperties["library"];
|
||||
public libraryItemsFromStorage: LibraryItems | undefined;
|
||||
public id: string;
|
||||
private store: Store;
|
||||
store: Store;
|
||||
private history: History;
|
||||
private excalidrawContainerValue: {
|
||||
container: HTMLDivElement | null;
|
||||
@@ -714,10 +720,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
id: this.id,
|
||||
};
|
||||
|
||||
this.fonts = new Fonts({
|
||||
scene: this.scene,
|
||||
onSceneUpdated: this.onSceneUpdated,
|
||||
});
|
||||
this.fonts = new Fonts({ scene: this.scene });
|
||||
this.history = new History();
|
||||
|
||||
this.actionManager.registerAll(actions);
|
||||
@@ -940,7 +943,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
this.scene.informMutation();
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
|
||||
// GC
|
||||
@@ -1452,10 +1455,10 @@ class App extends React.Component<AppProps, AppState> {
|
||||
const selectedElements = this.scene.getSelectedElements(this.state);
|
||||
const { renderTopRightUI, renderCustomStats } = this.props;
|
||||
|
||||
const versionNonce = this.scene.getVersionNonce();
|
||||
const sceneNonce = this.scene.getSceneNonce();
|
||||
const { elementsMap, visibleElements } =
|
||||
this.renderer.getRenderableElements({
|
||||
versionNonce,
|
||||
sceneNonce,
|
||||
zoom: this.state.zoom,
|
||||
offsetLeft: this.state.offsetLeft,
|
||||
offsetTop: this.state.offsetTop,
|
||||
@@ -1667,13 +1670,26 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{this.state.showStats && (
|
||||
<Stats
|
||||
appState={this.state}
|
||||
setAppState={this.setState}
|
||||
scene={this.scene}
|
||||
onClose={() => {
|
||||
this.actionManager.executeAction(
|
||||
actionToggleStats,
|
||||
);
|
||||
}}
|
||||
renderCustomStats={renderCustomStats}
|
||||
/>
|
||||
)}
|
||||
<StaticCanvas
|
||||
canvas={this.canvas}
|
||||
rc={this.rc}
|
||||
elementsMap={elementsMap}
|
||||
allElementsMap={allElementsMap}
|
||||
visibleElements={visibleElements}
|
||||
versionNonce={versionNonce}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
@@ -1695,7 +1711,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elementsMap={elementsMap}
|
||||
visibleElements={visibleElements}
|
||||
selectedElements={selectedElements}
|
||||
versionNonce={versionNonce}
|
||||
sceneNonce={sceneNonce}
|
||||
selectionNonce={
|
||||
this.state.selectionElement?.versionNonce
|
||||
}
|
||||
@@ -1819,7 +1835,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
);
|
||||
}
|
||||
this.magicGenerations.set(frameElement.id, data);
|
||||
this.onSceneUpdated();
|
||||
this.triggerRender();
|
||||
};
|
||||
|
||||
private getTextFromElements(elements: readonly ExcalidrawElement[]) {
|
||||
@@ -2444,7 +2460,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.history.record(increment.elementsChange, increment.appStateChange);
|
||||
});
|
||||
|
||||
this.scene.addCallback(this.onSceneUpdated);
|
||||
this.scene.onUpdate(this.triggerRender);
|
||||
this.addEventListeners();
|
||||
|
||||
if (this.props.autoFocus && this.excalidrawContainerRef.current) {
|
||||
@@ -2489,6 +2505,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
public componentWillUnmount() {
|
||||
this.renderer.destroy();
|
||||
this.scene = new Scene();
|
||||
this.fonts = new Fonts({ scene: this.scene });
|
||||
this.renderer = new Renderer(this.scene);
|
||||
this.files = {};
|
||||
this.imageCache.clear();
|
||||
@@ -2566,7 +2583,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
addEventListener(document, EVENT.KEYUP, this.onKeyUp, { passive: true }),
|
||||
addEventListener(
|
||||
document,
|
||||
EVENT.MOUSE_MOVE,
|
||||
EVENT.POINTER_MOVE,
|
||||
this.updateCurrentCursorPosition,
|
||||
),
|
||||
// rerender text elements on font load to fix #637 && #1553
|
||||
@@ -2595,6 +2612,9 @@ class App extends React.Component<AppProps, AppState> {
|
||||
),
|
||||
addEventListener(window, EVENT.FOCUS, () => {
|
||||
this.maybeCleanupAfterMissingPointerUp(null);
|
||||
// browsers (chrome?) tend to free up memory a lot, which results
|
||||
// in canvas context being cleared. Thus re-render on focus.
|
||||
this.triggerRender(true);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3339,32 +3359,53 @@ class App extends React.Component<AppProps, AppState> {
|
||||
text,
|
||||
fontSize: this.state.currentItemFontSize,
|
||||
fontFamily: this.state.currentItemFontFamily,
|
||||
textAlign: this.state.currentItemTextAlign,
|
||||
textAlign: DEFAULT_TEXT_ALIGN,
|
||||
verticalAlign: DEFAULT_VERTICAL_ALIGN,
|
||||
locked: false,
|
||||
};
|
||||
|
||||
const fontString = getFontString({
|
||||
fontSize: textElementProps.fontSize,
|
||||
fontFamily: textElementProps.fontFamily,
|
||||
});
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
const [x1, , x2] = getVisibleSceneBounds(this.state);
|
||||
// long texts should not go beyond 800 pixels in width nor should it go below 200 px
|
||||
const maxTextWidth = Math.max(Math.min((x2 - x1) * 0.5, 800), 200);
|
||||
const LINE_GAP = 10;
|
||||
let currentY = y;
|
||||
|
||||
const lines = isPlainPaste ? [text] : text.split("\n");
|
||||
const textElements = lines.reduce(
|
||||
(acc: ExcalidrawTextElement[], line, idx) => {
|
||||
const text = line.trim();
|
||||
|
||||
const lineHeight = getDefaultLineHeight(textElementProps.fontFamily);
|
||||
if (text.length) {
|
||||
const originalText = line.trim();
|
||||
if (originalText.length) {
|
||||
const topLayerFrame = this.getTopLayerFrameAtSceneCoords({
|
||||
x,
|
||||
y: currentY,
|
||||
});
|
||||
|
||||
let metrics = measureText(originalText, fontString, lineHeight);
|
||||
const isTextWrapped = metrics.width > maxTextWidth;
|
||||
|
||||
const text = isTextWrapped
|
||||
? wrapText(originalText, fontString, maxTextWidth)
|
||||
: originalText;
|
||||
|
||||
metrics = isTextWrapped
|
||||
? measureText(text, fontString, lineHeight)
|
||||
: metrics;
|
||||
|
||||
const startX = x - metrics.width / 2;
|
||||
const startY = currentY - metrics.height / 2;
|
||||
|
||||
const element = newTextElement({
|
||||
...textElementProps,
|
||||
x,
|
||||
y: currentY,
|
||||
x: startX,
|
||||
y: startY,
|
||||
text,
|
||||
originalText,
|
||||
lineHeight,
|
||||
autoResize: !isTextWrapped,
|
||||
frameId: topLayerFrame ? topLayerFrame.id : null,
|
||||
});
|
||||
acc.push(element);
|
||||
@@ -3670,7 +3711,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
});
|
||||
this.scene.informMutation();
|
||||
this.scene.triggerUpdate();
|
||||
|
||||
this.addNewImagesToImageCache();
|
||||
},
|
||||
@@ -3681,7 +3722,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
elements?: SceneData["elements"];
|
||||
appState?: Pick<AppState, K> | null;
|
||||
collaborators?: SceneData["collaborators"];
|
||||
/** @default StoreAction.CAPTURE */
|
||||
/** @default StoreAction.NONE */
|
||||
storeAction?: SceneData["storeAction"];
|
||||
}) => {
|
||||
const nextElements = syncInvalidIndices(sceneData.elements ?? []);
|
||||
@@ -3730,8 +3771,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
},
|
||||
);
|
||||
|
||||
private onSceneUpdated = () => {
|
||||
this.setState({});
|
||||
private triggerRender = (
|
||||
/** force always re-renders canvas even if no change */
|
||||
force?: boolean,
|
||||
) => {
|
||||
if (force === true) {
|
||||
this.scene.triggerUpdate();
|
||||
} else {
|
||||
this.setState({});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -4300,25 +4348,22 @@ class App extends React.Component<AppProps, AppState> {
|
||||
) {
|
||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||
|
||||
const updateElement = (
|
||||
text: string,
|
||||
originalText: string,
|
||||
isDeleted: boolean,
|
||||
) => {
|
||||
const updateElement = (nextOriginalText: string, isDeleted: boolean) => {
|
||||
this.scene.replaceAllElements([
|
||||
// Not sure why we include deleted elements as well hence using deleted elements map
|
||||
...this.scene.getElementsIncludingDeleted().map((_element) => {
|
||||
if (_element.id === element.id && isTextElement(_element)) {
|
||||
return updateTextElement(
|
||||
_element,
|
||||
getContainerElement(_element, elementsMap),
|
||||
elementsMap,
|
||||
{
|
||||
text,
|
||||
isDeleted,
|
||||
originalText,
|
||||
},
|
||||
);
|
||||
return newElementWith(_element, {
|
||||
originalText: nextOriginalText,
|
||||
isDeleted: isDeleted ?? _element.isDeleted,
|
||||
// returns (wrapped) text and new dimensions
|
||||
...refreshTextDimensions(
|
||||
_element,
|
||||
getContainerElement(_element, elementsMap),
|
||||
elementsMap,
|
||||
nextOriginalText,
|
||||
),
|
||||
});
|
||||
}
|
||||
return _element;
|
||||
}),
|
||||
@@ -4341,15 +4386,15 @@ class App extends React.Component<AppProps, AppState> {
|
||||
viewportY - this.state.offsetTop,
|
||||
];
|
||||
},
|
||||
onChange: withBatchedUpdates((text) => {
|
||||
updateElement(text, text, false);
|
||||
onChange: withBatchedUpdates((nextOriginalText) => {
|
||||
updateElement(nextOriginalText, false);
|
||||
if (isNonDeletedElement(element)) {
|
||||
updateBoundElements(element, elementsMap);
|
||||
}
|
||||
}),
|
||||
onSubmit: withBatchedUpdates(({ text, viaKeyboard, originalText }) => {
|
||||
const isDeleted = !text.trim();
|
||||
updateElement(text, originalText, isDeleted);
|
||||
onSubmit: withBatchedUpdates(({ viaKeyboard, nextOriginalText }) => {
|
||||
const isDeleted = !nextOriginalText.trim();
|
||||
updateElement(nextOriginalText, isDeleted);
|
||||
// select the created text element only if submitting via keyboard
|
||||
// (when submitting via click it should act as signal to deselect)
|
||||
if (!isDeleted && viaKeyboard) {
|
||||
@@ -4394,7 +4439,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
// do an initial update to re-initialize element position since we were
|
||||
// modifying element's x/y for sake of editor (case: syncing to remote)
|
||||
updateElement(element.text, element.originalText, false);
|
||||
updateElement(element.originalText, false);
|
||||
}
|
||||
|
||||
private deselectElements() {
|
||||
@@ -5101,8 +5146,11 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
this.translateCanvas({
|
||||
zoom: zoomState.zoom,
|
||||
scrollX: zoomState.scrollX + deltaX / nextZoom,
|
||||
scrollY: zoomState.scrollY + deltaY / nextZoom,
|
||||
// 2x multiplier is just a magic number that makes this work correctly
|
||||
// on touchscreen devices (note: if we get report that panning is slower/faster
|
||||
// than actual movement, consider swapping with devicePixelRatio)
|
||||
scrollX: zoomState.scrollX + 2 * (deltaX / nextZoom),
|
||||
scrollY: zoomState.scrollY + 2 * (deltaY / nextZoom),
|
||||
shouldCacheIgnoreZoom: true,
|
||||
});
|
||||
});
|
||||
@@ -5577,7 +5625,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
this.elementsPendingErasure = new Set(this.elementsPendingErasure);
|
||||
this.onSceneUpdated();
|
||||
this.triggerRender();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -8069,7 +8117,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
|
||||
this.scene.informMutation();
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8564,7 +8612,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
|
||||
private restoreReadyToEraseElements = () => {
|
||||
this.elementsPendingErasure = new Set();
|
||||
this.onSceneUpdated();
|
||||
this.triggerRender();
|
||||
};
|
||||
|
||||
private eraseElements = () => {
|
||||
@@ -8978,7 +9026,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
files,
|
||||
);
|
||||
if (updatedFiles.size) {
|
||||
this.scene.informMutation();
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -9633,6 +9681,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
}
|
||||
|
||||
return [
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionCut,
|
||||
actionCopy,
|
||||
actionPaste,
|
||||
@@ -9645,6 +9694,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
actionPasteStyles,
|
||||
CONTEXT_MENU_SEPARATOR,
|
||||
actionGroup,
|
||||
actionTextAutoResize,
|
||||
actionUnbindText,
|
||||
actionBindText,
|
||||
actionWrapTextInContainer,
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ButtonIconSelect = <T extends Object>(
|
||||
{props.options.map((option) =>
|
||||
props.type === "button" ? (
|
||||
<button
|
||||
type="button"
|
||||
key={option.text}
|
||||
onClick={(event) => props.onClick(option.value, event)}
|
||||
className={clsx({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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";
|
||||
@@ -444,7 +442,7 @@ const LayerUI = ({
|
||||
);
|
||||
ShapeCache.delete(element);
|
||||
}
|
||||
Scene.getScene(selectedElements[0])?.informMutation();
|
||||
Scene.getScene(selectedElements[0])?.triggerUpdate();
|
||||
} else if (colorPickerType === "elementBackground") {
|
||||
setAppState({
|
||||
currentItemBackgroundColor: color,
|
||||
@@ -542,19 +540,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) => ({
|
||||
|
||||
@@ -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";
|
||||
@@ -157,17 +155,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={{
|
||||
@@ -194,6 +181,7 @@ export const MobileMenu = ({
|
||||
!appState.openMenu &&
|
||||
!appState.openSidebar && (
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-back-to-content"
|
||||
onClick={() => {
|
||||
setAppState((appState) => ({
|
||||
|
||||
@@ -65,6 +65,7 @@ const ChartPreviewBtn = (props: {
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ChartPreview"
|
||||
onClick={() => {
|
||||
if (chartElements) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getBoundTextElement } from "../../element/textElement";
|
||||
import { isArrowElement } from "../../element/typeChecks";
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { degreeToRadian, radianToDegree } from "../../math";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
|
||||
interface AngleProps {
|
||||
element: ExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 15;
|
||||
|
||||
const Angle = ({ element, elementsMap }: AngleProps) => {
|
||||
const handleDegreeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
stateAtStart,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const _stateAtStart = stateAtStart[0];
|
||||
if (_stateAtStart) {
|
||||
if (nextValue !== undefined) {
|
||||
const nextAngle = degreeToRadian(nextValue);
|
||||
mutateElement(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const originalAngleInDegrees =
|
||||
Math.round(radianToDegree(_stateAtStart.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(element, {
|
||||
angle: nextAngle,
|
||||
});
|
||||
|
||||
const boundTextElement = getBoundTextElement(element, elementsMap);
|
||||
if (boundTextElement && !isArrowElement(element)) {
|
||||
mutateElement(boundTextElement, { angle: nextAngle });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label="A"
|
||||
value={Math.round(radianToDegree(element.angle) * 100) / 100}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDegreeChange}
|
||||
editable={isPropertyEditable(element, "angle")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Angle;
|
||||
@@ -0,0 +1,247 @@
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue, isPropertyEditable } from "./utils";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import {
|
||||
measureFontSizeFromWidth,
|
||||
rescalePointsInElement,
|
||||
} from "../../element/resizeElements";
|
||||
import {
|
||||
getApproxMinLineHeight,
|
||||
getApproxMinLineWidth,
|
||||
getBoundTextElement,
|
||||
getBoundTextMaxWidth,
|
||||
handleBindTextResize,
|
||||
} from "../../element/textElement";
|
||||
import { getFontString } from "../../utils";
|
||||
import { updateBoundElements } from "../../element/binding";
|
||||
|
||||
interface DimensionDragInputProps {
|
||||
property: "width" | "height";
|
||||
element: ExcalidrawElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
const _shouldKeepAspectRatio = (element: ExcalidrawElement) => {
|
||||
return element.type === "image";
|
||||
};
|
||||
|
||||
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),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeElement = (
|
||||
nextWidth: number,
|
||||
nextHeight: number,
|
||||
keepAspectRatio: boolean,
|
||||
latestState: ExcalidrawElement,
|
||||
stateAtStart: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
mutateElement(latestState, {
|
||||
...newOrigin(
|
||||
latestState.x,
|
||||
latestState.y,
|
||||
latestState.width,
|
||||
latestState.height,
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
latestState.angle,
|
||||
),
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, true),
|
||||
});
|
||||
|
||||
let boundTextFont: { fontSize?: number } = {};
|
||||
const boundTextElement = getBoundTextElement(latestState, elementsMap);
|
||||
|
||||
if (boundTextElement) {
|
||||
boundTextFont = {
|
||||
fontSize: boundTextElement.fontSize,
|
||||
};
|
||||
if (keepAspectRatio) {
|
||||
const updatedElement = {
|
||||
...latestState,
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
};
|
||||
|
||||
const nextFont = measureFontSizeFromWidth(
|
||||
boundTextElement,
|
||||
elementsMap,
|
||||
getBoundTextMaxWidth(updatedElement, boundTextElement),
|
||||
);
|
||||
boundTextFont = {
|
||||
fontSize: nextFont?.size ?? boundTextElement.fontSize,
|
||||
};
|
||||
} else {
|
||||
const minWidth = getApproxMinLineWidth(
|
||||
getFontString(boundTextElement),
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
const minHeight = getApproxMinLineHeight(
|
||||
boundTextElement.fontSize,
|
||||
boundTextElement.lineHeight,
|
||||
);
|
||||
nextWidth = Math.max(nextWidth, minWidth);
|
||||
nextHeight = Math.max(nextHeight, minHeight);
|
||||
}
|
||||
}
|
||||
|
||||
updateBoundElements(latestState, elementsMap, {
|
||||
newSize: {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
},
|
||||
});
|
||||
|
||||
if (boundTextElement && boundTextFont) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(latestState, elementsMap, "e", keepAspectRatio);
|
||||
};
|
||||
|
||||
const DimensionDragInput = ({
|
||||
property,
|
||||
element,
|
||||
elementsMap,
|
||||
}: DimensionDragInputProps) => {
|
||||
const handleDimensionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
stateAtStart,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const _stateAtStart = stateAtStart[0];
|
||||
if (_stateAtStart) {
|
||||
const keepAspectRatio =
|
||||
shouldKeepAspectRatio || _shouldKeepAspectRatio(element);
|
||||
const aspectRatio = _stateAtStart.width / _stateAtStart.height;
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextWidth = Math.max(
|
||||
property === "width"
|
||||
? nextValue
|
||||
: keepAspectRatio
|
||||
? nextValue * aspectRatio
|
||||
: _stateAtStart.width,
|
||||
0,
|
||||
);
|
||||
const nextHeight = Math.max(
|
||||
property === "height"
|
||||
? nextValue
|
||||
: keepAspectRatio
|
||||
? nextValue / aspectRatio
|
||||
: _stateAtStart.height,
|
||||
0,
|
||||
);
|
||||
|
||||
resizeElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
keepAspectRatio,
|
||||
element,
|
||||
_stateAtStart,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
let nextWidth = Math.max(0, _stateAtStart.width + changeInWidth);
|
||||
if (property === "width") {
|
||||
if (shouldChangeByStepSize) {
|
||||
nextWidth = getStepSizedValue(nextWidth, STEP_SIZE);
|
||||
} else {
|
||||
nextWidth = Math.round(nextWidth);
|
||||
}
|
||||
}
|
||||
|
||||
let nextHeight = Math.max(0, _stateAtStart.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;
|
||||
}
|
||||
}
|
||||
|
||||
resizeElement(
|
||||
nextWidth,
|
||||
nextHeight,
|
||||
keepAspectRatio,
|
||||
element,
|
||||
_stateAtStart,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={
|
||||
Math.round(
|
||||
(property === "width" ? element.width : element.height) * 100,
|
||||
) / 100
|
||||
}
|
||||
editable={isPropertyEditable(element, property)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DimensionDragInput;
|
||||
@@ -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 {
|
||||
height: var(--default-button-size);
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
||||
border: 1px solid var(--default-border-color);
|
||||
border-right: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
color: var(--input-label-color);
|
||||
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: var(--default-button-size);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import throttle from "lodash.throttle";
|
||||
import { EVENT } from "../../constants";
|
||||
import { KEYS } from "../../keys";
|
||||
import type { ElementsMap, ExcalidrawElement } from "../../element/types";
|
||||
import { deepCopyElement } from "../../element/newElement";
|
||||
|
||||
import "./DragInput.scss";
|
||||
import clsx from "clsx";
|
||||
import { useApp } from "../App";
|
||||
|
||||
export type DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
stateAtStart,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}: {
|
||||
accumulatedChange: number;
|
||||
instantChange: number;
|
||||
stateAtStart: ExcalidrawElement[];
|
||||
originalElementsMap: ElementsMap;
|
||||
shouldKeepAspectRatio: boolean;
|
||||
shouldChangeByStepSize: boolean;
|
||||
nextValue?: number;
|
||||
}) => void;
|
||||
|
||||
interface StatsDragInputProps {
|
||||
label: string | React.ReactNode;
|
||||
value: number;
|
||||
elements: ExcalidrawElement[];
|
||||
editable?: boolean;
|
||||
shouldKeepAspectRatio?: boolean;
|
||||
dragInputCallback: DragInputCallbackType;
|
||||
}
|
||||
|
||||
const StatsDragInput = ({
|
||||
label,
|
||||
dragInputCallback,
|
||||
value,
|
||||
elements,
|
||||
editable = true,
|
||||
shouldKeepAspectRatio,
|
||||
}: StatsDragInputProps) => {
|
||||
const app = useApp();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const labelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const cbThrottled = useMemo(() => {
|
||||
return throttle(dragInputCallback, 16);
|
||||
}, [dragInputCallback]);
|
||||
|
||||
const [inputValue, setInputValue] = useState(value.toString());
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value.toString());
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={clsx("drag-input-container", !editable && "disabled")}>
|
||||
<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 stateAtStart: ExcalidrawElement[] | null = null;
|
||||
let originalElementsMap: Map<string, ExcalidrawElement> | null =
|
||||
null;
|
||||
|
||||
let accumulatedChange: number | null = null;
|
||||
|
||||
document.body.classList.add("dragResize");
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!stateAtStart) {
|
||||
stateAtStart = elements.map((element) =>
|
||||
deepCopyElement(element),
|
||||
);
|
||||
}
|
||||
|
||||
if (!originalElementsMap) {
|
||||
originalElementsMap = app.scene
|
||||
.getNonDeletedElements()
|
||||
.reduce((acc, element) => {
|
||||
acc.set(element.id, deepCopyElement(element));
|
||||
return acc;
|
||||
}, new Map() as ElementsMap);
|
||||
}
|
||||
|
||||
if (!accumulatedChange) {
|
||||
accumulatedChange = 0;
|
||||
}
|
||||
|
||||
if (lastPointer && stateAtStart && accumulatedChange !== null) {
|
||||
const instantChange = event.clientX - lastPointer.x;
|
||||
accumulatedChange += instantChange;
|
||||
|
||||
cbThrottled({
|
||||
accumulatedChange,
|
||||
instantChange,
|
||||
stateAtStart,
|
||||
originalElementsMap,
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: event.shiftKey,
|
||||
});
|
||||
}
|
||||
|
||||
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.store.shouldCaptureIncrement();
|
||||
|
||||
lastPointer = null;
|
||||
accumulatedChange = null;
|
||||
stateAtStart = null;
|
||||
originalElementsMap = null;
|
||||
|
||||
document.body.classList.remove("dragResize");
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onPointerEnter={() => {
|
||||
if (labelRef.current) {
|
||||
labelRef.current.style.cursor = "ew-resize";
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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
|
||||
) {
|
||||
const v = Number(eventTarget.value);
|
||||
if (isNaN(v)) {
|
||||
setInputValue(value.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
dragInputCallback({
|
||||
accumulatedChange: 0,
|
||||
instantChange: 0,
|
||||
stateAtStart: elements,
|
||||
originalElementsMap: app.scene.getNonDeletedElementsMap(),
|
||||
shouldKeepAspectRatio: shouldKeepAspectRatio!!,
|
||||
shouldChangeByStepSize: false,
|
||||
nextValue: v,
|
||||
});
|
||||
app.store.shouldCaptureIncrement();
|
||||
eventTarget.blur();
|
||||
}
|
||||
}
|
||||
}}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(event) => {
|
||||
const eventTarget = event.target;
|
||||
if (eventTarget instanceof HTMLInputElement) {
|
||||
setInputValue(event.target.value);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!inputValue) {
|
||||
setInputValue(value.toString());
|
||||
}
|
||||
}}
|
||||
disabled={!editable}
|
||||
></input>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsDragInput;
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { ElementsMap, ExcalidrawTextElement } from "../../element/types";
|
||||
import { refreshTextDimensions } from "../../element/newElement";
|
||||
import StatsDragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { mutateElement } from "../../element/mutateElement";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
|
||||
interface FontSizeProps {
|
||||
element: ExcalidrawTextElement;
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const MIN_FONT_SIZE = 4;
|
||||
const STEP_SIZE = 4;
|
||||
|
||||
const FontSize = ({ element, elementsMap }: FontSizeProps) => {
|
||||
const handleFontSizeChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
stateAtStart,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const _stateAtStart = stateAtStart[0];
|
||||
if (_stateAtStart) {
|
||||
if (nextValue) {
|
||||
const nextFontSize = Math.max(Math.round(nextValue), MIN_FONT_SIZE);
|
||||
|
||||
const newElement = {
|
||||
...element,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(element, {
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (_stateAtStart.type === "text") {
|
||||
const originalFontSize = Math.round(_stateAtStart.fontSize);
|
||||
const changeInFontSize = Math.round(accumulatedChange);
|
||||
let nextFontSize = Math.max(
|
||||
originalFontSize + changeInFontSize,
|
||||
MIN_FONT_SIZE,
|
||||
);
|
||||
if (shouldChangeByStepSize) {
|
||||
nextFontSize = getStepSizedValue(nextFontSize, STEP_SIZE);
|
||||
}
|
||||
const newElement = {
|
||||
...element,
|
||||
fontSize: nextFontSize,
|
||||
};
|
||||
const updates = refreshTextDimensions(newElement, null, elementsMap);
|
||||
mutateElement(element, {
|
||||
...updates,
|
||||
fontSize: nextFontSize,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StatsDragInput
|
||||
label="F"
|
||||
value={Math.round(element.fontSize * 10) / 10}
|
||||
elements={[element]}
|
||||
dragInputCallback={handleFontSizeChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FontSize;
|
||||
@@ -0,0 +1,210 @@
|
||||
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 } from "../../element/types";
|
||||
import DragInput from "./DragInput";
|
||||
import type { DragInputCallbackType } from "./DragInput";
|
||||
import { getStepSizedValue } from "./utils";
|
||||
|
||||
interface MultiDimensionProps {
|
||||
property: "width" | "height";
|
||||
elements: ExcalidrawElement[];
|
||||
elementsMap: ElementsMap;
|
||||
}
|
||||
|
||||
const STEP_SIZE = 10;
|
||||
|
||||
const getResizedUpdates = (
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
scale: number,
|
||||
stateAtStart: ExcalidrawElement,
|
||||
) => {
|
||||
const offsetX = stateAtStart.x - anchorX;
|
||||
const offsetY = stateAtStart.y - anchorY;
|
||||
const nextWidth = stateAtStart.width * scale;
|
||||
const nextHeight = stateAtStart.height * scale;
|
||||
const x = anchorX + offsetX * scale;
|
||||
const y = anchorY + offsetY * scale;
|
||||
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
x,
|
||||
y,
|
||||
...rescalePointsInElement(stateAtStart, nextWidth, nextHeight, false),
|
||||
...(isTextElement(stateAtStart)
|
||||
? { fontSize: stateAtStart.fontSize * scale }
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
const resizeElement = (
|
||||
anchorX: number,
|
||||
anchorY: number,
|
||||
property: MultiDimensionProps["property"],
|
||||
scale: number,
|
||||
latestElement: ExcalidrawElement,
|
||||
origElement: ExcalidrawElement,
|
||||
elementsMap: ElementsMap,
|
||||
originalElementsMap: ElementsMap,
|
||||
shouldInformMutation: boolean,
|
||||
) => {
|
||||
const updates = getResizedUpdates(anchorX, anchorY, scale, origElement);
|
||||
|
||||
mutateElement(latestElement, updates, shouldInformMutation);
|
||||
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,
|
||||
},
|
||||
shouldInformMutation,
|
||||
);
|
||||
handleBindTextResize(
|
||||
latestElement,
|
||||
elementsMap,
|
||||
property === "width" ? "e" : "s",
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const MultiDimension = ({
|
||||
property,
|
||||
elements,
|
||||
elementsMap,
|
||||
}: MultiDimensionProps) => {
|
||||
const handleDimensionChange: DragInputCallbackType = ({
|
||||
accumulatedChange,
|
||||
stateAtStart,
|
||||
originalElementsMap,
|
||||
shouldChangeByStepSize,
|
||||
nextValue,
|
||||
}) => {
|
||||
const [x1, y1, x2, y2] = getCommonBounds(stateAtStart);
|
||||
const initialWidth = x2 - x1;
|
||||
const initialHeight = y2 - y1;
|
||||
const keepAspectRatio = true;
|
||||
const aspectRatio = initialWidth / initialHeight;
|
||||
|
||||
if (nextValue !== undefined) {
|
||||
const nextHeight =
|
||||
property === "height" ? nextValue : nextValue / aspectRatio;
|
||||
|
||||
const scale = nextHeight / initialHeight;
|
||||
const anchorX = property === "width" ? x1 : x1 + width / 2;
|
||||
const anchorY = property === "height" ? y1 : y1 + height / 2;
|
||||
|
||||
let i = 0;
|
||||
while (i < stateAtStart.length) {
|
||||
const latestElement = elements[i];
|
||||
const origElement = stateAtStart[i];
|
||||
|
||||
// it should never happen that element and origElement are different
|
||||
// but check just in case
|
||||
if (latestElement.id === origElement.id) {
|
||||
resizeElement(
|
||||
anchorX,
|
||||
anchorY,
|
||||
property,
|
||||
scale,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
i === stateAtStart.length - 1,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const changeInWidth = property === "width" ? accumulatedChange : 0;
|
||||
const changeInHeight = property === "height" ? accumulatedChange : 0;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if (keepAspectRatio) {
|
||||
if (property === "width") {
|
||||
nextHeight = Math.round((nextWidth / aspectRatio) * 100) / 100;
|
||||
} else {
|
||||
nextWidth = Math.round(nextHeight * aspectRatio * 100) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
const scale = nextHeight / initialHeight;
|
||||
const anchorX = property === "width" ? x1 : x1 + width / 2;
|
||||
const anchorY = property === "height" ? y1 : y1 + height / 2;
|
||||
|
||||
let i = 0;
|
||||
while (i < stateAtStart.length) {
|
||||
const latestElement = elements[i];
|
||||
const origElement = stateAtStart[i];
|
||||
|
||||
if (latestElement.id === origElement.id) {
|
||||
resizeElement(
|
||||
anchorX,
|
||||
anchorY,
|
||||
property,
|
||||
scale,
|
||||
latestElement,
|
||||
origElement,
|
||||
elementsMap,
|
||||
originalElementsMap,
|
||||
i === stateAtStart.length - 1,
|
||||
);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
};
|
||||
|
||||
const [x1, y1, x2, y2] = getCommonBounds(elements);
|
||||
const width = x2 - x1;
|
||||
const height = y2 - y1;
|
||||
|
||||
return (
|
||||
<DragInput
|
||||
label={property === "width" ? "W" : "H"}
|
||||
elements={elements}
|
||||
dragInputCallback={handleDimensionChange}
|
||||
value={Math.round((property === "width" ? width : height) * 100) / 100}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiDimension;
|
||||
+40
-1
@@ -1,7 +1,8 @@
|
||||
@import "../css/variables.module.scss";
|
||||
@import "../../css/variables.module.scss";
|
||||
|
||||
.excalidraw {
|
||||
.Stats {
|
||||
width: 204px;
|
||||
position: absolute;
|
||||
top: 64px;
|
||||
right: 12px;
|
||||
@@ -9,6 +10,38 @@
|
||||
z-index: 10;
|
||||
pointer-events: var(--ui-pointerEvents);
|
||||
|
||||
.sectionContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.elementType {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.elementsCount {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.statsItem {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 24px 8px 0;
|
||||
white-space: nowrap;
|
||||
@@ -39,6 +72,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: var(--default-border-color);
|
||||
}
|
||||
|
||||
:root[dir="rtl"] & {
|
||||
left: 12px;
|
||||
right: initial;
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { getCommonBounds } from "../../element/bounds";
|
||||
import type { NonDeletedExcalidrawElement } from "../../element/types";
|
||||
import { t } from "../../i18n";
|
||||
import { getSelectedElements } from "../../scene";
|
||||
import type Scene from "../../scene/Scene";
|
||||
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 "./index.scss";
|
||||
import FontSize from "./FontSize";
|
||||
import MultiDimension from "./MultiDimension";
|
||||
import { elementsAreInSameGroup } from "../../groups";
|
||||
|
||||
interface StatsProps {
|
||||
appState: AppState;
|
||||
scene: Scene;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onClose: () => void;
|
||||
renderCustomStats: ExcalidrawProps["renderCustomStats"];
|
||||
}
|
||||
const STATS_TIMEOUT = 50;
|
||||
|
||||
export const Stats = (props: StatsProps) => {
|
||||
const elements = props.scene.getNonDeletedElements();
|
||||
const elementsMap = props.scene.getNonDeletedElementsMap();
|
||||
const sceneNonce = props.scene.getSceneNonce();
|
||||
// const selectedElements = getTargetElements(elements, props.appState);
|
||||
const selectedElements = getSelectedElements(
|
||||
props.scene.getNonDeletedElementsMap(),
|
||||
props.appState,
|
||||
{
|
||||
includeBoundTextElement: false,
|
||||
},
|
||||
);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="Stats">
|
||||
<Island padding={3}>
|
||||
<div className="section">
|
||||
<div className="close" onClick={props.onClose}>
|
||||
{CloseIcon}
|
||||
</div>
|
||||
<h3>{t("stats.generalStats")}</h3>
|
||||
<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>
|
||||
{props.renderCustomStats?.(elements, props.appState)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedElements.length > 0 && (
|
||||
<div
|
||||
className="section"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<h3>{t("stats.elementStats")}</h3>
|
||||
|
||||
{singleElement && (
|
||||
<div className="sectionContent">
|
||||
<div className="elementType">
|
||||
{t(`element.${singleElement.type}`)}
|
||||
</div>
|
||||
|
||||
<div className="statsItem">
|
||||
<Dimension
|
||||
property="width"
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Dimension
|
||||
property="height"
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<Angle element={singleElement} elementsMap={elementsMap} />
|
||||
{singleElement.type === "text" && (
|
||||
<FontSize
|
||||
element={singleElement}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{singleElement.type === "text" && <div></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">
|
||||
<MultiDimension
|
||||
property="width"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
<MultiDimension
|
||||
property="height"
|
||||
elements={multipleElements}
|
||||
elementsMap={elementsMap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Island>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isFrameLikeElement, isTextElement } from "../../element/typeChecks";
|
||||
import type { ExcalidrawElement } from "../../element/types";
|
||||
|
||||
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);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ type InteractiveCanvasProps = {
|
||||
elementsMap: RenderableElementsMap;
|
||||
visibleElements: readonly NonDeletedExcalidrawElement[];
|
||||
selectedElements: readonly NonDeletedExcalidrawElement[];
|
||||
versionNonce: number | undefined;
|
||||
sceneNonce: number | undefined;
|
||||
selectionNonce: number | undefined;
|
||||
scale: number;
|
||||
appState: InteractiveCanvasAppState;
|
||||
@@ -206,10 +206,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;
|
||||
@@ -112,10 +112,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
|
||||
|
||||
@@ -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)",
|
||||
},
|
||||
});
|
||||
|
||||
//
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
--sat: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
body.dragResize,
|
||||
body.dragResize a:hover,
|
||||
body.dragResize * {
|
||||
cursor: ew-resize;
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
--ui-font: Assistant, system-ui, BlinkMacSystemFont, -apple-system, Segoe UI,
|
||||
Roboto, Helvetica, Arial, sans-serif;
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
@@ -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": [
|
||||
{
|
||||
@@ -378,6 +380,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 4`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id48",
|
||||
@@ -478,6 +481,7 @@ 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",
|
||||
@@ -652,6 +656,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 2`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id41",
|
||||
@@ -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": [
|
||||
{
|
||||
@@ -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": [
|
||||
{
|
||||
@@ -1194,6 +1201,7 @@ 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,
|
||||
@@ -1234,6 +1242,7 @@ 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,
|
||||
@@ -1566,6 +1575,7 @@ 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",
|
||||
@@ -1608,6 +1618,7 @@ 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",
|
||||
@@ -1650,6 +1661,7 @@ 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",
|
||||
@@ -1692,6 +1704,7 @@ 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",
|
||||
@@ -1734,6 +1747,7 @@ 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",
|
||||
@@ -1774,6 +1788,7 @@ 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",
|
||||
@@ -2022,6 +2037,7 @@ 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",
|
||||
@@ -2062,6 +2078,7 @@ 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",
|
||||
@@ -2102,6 +2119,7 @@ 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",
|
||||
@@ -2143,6 +2161,7 @@ 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",
|
||||
@@ -2406,6 +2425,7 @@ 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",
|
||||
@@ -2446,6 +2466,7 @@ 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",
|
||||
@@ -2487,6 +2508,7 @@ CONTAINER",
|
||||
exports[`Test Transform > should transform to text containers when label provided 9`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id15",
|
||||
@@ -2530,6 +2552,7 @@ CONTAINER",
|
||||
exports[`Test Transform > should transform to text containers when label provided 10`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id16",
|
||||
@@ -2571,6 +2594,7 @@ TEXT CONTAINER",
|
||||
exports[`Test Transform > should transform to text containers when label provided 11`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id17",
|
||||
@@ -2613,6 +2637,7 @@ CONTAINER",
|
||||
exports[`Test Transform > should transform to text containers when label provided 12`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": null,
|
||||
"containerId": "id18",
|
||||
|
||||
@@ -208,7 +208,7 @@ const restoreElement = (
|
||||
verticalAlign: element.verticalAlign || DEFAULT_VERTICAL_ALIGN,
|
||||
containerId: element.containerId ?? null,
|
||||
originalText: element.originalText || text,
|
||||
|
||||
autoResize: element.autoResize ?? true,
|
||||
lineHeight,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { isLinearElementType } from "./typeChecks";
|
||||
export {
|
||||
newElement,
|
||||
newTextElement,
|
||||
updateTextElement,
|
||||
refreshTextDimensions,
|
||||
newLinearElement,
|
||||
newImageElement,
|
||||
|
||||
@@ -98,7 +98,7 @@ export const mutateElement = <TElement extends Mutable<ExcalidrawElement>>(
|
||||
element.updated = getUpdatedTimestamp();
|
||||
|
||||
if (informMutation) {
|
||||
Scene.getScene(element)?.informMutation();
|
||||
Scene.getScene(element)?.triggerUpdate();
|
||||
}
|
||||
|
||||
return element;
|
||||
@@ -107,6 +107,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;
|
||||
for (const key in updates) {
|
||||
@@ -123,7 +125,7 @@ export const newElementWith = <TElement extends ExcalidrawElement>(
|
||||
}
|
||||
}
|
||||
|
||||
if (!didChange) {
|
||||
if (!didChange && !force) {
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -215,6 +215,7 @@ const getTextElementPositionOffsets = (
|
||||
export const newTextElement = (
|
||||
opts: {
|
||||
text: string;
|
||||
originalText?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: FontFamilyValues;
|
||||
textAlign?: TextAlign;
|
||||
@@ -222,6 +223,7 @@ 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;
|
||||
@@ -240,24 +242,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;
|
||||
};
|
||||
|
||||
@@ -271,18 +277,25 @@ const getAdjustedDimensions = (
|
||||
width: number;
|
||||
height: number;
|
||||
} => {
|
||||
const { width: nextWidth, height: nextHeight } = measureText(
|
||||
let { width: nextWidth, height: nextHeight } = measureText(
|
||||
nextText,
|
||||
getFontString(element),
|
||||
element.lineHeight,
|
||||
);
|
||||
|
||||
// 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 = measureText(
|
||||
element.text,
|
||||
@@ -343,38 +356,19 @@ export const refreshTextDimensions = (
|
||||
if (textElement.isDeleted) {
|
||||
return;
|
||||
}
|
||||
if (container) {
|
||||
if (container || !textElement.autoResize) {
|
||||
text = wrapText(
|
||||
text,
|
||||
getFontString(textElement),
|
||||
getBoundTextMaxWidth(container, textElement),
|
||||
container
|
||||
? getBoundTextMaxWidth(container, textElement)
|
||||
: textElement.width,
|
||||
);
|
||||
}
|
||||
const dimensions = getAdjustedDimensions(textElement, elementsMap, text);
|
||||
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";
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import {
|
||||
BOUND_TEXT_PADDING,
|
||||
MIN_FONT_SIZE,
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
} from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import { rotate, centerPoint, rotatePoint } from "../math";
|
||||
@@ -45,6 +49,9 @@ import {
|
||||
handleBindTextResize,
|
||||
getBoundTextMaxWidth,
|
||||
getApproxMinLineHeight,
|
||||
wrapText,
|
||||
measureText,
|
||||
getMinCharWidth,
|
||||
} from "./textElement";
|
||||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { isInGroup } from "../groups";
|
||||
@@ -84,14 +91,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 +182,7 @@ const rotateSingleElement = (
|
||||
}
|
||||
};
|
||||
|
||||
const rescalePointsInElement = (
|
||||
export const rescalePointsInElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
width: number,
|
||||
height: number,
|
||||
@@ -197,7 +199,7 @@ const rescalePointsInElement = (
|
||||
}
|
||||
: {};
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
export const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
elementsMap: ElementsMap,
|
||||
nextWidth: number,
|
||||
@@ -223,9 +225,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 +248,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 +323,102 @@ 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 =
|
||||
getMinCharWidth(getFontString(element)) + BOUND_TEXT_PADDING * 2;
|
||||
|
||||
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 +977,7 @@ export const resizeMultipleElements = (
|
||||
}
|
||||
}
|
||||
|
||||
Scene.getScene(elementsAndUpdates[0].element)?.informMutation();
|
||||
Scene.getScene(elementsAndUpdates[0].element)?.triggerUpdate();
|
||||
};
|
||||
|
||||
const rotateMultipleElements = (
|
||||
@@ -938,7 +1039,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],
|
||||
|
||||
@@ -48,7 +48,7 @@ export const redrawTextBoundingBox = (
|
||||
textElement: ExcalidrawTextElement,
|
||||
container: ExcalidrawElement | null,
|
||||
elementsMap: ElementsMap,
|
||||
informMutation: boolean = true,
|
||||
informMutation = true,
|
||||
) => {
|
||||
let maxWidth = undefined;
|
||||
const boundTextUpdates = {
|
||||
@@ -62,21 +62,27 @@ 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 = wrapText(
|
||||
textElement.originalText,
|
||||
getFontString(textElement),
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
const metrics = measureText(
|
||||
boundTextUpdates.text,
|
||||
getFontString(textElement),
|
||||
textElement.lineHeight,
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
if (container) {
|
||||
|
||||
@@ -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 };
|
||||
@@ -800,26 +911,15 @@ 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");
|
||||
|
||||
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);
|
||||
@@ -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",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -79,12 +79,14 @@ export const textWysiwyg = ({
|
||||
app,
|
||||
}: {
|
||||
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;
|
||||
@@ -129,11 +131,8 @@ export const textWysiwyg = ({
|
||||
app.scene.getNonDeletedElementsMap(),
|
||||
);
|
||||
let maxWidth = updatedTextElement.width;
|
||||
|
||||
let maxHeight = updatedTextElement.height;
|
||||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
const textElementHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
@@ -226,6 +225,8 @@ export const textWysiwyg = ({
|
||||
if (!container) {
|
||||
maxWidth = (appState.width - 8 - viewportX) / appState.zoom.value;
|
||||
textElementWidth = Math.min(textElementWidth, maxWidth);
|
||||
} else {
|
||||
textElementWidth += 0.5;
|
||||
}
|
||||
|
||||
// Make sure text editor height doesn't go beyond viewport
|
||||
@@ -260,6 +261,7 @@ export const textWysiwyg = ({
|
||||
if (isTestEnv()) {
|
||||
editable.style.fontFamily = getFontFamilyString(updatedTextElement);
|
||||
}
|
||||
|
||||
mutateElement(updatedTextElement, { x: coordX, y: coordY });
|
||||
}
|
||||
};
|
||||
@@ -276,7 +278,7 @@ export const textWysiwyg = ({
|
||||
let whiteSpace = "pre";
|
||||
let wordBreak = "normal";
|
||||
|
||||
if (isBoundToContainer(element)) {
|
||||
if (isBoundToContainer(element) || !element.autoResize) {
|
||||
whiteSpace = "pre-wrap";
|
||||
wordBreak = "break-word";
|
||||
}
|
||||
@@ -499,14 +501,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) {
|
||||
@@ -538,9 +538,8 @@ export const textWysiwyg = ({
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
text,
|
||||
viaKeyboard: submittedViaKeyboard,
|
||||
originalText: editable.value,
|
||||
nextOriginalText: editable.value,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -644,7 +643,7 @@ export const textWysiwyg = ({
|
||||
};
|
||||
|
||||
// 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",
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Bounds } from "./bounds";
|
||||
import { getElementAbsoluteCoords } from "./bounds";
|
||||
import { rotate } from "../math";
|
||||
import type { Device, InteractiveCanvasAppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isFrameLikeElement, isLinearElement } from "./typeChecks";
|
||||
import {
|
||||
DEFAULT_TRANSFORM_HANDLE_SPACING,
|
||||
@@ -65,13 +64,6 @@ export const OMIT_SIDES_FOR_FRAME = {
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_LINE_SLASH = {
|
||||
e: true,
|
||||
s: true,
|
||||
@@ -290,8 +282,6 @@ export const getTransformHandles = (
|
||||
omitSides = OMIT_SIDES_FOR_LINE_BACKSLASH;
|
||||
}
|
||||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameLikeElement(element)) {
|
||||
omitSides = {
|
||||
...omitSides,
|
||||
|
||||
@@ -193,6 +193,13 @@ export type ExcalidrawTextElement = _ExcalidrawElementBase &
|
||||
verticalAlign: VerticalAlign;
|
||||
containerId: ExcalidrawGenericElement["id"] | null;
|
||||
originalText: string;
|
||||
/**
|
||||
* If `true` the width will fit the text. If `false`, the text will
|
||||
* wrap to fit the width.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
autoResize: boolean;
|
||||
/**
|
||||
* Unitless line height (aligned to W3C). To get line height in px, multiply
|
||||
* with font size (using `getLineHeightInPx` helper).
|
||||
|
||||
@@ -133,27 +133,11 @@ const getMovedIndicesGroups = (
|
||||
let i = 0;
|
||||
|
||||
while (i < elements.length) {
|
||||
if (
|
||||
movedElements.has(elements[i].id) &&
|
||||
!isValidFractionalIndex(
|
||||
elements[i]?.index,
|
||||
elements[i - 1]?.index,
|
||||
elements[i + 1]?.index,
|
||||
)
|
||||
) {
|
||||
if (movedElements.has(elements[i].id)) {
|
||||
const indicesGroup = [i - 1, i]; // push the lower bound index as the first item
|
||||
|
||||
while (++i < elements.length) {
|
||||
if (
|
||||
!(
|
||||
movedElements.has(elements[i].id) &&
|
||||
!isValidFractionalIndex(
|
||||
elements[i]?.index,
|
||||
elements[i - 1]?.index,
|
||||
elements[i + 1]?.index,
|
||||
)
|
||||
)
|
||||
) {
|
||||
if (!movedElements.has(elements[i].id)) {
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -148,7 +148,9 @@
|
||||
"discordChat": "Discord chat",
|
||||
"zoomToFitViewport": "Zoom to fit in viewport",
|
||||
"zoomToFitSelection": "Zoom to fit selection",
|
||||
"zoomToFit": "Zoom to fit all elements"
|
||||
"zoomToFit": "Zoom to fit all elements",
|
||||
"installPWA": "Install Excalidraw locally (PWA)",
|
||||
"autoResize": "Enable text auto-resizing"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "No items added yet...",
|
||||
@@ -268,6 +270,22 @@
|
||||
"mermaidToExcalidraw": "Mermaid to Excalidraw",
|
||||
"magicSettings": "AI settings"
|
||||
},
|
||||
"element": {
|
||||
"rectangle": "Rectangle",
|
||||
"diamond": "Diamond",
|
||||
"ellipse": "Ellipse",
|
||||
"arrow": "Arrow",
|
||||
"line": "Line",
|
||||
"freedraw": "Freedraw",
|
||||
"text": "Text",
|
||||
"image": "Image",
|
||||
"group": "Group",
|
||||
"frame": "Frame",
|
||||
"magicframe": "Wireframe to code",
|
||||
"embeddable": "Web Embed",
|
||||
"selection": "Selection",
|
||||
"iframe": "IFrame"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas actions",
|
||||
"selectedShapeActions": "Selected shape actions",
|
||||
@@ -441,7 +459,9 @@
|
||||
"scene": "Scene",
|
||||
"selected": "Selected",
|
||||
"storage": "Storage",
|
||||
"title": "Stats for nerds",
|
||||
"title": "Stats",
|
||||
"generalStats": "General stats",
|
||||
"elementStats": "Element stats",
|
||||
"total": "Total",
|
||||
"version": "Version",
|
||||
"versionCopy": "Click to copy",
|
||||
|
||||
@@ -475,6 +475,14 @@ export const isRightAngle = (angle: number) => {
|
||||
return Math.round((angle / Math.PI) * 10000) % 5000 === 0;
|
||||
};
|
||||
|
||||
export const radianToDegree = (r: number) => {
|
||||
return (r * 180) / Math.PI;
|
||||
};
|
||||
|
||||
export const degreeToRadian = (d: number) => {
|
||||
return (d / 180) * Math.PI;
|
||||
};
|
||||
|
||||
// Given two ranges, return if the two ranges overlap with each other
|
||||
// e.g. [1, 3] overlaps with [2, 4] while [1, 3] does not overlap with [4, 5]
|
||||
export const rangesOverlap = (
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@excalidraw/laser-pointer": "1.3.1",
|
||||
"@excalidraw/mermaid-to-excalidraw": "0.3.0",
|
||||
"@excalidraw/mermaid-to-excalidraw": "1.0.0",
|
||||
"@excalidraw/random-username": "1.1.0",
|
||||
"@radix-ui/react-popover": "1.0.3",
|
||||
"@radix-ui/react-tabs": "1.0.2",
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { isTextElement, refreshTextDimensions } from "../element";
|
||||
import { isTextElement } from "../element";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getContainerElement } from "../element/textElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
@@ -12,17 +10,9 @@ import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
export class Fonts {
|
||||
private scene: Scene;
|
||||
private onSceneUpdated: () => void;
|
||||
|
||||
constructor({
|
||||
scene,
|
||||
onSceneUpdated,
|
||||
}: {
|
||||
scene: Scene;
|
||||
onSceneUpdated: () => void;
|
||||
}) {
|
||||
constructor({ scene }: { scene: Scene }) {
|
||||
this.scene = scene;
|
||||
this.onSceneUpdated = onSceneUpdated;
|
||||
}
|
||||
|
||||
// it's ok to track fonts across multiple instances only once, so let's use
|
||||
@@ -57,22 +47,16 @@ export class Fonts {
|
||||
let didUpdate = false;
|
||||
|
||||
this.scene.mapElements((element) => {
|
||||
if (isTextElement(element) && !isBoundToContainer(element)) {
|
||||
ShapeCache.delete(element);
|
||||
if (isTextElement(element)) {
|
||||
didUpdate = true;
|
||||
return newElementWith(element, {
|
||||
...refreshTextDimensions(
|
||||
element,
|
||||
getContainerElement(element, this.scene.getNonDeletedElementsMap()),
|
||||
this.scene.getNonDeletedElementsMap(),
|
||||
),
|
||||
});
|
||||
ShapeCache.delete(element);
|
||||
return newElementWith(element, {}, true);
|
||||
}
|
||||
return element;
|
||||
});
|
||||
|
||||
if (didUpdate) {
|
||||
this.onSceneUpdated();
|
||||
this.scene.triggerUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -107,9 +107,8 @@ export class Renderer {
|
||||
width,
|
||||
editingElement,
|
||||
pendingImageElementId,
|
||||
// unused but serves we cache on it to invalidate elements if they
|
||||
// get mutated
|
||||
versionNonce: _versionNonce,
|
||||
// cache-invalidation nonce
|
||||
sceneNonce: _sceneNonce,
|
||||
}: {
|
||||
zoom: AppState["zoom"];
|
||||
offsetLeft: AppState["offsetLeft"];
|
||||
@@ -120,7 +119,7 @@ export class Renderer {
|
||||
width: AppState["width"];
|
||||
editingElement: AppState["editingElement"];
|
||||
pendingImageElementId: AppState["pendingImageElementId"];
|
||||
versionNonce: ReturnType<InstanceType<typeof Scene>["getVersionNonce"]>;
|
||||
sceneNonce: ReturnType<InstanceType<typeof Scene>["getSceneNonce"]>;
|
||||
}) => {
|
||||
const elements = this.scene.getNonDeletedElements();
|
||||
|
||||
|
||||
@@ -138,7 +138,17 @@ class Scene {
|
||||
elements: null,
|
||||
cache: new Map(),
|
||||
};
|
||||
private versionNonce: number | undefined;
|
||||
/**
|
||||
* Random integer regenerated each scene update.
|
||||
*
|
||||
* Does not relate to elements versions, it's only a renderer
|
||||
* cache-invalidation nonce at the moment.
|
||||
*/
|
||||
private sceneNonce: number | undefined;
|
||||
|
||||
getSceneNonce() {
|
||||
return this.sceneNonce;
|
||||
}
|
||||
|
||||
getNonDeletedElementsMap() {
|
||||
return this.nonDeletedElementsMap;
|
||||
@@ -214,10 +224,6 @@ class Scene {
|
||||
return (this.elementsMap.get(id) as T | undefined) || null;
|
||||
}
|
||||
|
||||
getVersionNonce() {
|
||||
return this.versionNonce;
|
||||
}
|
||||
|
||||
getNonDeletedElement(
|
||||
id: ExcalidrawElement["id"],
|
||||
): NonDeleted<ExcalidrawElement> | null {
|
||||
@@ -286,18 +292,18 @@ class Scene {
|
||||
this.frames = nextFrameLikes;
|
||||
this.nonDeletedFramesLikes = getNonDeletedElements(this.frames).elements;
|
||||
|
||||
this.informMutation();
|
||||
this.triggerUpdate();
|
||||
}
|
||||
|
||||
informMutation() {
|
||||
this.versionNonce = randomInteger();
|
||||
triggerUpdate() {
|
||||
this.sceneNonce = randomInteger();
|
||||
|
||||
for (const callback of Array.from(this.callbacks)) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
addCallback(cb: SceneStateCallback): SceneStateCallbackRemover {
|
||||
onUpdate(cb: SceneStateCallback): SceneStateCallbackRemover {
|
||||
if (this.callbacks.has(cb)) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@ export const exportToSvg = async (
|
||||
assetPath =
|
||||
window.EXCALIDRAW_ASSET_PATH ||
|
||||
`https://unpkg.com/${import.meta.env.VITE_PKG_NAME}@${
|
||||
import.meta.env.PKG_VERSION
|
||||
import.meta.env.VITE_PKG_VERSION
|
||||
}`;
|
||||
|
||||
if (assetPath?.startsWith("/")) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { deepCopyElement } from "./element/newElement";
|
||||
import type { OrderedExcalidrawElement } from "./element/types";
|
||||
import { Emitter } from "./emitter";
|
||||
import type { AppState, ObservedAppState } from "./types";
|
||||
import type { ValueOf } from "./utility-types";
|
||||
import { isShallowEqual } from "./utils";
|
||||
|
||||
// hidden non-enumerable property for runtime checks
|
||||
@@ -35,16 +36,41 @@ const isObservedAppState = (
|
||||
): appState is ObservedAppState =>
|
||||
!!Reflect.get(appState, hiddenObservedAppStateProp);
|
||||
|
||||
export type StoreActionType = "capture" | "update" | "none";
|
||||
|
||||
export const StoreAction: {
|
||||
[K in Uppercase<StoreActionType>]: StoreActionType;
|
||||
} = {
|
||||
export const StoreAction = {
|
||||
/**
|
||||
* Immediately undoable.
|
||||
*
|
||||
* Use for updates which should be captured.
|
||||
* Should be used for most of the local updates.
|
||||
*
|
||||
* These updates will _immediately_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
CAPTURE: "capture",
|
||||
/**
|
||||
* Never undoable.
|
||||
*
|
||||
* Use for updates which should never be recorded, such as remote updates
|
||||
* or scene initialization.
|
||||
*
|
||||
* These updates will _never_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
UPDATE: "update",
|
||||
/**
|
||||
* Eventually undoable.
|
||||
*
|
||||
* Use for updates which should not be captured immediately - likely
|
||||
* exceptions which are part of some async multi-step process. Otherwise, all
|
||||
* such updates would end up being captured with the next
|
||||
* `StoreAction.CAPTURE` - triggered either by the next `updateScene`
|
||||
* or internally by the editor.
|
||||
*
|
||||
* These updates will _eventually_ make it to the local undo / redo stacks.
|
||||
*/
|
||||
NONE: "none",
|
||||
} as const;
|
||||
|
||||
export type StoreActionType = ValueOf<typeof StoreAction>;
|
||||
|
||||
/**
|
||||
* Represent an increment to the Store.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
"separator",
|
||||
{
|
||||
"icon": <svg
|
||||
aria-hidden="true"
|
||||
@@ -26,7 +27,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -129,7 +130,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -181,7 +182,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -247,7 +248,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -287,7 +288,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -326,6 +327,16 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"icon": null,
|
||||
"label": "labels.autoResize",
|
||||
"name": "autoResize",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.unbindText",
|
||||
"name": "unbindText",
|
||||
@@ -387,11 +398,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -438,7 +453,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -482,11 +497,15 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -535,7 +554,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -584,7 +603,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -624,7 +643,7 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4414,6 +4433,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
"separator",
|
||||
{
|
||||
"icon": <svg
|
||||
aria-hidden="true"
|
||||
@@ -4428,7 +4448,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4531,7 +4551,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4583,7 +4603,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4649,7 +4669,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4689,7 +4709,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4728,6 +4748,16 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"icon": null,
|
||||
"label": "labels.autoResize",
|
||||
"name": "autoResize",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.unbindText",
|
||||
"name": "unbindText",
|
||||
@@ -4789,11 +4819,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4840,7 +4874,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4884,11 +4918,15 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4937,7 +4975,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -4986,7 +5024,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5026,7 +5064,7 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5514,6 +5552,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
"separator",
|
||||
{
|
||||
"icon": <svg
|
||||
aria-hidden="true"
|
||||
@@ -5528,7 +5567,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5631,7 +5670,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5683,7 +5722,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5749,7 +5788,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5789,7 +5828,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5828,6 +5867,16 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"icon": null,
|
||||
"label": "labels.autoResize",
|
||||
"name": "autoResize",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.unbindText",
|
||||
"name": "unbindText",
|
||||
@@ -5889,11 +5938,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5940,7 +5993,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -5984,11 +6037,15 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -6037,7 +6094,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -6086,7 +6143,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -6126,7 +6183,7 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -6684,7 +6741,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -6736,7 +6793,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -6948,7 +7005,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7004,7 +7061,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7048,7 +7105,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7101,7 +7158,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7143,7 +7200,7 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7321,6 +7378,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
"separator",
|
||||
{
|
||||
"icon": <svg
|
||||
aria-hidden="true"
|
||||
@@ -7335,7 +7393,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7438,7 +7496,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7490,7 +7548,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7556,7 +7614,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7596,7 +7654,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7635,6 +7693,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"icon": null,
|
||||
"label": "labels.autoResize",
|
||||
"name": "autoResize",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.unbindText",
|
||||
"name": "unbindText",
|
||||
@@ -7696,11 +7764,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7747,7 +7819,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7791,11 +7863,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7844,7 +7920,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7893,7 +7969,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -7933,7 +8009,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8188,6 +8264,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"collaborators": Map {},
|
||||
"contextMenu": {
|
||||
"items": [
|
||||
"separator",
|
||||
{
|
||||
"icon": <svg
|
||||
aria-hidden="true"
|
||||
@@ -8202,7 +8279,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8305,7 +8382,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8357,7 +8434,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8423,7 +8500,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8463,7 +8540,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8502,6 +8579,16 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"icon": null,
|
||||
"label": "labels.autoResize",
|
||||
"name": "autoResize",
|
||||
"perform": [Function],
|
||||
"predicate": [Function],
|
||||
"trackEvent": {
|
||||
"category": "element",
|
||||
},
|
||||
},
|
||||
{
|
||||
"label": "labels.unbindText",
|
||||
"name": "unbindText",
|
||||
@@ -8563,11 +8650,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8614,7 +8705,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8658,11 +8749,15 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
transform="rotate(180)"
|
||||
style={
|
||||
{
|
||||
"transform": "rotate(180deg)",
|
||||
}
|
||||
}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8711,7 +8806,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.5}
|
||||
strokeWidth={"1.50000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8760,7 +8855,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
@@ -8800,7 +8895,7 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g
|
||||
strokeWidth={1.25}
|
||||
strokeWidth={"1.25000"}
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ exports[`Test Linear Elements > Test bound text element > should match styles fo
|
||||
class="excalidraw-wysiwyg"
|
||||
data-type="wysiwyg"
|
||||
dir="auto"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||
style="position: absolute; display: inline-block; min-height: 1em; backface-visibility: hidden; margin: 0px; padding: 0px; border: 0px; outline: 0; resize: none; background: transparent; overflow: hidden; z-index: var(--zIndex-wysiwyg); word-break: break-word; white-space: pre-wrap; overflow-wrap: break-word; box-sizing: content-box; width: 10.5px; height: 25px; left: 35px; top: 7.5px; transform: translate(0px, 0px) scale(1) rotate(0deg); text-align: center; vertical-align: middle; color: rgb(30, 30, 30); opacity: 1; filter: var(--theme-filter); max-height: 992.5px; font: Emoji 20px 20px; line-height: 1.25; font-family: Virgil, Segoe UI Emoji;"
|
||||
tabindex="0"
|
||||
wrap="off"
|
||||
/>
|
||||
|
||||
@@ -189,13 +189,13 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"endArrowhead": "arrow",
|
||||
"endBinding": {
|
||||
"elementId": "id1",
|
||||
"focus": -0.46666666666666673,
|
||||
"focus": "-0.46667",
|
||||
"gap": 10,
|
||||
},
|
||||
"fillStyle": "solid",
|
||||
"frameId": null,
|
||||
"groupIds": [],
|
||||
"height": 81.48231043525051,
|
||||
"height": "81.48231",
|
||||
"id": "id2",
|
||||
"index": "a2",
|
||||
"isDeleted": false,
|
||||
@@ -210,7 +210,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
],
|
||||
[
|
||||
81,
|
||||
81.48231043525051,
|
||||
"81.48231",
|
||||
],
|
||||
],
|
||||
"roughness": 1,
|
||||
@@ -221,7 +221,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"startArrowhead": null,
|
||||
"startBinding": {
|
||||
"elementId": "id0",
|
||||
"focus": -0.6000000000000001,
|
||||
"focus": "-0.60000",
|
||||
"gap": 10,
|
||||
},
|
||||
"strokeColor": "#1e1e1e",
|
||||
@@ -233,6 +233,6 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"versionNonce": 2066753033,
|
||||
"width": 81,
|
||||
"x": 110,
|
||||
"y": 49.981789081137734,
|
||||
"y": "49.98179",
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -336,7 +336,7 @@ History {
|
||||
"groupIds": [
|
||||
"id5",
|
||||
],
|
||||
"index": "a1V",
|
||||
"index": "a2",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [],
|
||||
@@ -348,9 +348,11 @@ History {
|
||||
"groupIds": [
|
||||
"id5",
|
||||
],
|
||||
"index": "a3",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [],
|
||||
"index": "a2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -719,7 +721,7 @@ History {
|
||||
"groupIds": [
|
||||
"id4",
|
||||
],
|
||||
"index": "a1V",
|
||||
"index": "a2",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [],
|
||||
@@ -731,9 +733,11 @@ History {
|
||||
"groupIds": [
|
||||
"id4",
|
||||
],
|
||||
"index": "a3",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [],
|
||||
"index": "a2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1856,7 +1860,7 @@ History {
|
||||
"groupIds": [
|
||||
"id5",
|
||||
],
|
||||
"index": "a1V",
|
||||
"index": "a2",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [],
|
||||
@@ -1868,9 +1872,11 @@ History {
|
||||
"groupIds": [
|
||||
"id5",
|
||||
],
|
||||
"index": "a3",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [],
|
||||
"index": "a2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -10752,7 +10758,7 @@ exports[`regression tests > pinch-to-zoom works > [end of test] appState 1`] = `
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollX": -2.916666666666668,
|
||||
"scrollX": "-6.25000",
|
||||
"scrollY": 0,
|
||||
"scrolledOutside": false,
|
||||
"selectedElementIds": {},
|
||||
@@ -12810,7 +12816,7 @@ History {
|
||||
"id5",
|
||||
"id3",
|
||||
],
|
||||
"index": "a1V",
|
||||
"index": "a2",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [
|
||||
@@ -12825,11 +12831,13 @@ History {
|
||||
"id5",
|
||||
"id3",
|
||||
],
|
||||
"index": "a3",
|
||||
},
|
||||
"inserted": {
|
||||
"groupIds": [
|
||||
"id3",
|
||||
],
|
||||
"index": "a2",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -13688,8 +13696,8 @@ exports[`regression tests > two-finger scroll works > [end of test] appState 1`]
|
||||
"pendingImageElementId": null,
|
||||
"previousSelectedElementIds": {},
|
||||
"resizingElement": null,
|
||||
"scrollX": 10,
|
||||
"scrollY": -10,
|
||||
"scrollX": 20,
|
||||
"scrollY": "-18.53553",
|
||||
"scrolledOutside": false,
|
||||
"selectedElementIds": {},
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
|
||||
@@ -302,6 +302,7 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
||||
exports[`restoreElements > should restore text element correctly passing value for each attribute 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"containerId": null,
|
||||
@@ -315,7 +316,7 @@ exports[`restoreElements > should restore text element correctly passing value f
|
||||
"id": "id-text01",
|
||||
"index": "a0",
|
||||
"isDeleted": false,
|
||||
"lineHeight": 1.25,
|
||||
"lineHeight": "1.25000",
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
@@ -337,13 +338,14 @@ exports[`restoreElements > should restore text element correctly passing value f
|
||||
"verticalAlign": "middle",
|
||||
"width": 100,
|
||||
"x": -20,
|
||||
"y": -8.75,
|
||||
"y": "-8.75000",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`restoreElements > should restore text element correctly with unknown font family, null text and undefined alignment 1`] = `
|
||||
{
|
||||
"angle": 0,
|
||||
"autoResize": true,
|
||||
"backgroundColor": "transparent",
|
||||
"boundElements": [],
|
||||
"containerId": null,
|
||||
@@ -357,7 +359,7 @@ exports[`restoreElements > should restore text element correctly with unknown fo
|
||||
"id": "id-text01",
|
||||
"index": "a0",
|
||||
"isDeleted": true,
|
||||
"lineHeight": 1.25,
|
||||
"lineHeight": "1.25000",
|
||||
"link": null,
|
||||
"locked": false,
|
||||
"opacity": 100,
|
||||
|
||||
@@ -77,97 +77,6 @@ describe("sync invalid indices with array order", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("should NOT sync index when it is already valid", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a4" },
|
||||
],
|
||||
movedElements: ["A"],
|
||||
expect: {
|
||||
validInput: true,
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a4" },
|
||||
],
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
validInput: true,
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should NOT sync indices when they are already valid", () => {
|
||||
{
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["B", "C"],
|
||||
expect: {
|
||||
// this should not sync 'C'
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["A", "B"],
|
||||
expect: {
|
||||
// but this should sync 'A' as it's invalid!
|
||||
unchangedElements: ["C"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
{ id: "D", index: "a1" },
|
||||
{ id: "E", index: "a2" },
|
||||
],
|
||||
movedElements: ["B", "D", "E"],
|
||||
expect: {
|
||||
// should not sync 'E'
|
||||
unchangedElements: ["A", "C", "E"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a0" },
|
||||
{ id: "D", index: "a2" },
|
||||
{ id: "E" },
|
||||
{ id: "F", index: "a3" },
|
||||
{ id: "G" },
|
||||
{ id: "H", index: "a1" },
|
||||
{ id: "I", index: "a2" },
|
||||
{ id: "J" },
|
||||
],
|
||||
movedElements: ["A", "B", "D", "E", "F", "G", "J"],
|
||||
expect: {
|
||||
// should not sync 'D' and 'F'
|
||||
unchangedElements: ["C", "D", "F"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync when fractional index is not defined", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [{ id: "A" }],
|
||||
@@ -384,6 +293,122 @@ describe("sync invalid indices with array order", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("should sync all moved elements regardless of their validity", () => {
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a4" },
|
||||
],
|
||||
movedElements: ["A"],
|
||||
expect: {
|
||||
validInput: true,
|
||||
unchangedElements: ["B"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a2" },
|
||||
{ id: "B", index: "a4" },
|
||||
],
|
||||
movedElements: ["B"],
|
||||
expect: {
|
||||
validInput: true,
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "C", index: "a2" },
|
||||
{ id: "D", index: "a3" },
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a1" },
|
||||
],
|
||||
movedElements: ["C", "D"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "D", index: "a4" },
|
||||
{ id: "C", index: "a3" },
|
||||
{ id: "F", index: "a6" },
|
||||
{ id: "E", index: "a5" },
|
||||
{ id: "H", index: "a8" },
|
||||
{ id: "G", index: "a7" },
|
||||
{ id: "I", index: "a9" },
|
||||
],
|
||||
movedElements: ["D", "F", "H"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "B", "C", "E", "G", "I"],
|
||||
},
|
||||
});
|
||||
|
||||
{
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["B", "C"],
|
||||
expect: {
|
||||
unchangedElements: ["A"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a1" },
|
||||
{ id: "B", index: "a0" },
|
||||
{ id: "C", index: "a2" },
|
||||
],
|
||||
movedElements: ["A", "B"],
|
||||
expect: {
|
||||
unchangedElements: ["C"],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A", index: "a0" },
|
||||
{ id: "B", index: "a2" },
|
||||
{ id: "C", index: "a1" },
|
||||
{ id: "D", index: "a1" },
|
||||
{ id: "E", index: "a2" },
|
||||
],
|
||||
movedElements: ["B", "D", "E"],
|
||||
expect: {
|
||||
unchangedElements: ["A", "C"],
|
||||
},
|
||||
});
|
||||
|
||||
testMovedIndicesSync({
|
||||
elements: [
|
||||
{ id: "A" },
|
||||
{ id: "B" },
|
||||
{ id: "C", index: "a0" },
|
||||
{ id: "D", index: "a2" },
|
||||
{ id: "E" },
|
||||
{ id: "F", index: "a3" },
|
||||
{ id: "G" },
|
||||
{ id: "H", index: "a1" },
|
||||
{ id: "I", index: "a2" },
|
||||
{ id: "J" },
|
||||
],
|
||||
movedElements: ["A", "B", "D", "E", "F", "G", "J"],
|
||||
expect: {
|
||||
unchangedElements: ["C", "H", "I"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("should generate fractions for explicitly moved elements", () => {
|
||||
describe("should generate a fraction between 'A' and 'C'", () => {
|
||||
testMovedIndicesSync({
|
||||
|
||||
@@ -10,8 +10,9 @@ import { Excalidraw } from "../index";
|
||||
import { Keyboard, Pointer, UI } from "./helpers/ui";
|
||||
import { API } from "./helpers/api";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { fireEvent, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, queryByTestId, waitFor } from "@testing-library/react";
|
||||
import { createUndoAction, createRedoAction } from "../actions/actionHistory";
|
||||
import { actionToggleViewMode } from "../actions/actionToggleViewMode";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "../constants";
|
||||
import type { AppState, ExcalidrawImperativeAPI } from "../types";
|
||||
import { arrayToMap, resolvablePromise } from "../utils";
|
||||
@@ -49,7 +50,6 @@ const checkpoint = (name: string) => {
|
||||
expect(renderStaticScene.mock.calls.length).toMatchSnapshot(
|
||||
`[${name}] number of renders`,
|
||||
);
|
||||
|
||||
// `scrolledOutside` does not appear to be stable between test runs
|
||||
// `selectedLinearElemnt` includes `startBindingElement` containing seed and versionNonce
|
||||
const {
|
||||
@@ -1688,6 +1688,129 @@ describe("history", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable undo/redo buttons when stacks empty", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
const redoAction = createRedoAction(h.history, h.store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
});
|
||||
|
||||
const undoButton = queryByTestId(container, "button-undo");
|
||||
const redoButton = queryByTestId(container, "button-redo");
|
||||
|
||||
expect(undoButton).toBeDisabled();
|
||||
expect(redoButton).toBeDisabled();
|
||||
|
||||
const rectangle = UI.createElement("rectangle");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: rectangle.id }),
|
||||
]);
|
||||
|
||||
expect(h.history.isUndoStackEmpty).toBeFalsy();
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(undoButton).not.toBeDisabled();
|
||||
expect(redoButton).toBeDisabled();
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
expect(h.history.isRedoStackEmpty).toBeFalsy();
|
||||
expect(undoButton).toBeDisabled();
|
||||
expect(redoButton).not.toBeDisabled();
|
||||
|
||||
act(() => h.app.actionManager.executeAction(redoAction));
|
||||
|
||||
expect(h.history.isUndoStackEmpty).toBeFalsy();
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(undoButton).not.toBeDisabled();
|
||||
expect(redoButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("remounting undo/redo buttons should initialize undo/redo state correctly", async () => {
|
||||
const { container } = await render(
|
||||
<Excalidraw
|
||||
initialData={{
|
||||
elements: [API.createElement({ type: "rectangle", id: "A" })],
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const undoAction = createUndoAction(h.history, h.store);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(h.elements).toEqual([expect.objectContaining({ id: "A" })]);
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(queryByTestId(container, "button-undo")).toBeDisabled();
|
||||
expect(queryByTestId(container, "button-redo")).toBeDisabled();
|
||||
|
||||
// testing undo button
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const rectangle = UI.createElement("rectangle");
|
||||
expect(h.elements).toEqual([
|
||||
expect.objectContaining({ id: "A" }),
|
||||
expect.objectContaining({ id: rectangle.id }),
|
||||
]);
|
||||
|
||||
expect(h.history.isUndoStackEmpty).toBeFalsy();
|
||||
expect(h.history.isRedoStackEmpty).toBeTruthy();
|
||||
expect(queryByTestId(container, "button-undo")).not.toBeDisabled();
|
||||
expect(queryByTestId(container, "button-redo")).toBeDisabled();
|
||||
|
||||
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
|
||||
expect(h.state.viewModeEnabled).toBe(true);
|
||||
|
||||
expect(queryByTestId(container, "button-undo")).toBeNull();
|
||||
expect(queryByTestId(container, "button-redo")).toBeNull();
|
||||
|
||||
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
|
||||
expect(h.state.viewModeEnabled).toBe(false);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTestId(container, "button-undo")).not.toBeDisabled();
|
||||
expect(queryByTestId(container, "button-redo")).toBeDisabled();
|
||||
});
|
||||
|
||||
// testing redo button
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
act(() => h.app.actionManager.executeAction(undoAction));
|
||||
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
expect(h.history.isRedoStackEmpty).toBeFalsy();
|
||||
expect(queryByTestId(container, "button-undo")).toBeDisabled();
|
||||
expect(queryByTestId(container, "button-redo")).not.toBeDisabled();
|
||||
|
||||
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
|
||||
expect(h.state.viewModeEnabled).toBe(true);
|
||||
|
||||
expect(queryByTestId(container, "button-undo")).toBeNull();
|
||||
expect(queryByTestId(container, "button-redo")).toBeNull();
|
||||
|
||||
act(() => h.app.actionManager.executeAction(actionToggleViewMode));
|
||||
expect(h.state.viewModeEnabled).toBe(false);
|
||||
|
||||
expect(h.history.isUndoStackEmpty).toBeTruthy();
|
||||
expect(h.history.isRedoStackEmpty).toBeFalsy();
|
||||
expect(queryByTestId(container, "button-undo")).toBeDisabled();
|
||||
expect(queryByTestId(container, "button-redo")).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiplayer undo/redo", () => {
|
||||
|
||||
@@ -319,12 +319,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
55.9697848965255,
|
||||
47.442326230998205,
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
],
|
||||
[
|
||||
76.08587175006699,
|
||||
43.294165939653226,
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -381,12 +381,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
105.96978489652551,
|
||||
67.4423262309982,
|
||||
"105.96978",
|
||||
"67.44233",
|
||||
],
|
||||
[
|
||||
126.08587175006699,
|
||||
63.294165939653226,
|
||||
"126.08587",
|
||||
"63.29417",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -627,16 +627,16 @@ describe("Test Linear Elements", () => {
|
||||
0,
|
||||
],
|
||||
[
|
||||
85.96978489652551,
|
||||
77.4423262309982,
|
||||
"85.96978",
|
||||
"77.44233",
|
||||
],
|
||||
[
|
||||
70,
|
||||
50,
|
||||
],
|
||||
[
|
||||
106.08587175006699,
|
||||
73.29416593965323,
|
||||
"106.08587",
|
||||
"73.29417",
|
||||
],
|
||||
[
|
||||
40,
|
||||
@@ -683,12 +683,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
31.884084517616053,
|
||||
23.13275505472383,
|
||||
"31.88408",
|
||||
"23.13276",
|
||||
],
|
||||
[
|
||||
77.74792546875662,
|
||||
44.57840982272327,
|
||||
"77.74793",
|
||||
"44.57841",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -769,12 +769,12 @@ describe("Test Linear Elements", () => {
|
||||
expect(newMidPoints).toMatchInlineSnapshot(`
|
||||
[
|
||||
[
|
||||
55.9697848965255,
|
||||
47.442326230998205,
|
||||
"55.96978",
|
||||
"47.44233",
|
||||
],
|
||||
[
|
||||
76.08587175006699,
|
||||
43.294165939653226,
|
||||
"76.08587",
|
||||
"43.29417",
|
||||
],
|
||||
]
|
||||
`);
|
||||
@@ -928,8 +928,8 @@ describe("Test Linear Elements", () => {
|
||||
);
|
||||
expect(position).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": 85.82201843191861,
|
||||
"y": 75.63461309860818,
|
||||
"x": "85.82202",
|
||||
"y": "75.63461",
|
||||
}
|
||||
`);
|
||||
});
|
||||
@@ -972,10 +972,10 @@ describe("Test Linear Elements", () => {
|
||||
]);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should bind text to arrow when clicked on arrow and enter pressed", async () => {
|
||||
@@ -1006,10 +1006,10 @@ describe("Test Linear Elements", () => {
|
||||
]);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
"Online whiteboard
|
||||
collaboration made
|
||||
easy"
|
||||
`);
|
||||
});
|
||||
|
||||
it("should not bind text to line when double clicked", async () => {
|
||||
@@ -1068,15 +1068,15 @@ describe("Test Linear Elements", () => {
|
||||
true,
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
20,
|
||||
20,
|
||||
105,
|
||||
80,
|
||||
55.45893770831013,
|
||||
45,
|
||||
]
|
||||
`);
|
||||
[
|
||||
20,
|
||||
20,
|
||||
105,
|
||||
80,
|
||||
"55.45894",
|
||||
45,
|
||||
]
|
||||
`);
|
||||
|
||||
UI.resize(container, "ne", [300, 200]);
|
||||
|
||||
@@ -1084,7 +1084,7 @@ describe("Test Linear Elements", () => {
|
||||
.toMatchInlineSnapshot(`
|
||||
{
|
||||
"height": 130,
|
||||
"width": 366.11716195150507,
|
||||
"width": "366.11716",
|
||||
}
|
||||
`);
|
||||
|
||||
@@ -1095,11 +1095,11 @@ describe("Test Linear Elements", () => {
|
||||
arrayToMap(h.elements),
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
{
|
||||
"x": 271.11716195150507,
|
||||
"y": 45,
|
||||
}
|
||||
`);
|
||||
{
|
||||
"x": "271.11716",
|
||||
"y": 45,
|
||||
}
|
||||
`);
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text)
|
||||
.toMatchInlineSnapshot(`
|
||||
"Online whiteboard
|
||||
@@ -1112,15 +1112,15 @@ describe("Test Linear Elements", () => {
|
||||
true,
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
[
|
||||
20,
|
||||
35,
|
||||
501.11716195150507,
|
||||
95,
|
||||
205.4589377083102,
|
||||
52.5,
|
||||
]
|
||||
`);
|
||||
[
|
||||
20,
|
||||
35,
|
||||
"501.11716",
|
||||
95,
|
||||
"205.45894",
|
||||
"52.50000",
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it("should resize and position the bound text correctly when 2 pointer linear element resized", () => {
|
||||
|
||||
@@ -426,6 +426,112 @@ describe("text element", () => {
|
||||
expect(text.fontSize).toBe(fontSize);
|
||||
});
|
||||
});
|
||||
|
||||
// text can be resized from sides
|
||||
it("can be resized from e", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const width = text.width;
|
||||
const height = text.height;
|
||||
|
||||
UI.resize(text, "e", [30, 0]);
|
||||
expect(text.width).toBe(width + 30);
|
||||
expect(text.height).toBe(height);
|
||||
|
||||
UI.resize(text, "e", [-30, 0]);
|
||||
expect(text.width).toBe(width);
|
||||
expect(text.height).toBe(height);
|
||||
});
|
||||
|
||||
it("can be resized from w", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const width = text.width;
|
||||
const height = text.height;
|
||||
|
||||
UI.resize(text, "w", [-50, 0]);
|
||||
expect(text.width).toBe(width + 50);
|
||||
expect(text.height).toBe(height);
|
||||
|
||||
UI.resize(text, "w", [50, 0]);
|
||||
expect(text.width).toBe(width);
|
||||
expect(text.height).toBe(height);
|
||||
});
|
||||
|
||||
it("wraps when width is narrower than texts inside", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const prevWidth = text.width;
|
||||
const prevHeight = text.height;
|
||||
const prevText = text.text;
|
||||
|
||||
UI.resize(text, "w", [50, 0]);
|
||||
expect(text.width).toBe(prevWidth - 50);
|
||||
expect(text.height).toBeGreaterThan(prevHeight);
|
||||
expect(text.text).not.toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "w", [-50, 0]);
|
||||
expect(text.width).toBe(prevWidth);
|
||||
expect(text.height).toEqual(prevHeight);
|
||||
expect(text.text).toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "e", [-20, 0]);
|
||||
expect(text.width).toBe(prevWidth - 20);
|
||||
expect(text.height).toBeGreaterThan(prevHeight);
|
||||
expect(text.text).not.toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "e", [20, 0]);
|
||||
expect(text.width).toBe(prevWidth);
|
||||
expect(text.height).toEqual(prevHeight);
|
||||
expect(text.text).toEqual(prevText);
|
||||
expect(text.autoResize).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps properties when wrapped", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const alignment = text.textAlign;
|
||||
const fontSize = text.fontSize;
|
||||
const fontFamily = text.fontFamily;
|
||||
|
||||
UI.resize(text, "e", [-60, 0]);
|
||||
expect(text.textAlign).toBe(alignment);
|
||||
expect(text.fontSize).toBe(fontSize);
|
||||
expect(text.fontFamily).toBe(fontFamily);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "e", [60, 0]);
|
||||
expect(text.textAlign).toBe(alignment);
|
||||
expect(text.fontSize).toBe(fontSize);
|
||||
expect(text.fontFamily).toBe(fontFamily);
|
||||
expect(text.autoResize).toBe(false);
|
||||
});
|
||||
|
||||
it("has a minimum width when wrapped", async () => {
|
||||
const text = UI.createElement("text");
|
||||
await UI.editText(text, "Excalidraw\nEditor");
|
||||
|
||||
const width = text.width;
|
||||
|
||||
UI.resize(text, "e", [-width, 0]);
|
||||
expect(text.width).not.toEqual(0);
|
||||
UI.resize(text, "e", [width - text.width, 0]);
|
||||
expect(text.width).toEqual(width);
|
||||
expect(text.autoResize).toBe(false);
|
||||
|
||||
UI.resize(text, "w", [width, 0]);
|
||||
expect(text.width).not.toEqual(0);
|
||||
UI.resize(text, "w", [text.width - width, 0]);
|
||||
expect(text.width).toEqual(width);
|
||||
expect(text.autoResize).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("image element", () => {
|
||||
|
||||
@@ -242,3 +242,20 @@ expect.extend({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Serializer for IEE754 float pointing numbers to avoid random failures due to tiny precision differences
|
||||
*/
|
||||
expect.addSnapshotSerializer({
|
||||
serialize(val, config, indentation, depth, refs, printer) {
|
||||
return printer(val.toFixed(5), config, indentation, depth, refs);
|
||||
},
|
||||
test(val) {
|
||||
return (
|
||||
typeof val === "number" &&
|
||||
Number.isFinite(val) &&
|
||||
!Number.isNaN(val) &&
|
||||
!Number.isInteger(val)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -592,6 +592,7 @@ export type AppClassProperties = {
|
||||
files: BinaryFiles;
|
||||
device: App["device"];
|
||||
scene: App["scene"];
|
||||
store: App["store"];
|
||||
pasteFromClipboard: App["pasteFromClipboard"];
|
||||
id: App["id"];
|
||||
onInsertElements: App["onInsertElements"];
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1930,10 +1930,10 @@
|
||||
resolved "https://registry.npmjs.org/@excalidraw/markdown-to-text/-/markdown-to-text-0.1.2.tgz#1703705e7da608cf478f17bfe96fb295f55a23eb"
|
||||
integrity sha512-1nDXBNAojfi3oSFwJswKREkFm5wrSjqay81QlyRv2pkITG/XYB5v+oChENVBQLcxQwX4IUATWvXM5BcaNhPiIg==
|
||||
|
||||
"@excalidraw/mermaid-to-excalidraw@0.3.0":
|
||||
version "0.3.0"
|
||||
resolved "https://registry.npmjs.org/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-0.3.0.tgz#94c438133fc66db6b920e237abda5152b62e6cb0"
|
||||
integrity sha512-eyFN8y2ES3HFtETZWZZBakkSB5ROfnHJeCLeBlMgrIk1fxbXpPtxlu2VwGNpqPjDiCfV5FYnx7FaZ4CRiVRVMg==
|
||||
"@excalidraw/mermaid-to-excalidraw@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@excalidraw/mermaid-to-excalidraw/-/mermaid-to-excalidraw-1.0.0.tgz#8c058d2a43230425cba96d01e4a669a2d7c586a2"
|
||||
integrity sha512-RGSoJBY2gFag6mQOIwa3OakTrvAZYx0bwvnr5ojuCZInih8Fxhje4X1WZfsaQx+GATEH8Ioq3O3b1FPDg4nKjQ==
|
||||
dependencies:
|
||||
"@excalidraw/markdown-to-text" "0.1.2"
|
||||
mermaid "10.9.0"
|
||||
|
||||
Reference in New Issue
Block a user