mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/release' into danieljgeiger-mathjax
This commit is contained in:
commit
e4ddd08bb1
261 changed files with 12625 additions and 14661 deletions
|
@ -1,30 +1,29 @@
|
|||
import { register } from "./register";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { deepCopyElement } from "../element/newElement";
|
||||
import { randomId } from "../random";
|
||||
import { t } from "../i18n";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
export const actionAddToLibrary = register({
|
||||
name: "addToLibrary",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: "Support for adding images to the library coming soon!",
|
||||
},
|
||||
};
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (selectedElements.some((element) => element.type === type)) {
|
||||
return {
|
||||
commitToHistory: false,
|
||||
appState: {
|
||||
...appState,
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return app.library
|
||||
|
|
|
@ -13,19 +13,18 @@ import { ExcalidrawElement } from "../element/types";
|
|||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
_: unknown,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
|
@ -36,12 +35,10 @@ const alignActionsPredicate = (
|
|||
const alignSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
alignment: Alignment,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const updatedElements = alignElements(selectedElements, alignment);
|
||||
|
||||
|
@ -50,6 +47,7 @@ const alignSelectedElements = (
|
|||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -57,10 +55,10 @@ export const actionAlignTop = register({
|
|||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "start",
|
||||
axis: "y",
|
||||
}),
|
||||
|
@ -69,9 +67,9 @@ export const actionAlignTop = register({
|
|||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -88,10 +86,10 @@ export const actionAlignBottom = register({
|
|||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "end",
|
||||
axis: "y",
|
||||
}),
|
||||
|
@ -100,9 +98,9 @@ export const actionAlignBottom = register({
|
|||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -119,10 +117,10 @@ export const actionAlignLeft = register({
|
|||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "start",
|
||||
axis: "x",
|
||||
}),
|
||||
|
@ -131,9 +129,9 @@ export const actionAlignLeft = register({
|
|||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -150,10 +148,10 @@ export const actionAlignRight = register({
|
|||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "end",
|
||||
axis: "x",
|
||||
}),
|
||||
|
@ -162,9 +160,9 @@ export const actionAlignRight = register({
|
|||
},
|
||||
keyTest: (event) =>
|
||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -181,19 +179,19 @@ export const actionAlignVerticallyCentered = register({
|
|||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "center",
|
||||
axis: "y",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -208,19 +206,19 @@ export const actionAlignHorizontallyCentered = register({
|
|||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: alignSelectedElements(elements, appState, {
|
||||
elements: alignSelectedElements(elements, appState, app, {
|
||||
position: "center",
|
||||
axis: "x",
|
||||
}),
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState, null, app)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
|
|
@ -4,7 +4,7 @@ import {
|
|||
VERTICAL_ALIGN,
|
||||
TEXT_ALIGN,
|
||||
} from "../constants";
|
||||
import { getNonDeletedElements, isTextElement, newElement } from "../element";
|
||||
import { isTextElement, newElement } from "../element";
|
||||
import { mutateElement } from "../element/mutateElement";
|
||||
import {
|
||||
computeBoundTextPosition,
|
||||
|
@ -29,24 +29,21 @@ import {
|
|||
ExcalidrawTextContainer,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionUnbindText = register({
|
||||
name: "unbindText",
|
||||
contextItemLabel: "labels.unbindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return selectedElements.some((element) => hasBoundTextElement(element));
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
selectedElements.forEach((element) => {
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
|
@ -92,8 +89,8 @@ export const actionBindText = register({
|
|||
name: "bindText",
|
||||
contextItemLabel: "labels.bindText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (selectedElements.length === 2) {
|
||||
const textElement =
|
||||
|
@ -116,11 +113,8 @@ export const actionBindText = register({
|
|||
}
|
||||
return false;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
let textElement: ExcalidrawTextElement;
|
||||
let container: ExcalidrawTextContainer;
|
||||
|
@ -200,18 +194,15 @@ export const actionWrapTextInContainer = register({
|
|||
name: "wrapTextInContainer",
|
||||
contextItemLabel: "labels.createContainerFromText",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const areTextElements = selectedElements.every((el) => isTextElement(el));
|
||||
return selectedElements.length > 0 && areTextElements;
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||
const containerIds: AppState["selectedElementIds"] = {};
|
||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
|
|
|
@ -6,7 +6,7 @@ import { getCommonBounds, getNonDeletedElements } from "../element";
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getNormalizedZoom, getSelectedElements } from "../scene";
|
||||
import { getNormalizedZoom } from "../scene";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { getStateForZoom } from "../scene/zoom";
|
||||
import { AppState, NormalizedZoomValue } from "../types";
|
||||
|
@ -20,7 +20,6 @@ import {
|
|||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
import { Bounds } from "../element/bounds";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
|
@ -226,52 +225,93 @@ const zoomValueToFitBoundsOnViewport = (
|
|||
return clampedZoomValueToFitElements as NormalizedZoomValue;
|
||||
};
|
||||
|
||||
export const zoomToFitElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
zoomToSelection: boolean,
|
||||
) => {
|
||||
const nonDeletedElements = getNonDeletedElements(elements);
|
||||
const selectedElements = getSelectedElements(nonDeletedElements, appState);
|
||||
|
||||
const commonBounds =
|
||||
zoomToSelection && selectedElements.length > 0
|
||||
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
|
||||
: getCommonBounds(
|
||||
excludeElementsInFramesFromSelection(nonDeletedElements),
|
||||
);
|
||||
|
||||
const newZoom = {
|
||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
}),
|
||||
};
|
||||
export const zoomToFit = ({
|
||||
targetElements,
|
||||
appState,
|
||||
fitToViewport = false,
|
||||
viewportZoomFactor = 0.7,
|
||||
}: {
|
||||
targetElements: readonly ExcalidrawElement[];
|
||||
appState: Readonly<AppState>;
|
||||
/** whether to fit content to viewport (beyond >100%) */
|
||||
fitToViewport: boolean;
|
||||
/** zoom content to cover X of the viewport, when fitToViewport=true */
|
||||
viewportZoomFactor?: number;
|
||||
}) => {
|
||||
const commonBounds = getCommonBounds(getNonDeletedElements(targetElements));
|
||||
|
||||
const [x1, y1, x2, y2] = commonBounds;
|
||||
const centerX = (x1 + x2) / 2;
|
||||
const centerY = (y1 + y2) / 2;
|
||||
|
||||
let newZoomValue;
|
||||
let scrollX;
|
||||
let scrollY;
|
||||
|
||||
if (fitToViewport) {
|
||||
const commonBoundsWidth = x2 - x1;
|
||||
const commonBoundsHeight = y2 - y1;
|
||||
|
||||
newZoomValue =
|
||||
Math.min(
|
||||
appState.width / commonBoundsWidth,
|
||||
appState.height / commonBoundsHeight,
|
||||
) * Math.min(1, Math.max(viewportZoomFactor, 0.1));
|
||||
|
||||
// Apply clamping to newZoomValue to be between 10% and 3000%
|
||||
newZoomValue = Math.min(
|
||||
Math.max(newZoomValue, 0.1),
|
||||
30.0,
|
||||
) as NormalizedZoomValue;
|
||||
|
||||
scrollX = (appState.width / 2) * (1 / newZoomValue) - centerX;
|
||||
scrollY = (appState.height / 2) * (1 / newZoomValue) - centerY;
|
||||
} else {
|
||||
newZoomValue = zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
});
|
||||
|
||||
const centerScroll = centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: { value: newZoomValue },
|
||||
});
|
||||
|
||||
scrollX = centerScroll.scrollX;
|
||||
scrollY = centerScroll.scrollY;
|
||||
}
|
||||
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...centerScrollOn({
|
||||
scenePoint: { x: centerX, y: centerY },
|
||||
viewportDimensions: {
|
||||
width: appState.width,
|
||||
height: appState.height,
|
||||
},
|
||||
zoom: newZoom,
|
||||
}),
|
||||
zoom: newZoom,
|
||||
scrollX,
|
||||
scrollY,
|
||||
zoom: { value: newZoomValue },
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
};
|
||||
|
||||
export const actionZoomToSelected = register({
|
||||
name: "zoomToSelection",
|
||||
// Note, this action differs from actionZoomToFitSelection in that it doesn't
|
||||
// zoom beyond 100%. In other words, if the content is smaller than viewport
|
||||
// size, it won't be zoomed in.
|
||||
export const actionZoomToFitSelectionInViewport = register({
|
||||
name: "zoomToFitSelectionInViewport",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, true),
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return zoomToFit({
|
||||
targetElements: selectedElements.length ? selectedElements : elements,
|
||||
appState,
|
||||
fitToViewport: false,
|
||||
});
|
||||
},
|
||||
// NOTE shift-2 should have been assigned actionZoomToFitSelection.
|
||||
// TBD on how proceed
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.TWO &&
|
||||
event.shiftKey &&
|
||||
|
@ -279,11 +319,31 @@ export const actionZoomToSelected = register({
|
|||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return zoomToFit({
|
||||
targetElements: selectedElements.length ? selectedElements : elements,
|
||||
appState,
|
||||
fitToViewport: true,
|
||||
});
|
||||
},
|
||||
// NOTE this action should use shift-2 per figma, alas
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.THREE &&
|
||||
event.shiftKey &&
|
||||
!event.altKey &&
|
||||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFit = register({
|
||||
name: "zoomToFit",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => zoomToFitElements(elements, appState, false),
|
||||
perform: (elements, appState) =>
|
||||
zoomToFit({ targetElements: elements, appState, fitToViewport: false }),
|
||||
keyTest: (event) =>
|
||||
event.code === CODES.ONE &&
|
||||
event.shiftKey &&
|
||||
|
@ -336,6 +396,7 @@ export const actionToggleEraserTool = register({
|
|||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
|
@ -370,6 +431,7 @@ export const actionToggleHandTool = register({
|
|||
...appState,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
activeTool,
|
||||
},
|
||||
commitToHistory: true,
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
probablySupportsClipboardWriteText,
|
||||
} from "../clipboard";
|
||||
import { actionDeleteSelected } from "./actionDeleteSelected";
|
||||
import { getSelectedElements } from "../scene/selection";
|
||||
import { exportCanvas } from "../data/index";
|
||||
import { getNonDeletedElements, isTextElement } from "../element";
|
||||
import { t } from "../i18n";
|
||||
|
@ -16,7 +15,8 @@ export const actionCopy = register({
|
|||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const elementsToCopy = getSelectedElements(elements, appState, {
|
||||
const elementsToCopy = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
@ -75,14 +75,11 @@ export const actionCopyAsSvg = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard-svg",
|
||||
|
@ -122,14 +119,11 @@ export const actionCopyAsPng = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
try {
|
||||
await exportCanvas(
|
||||
"clipboard",
|
||||
|
@ -177,14 +171,11 @@ export const actionCopyAsPng = register({
|
|||
export const copyText = register({
|
||||
name: "copyText",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
|
||||
const text = selectedElements
|
||||
.reduce((acc: string[], element) => {
|
||||
|
@ -199,12 +190,15 @@ export const copyText = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
predicate: (elements, appState) => {
|
||||
predicate: (elements, appState, _, app) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).some(isTextElement)
|
||||
app.scene
|
||||
.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})
|
||||
.some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
|
|
|
@ -158,6 +158,7 @@ export const actionDeleteSelected = register({
|
|||
...nextAppState,
|
||||
activeTool: updateActiveTool(appState, { type: "selection" }),
|
||||
multiElement: null,
|
||||
activeEmbeddable: null,
|
||||
},
|
||||
commitToHistory: isSomeElementSelected(
|
||||
getNonDeletedElements(elements),
|
||||
|
|
|
@ -9,19 +9,13 @@ import { ExcalidrawElement } from "../element/types";
|
|||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const enableActionGroup = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
|
@ -32,12 +26,10 @@ const enableActionGroup = (
|
|||
const distributeSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
distribution: Distribution,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
const updatedElements = distributeElements(selectedElements, distribution);
|
||||
|
||||
|
@ -46,16 +38,17 @@ const distributeSelectedElements = (
|
|||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
app,
|
||||
);
|
||||
};
|
||||
|
||||
export const distributeHorizontally = register({
|
||||
name: "distributeHorizontally",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
elements: distributeSelectedElements(elements, appState, app, {
|
||||
space: "between",
|
||||
axis: "x",
|
||||
}),
|
||||
|
@ -64,9 +57,9 @@ export const distributeHorizontally = register({
|
|||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.H,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!enableActionGroup(appState, app)}
|
||||
type="button"
|
||||
icon={DistributeHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -82,10 +75,10 @@ export const distributeHorizontally = register({
|
|||
export const distributeVertically = register({
|
||||
name: "distributeVertically",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
appState,
|
||||
elements: distributeSelectedElements(elements, appState, {
|
||||
elements: distributeSelectedElements(elements, appState, app, {
|
||||
space: "between",
|
||||
axis: "y",
|
||||
}),
|
||||
|
@ -94,9 +87,9 @@ export const distributeVertically = register({
|
|||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] && event.altKey && event.code === CODES.V,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!enableActionGroup(appState, app)}
|
||||
type="button"
|
||||
icon={DistributeVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
|
|
@ -274,6 +274,8 @@ const duplicateElements = (
|
|||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
null,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import { newElementWith } from "../element/mutateElement";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { arrayToMap } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -11,14 +10,15 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
@ -46,8 +46,9 @@ export const actionToggleElementLock = register({
|
|||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, {
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
const selected = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && selected[0].type !== "frame") {
|
||||
|
@ -60,12 +61,13 @@ export const actionToggleElementLock = register({
|
|||
? "labels.elementLock.lockAll"
|
||||
: "labels.elementLock.unlockAll";
|
||||
},
|
||||
keyTest: (event, appState, elements) => {
|
||||
keyTest: (event, appState, elements, app) => {
|
||||
return (
|
||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, {
|
||||
app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: false,
|
||||
}).length > 0
|
||||
);
|
||||
|
|
|
@ -65,7 +65,7 @@ export const actionChangeExportScale = register({
|
|||
);
|
||||
|
||||
const scaleButtonTitle = `${t(
|
||||
"buttons.scale",
|
||||
"imageExportDialog.label.scale",
|
||||
)} ${s}x (${width}x${height})`;
|
||||
|
||||
return (
|
||||
|
@ -102,7 +102,7 @@ export const actionChangeExportBackground = register({
|
|||
checked={appState.exportBackground}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.withBackground")}
|
||||
{t("imageExportDialog.label.withBackground")}
|
||||
</CheckboxItem>
|
||||
),
|
||||
});
|
||||
|
@ -121,8 +121,8 @@ export const actionChangeExportEmbedScene = register({
|
|||
checked={appState.exportEmbedScene}
|
||||
onChange={(checked) => updateData(checked)}
|
||||
>
|
||||
{t("labels.exportEmbedScene")}
|
||||
<Tooltip label={t("labels.exportEmbedScene_details")} long={true}>
|
||||
{t("imageExportDialog.label.embedScene")}
|
||||
<Tooltip label={t("imageExportDialog.tooltip.embedScene")} long={true}>
|
||||
<div className="excalidraw-tooltip-icon">{questionCircle}</div>
|
||||
</Tooltip>
|
||||
</CheckboxItem>
|
||||
|
@ -277,7 +277,7 @@ export const actionExportWithDarkMode = register({
|
|||
onChange={(theme: Theme) => {
|
||||
updateData(theme === THEME.DARK);
|
||||
}}
|
||||
title={t("labels.toggleExportColorScheme")}
|
||||
title={t("imageExportDialog.label.darkMode")}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
|
|
@ -125,13 +125,6 @@ export const actionFinalize = register({
|
|||
{ x, y },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -167,6 +160,7 @@ export const actionFinalize = register({
|
|||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
multiElement: null,
|
||||
editingElement: null,
|
||||
|
|
|
@ -17,11 +17,12 @@ import { updateFrameMembershipOfSelectedElements } from "../frame";
|
|||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "horizontal"),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
|
@ -34,11 +35,12 @@ export const actionFlipHorizontal = register({
|
|||
export const actionFlipVertical = register({
|
||||
name: "flipVertical",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
return {
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "vertical"),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
|
|
|
@ -3,19 +3,12 @@ import { ExcalidrawElement } from "../element/types";
|
|||
import { removeAllElementsFromFrame } from "../frame";
|
||||
import { getFrameElements } from "../frame";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
const isSingleFrameSelected = (appState: AppState, app: AppClassProperties) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
return selectedElements.length === 1 && selectedElements[0].type === "frame";
|
||||
};
|
||||
|
@ -23,11 +16,8 @@ const isSingleFrameSelected = (
|
|||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedFrame = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)[0];
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
const elementsInFrame = getFrameElements(
|
||||
|
@ -55,17 +45,15 @@ export const actionSelectAllElementsInFrame = register({
|
|||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedFrame = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)[0];
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedFrame = app.scene.getSelectedElements(appState)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
return {
|
||||
|
@ -87,11 +75,12 @@ export const actionRemoveAllElementsFromFrame = register({
|
|||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
|
||||
predicate: (elements, appState, _, app) =>
|
||||
isSingleFrameSelected(appState, app),
|
||||
});
|
||||
|
||||
export const actionToggleFrameRendering = register({
|
||||
name: "toggleFrameRendering",
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
|
@ -99,13 +88,16 @@ export const actionToggleFrameRendering = register({
|
|||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
shouldRenderFrames: !appState.shouldRenderFrames,
|
||||
frameRendering: {
|
||||
...appState.frameRendering,
|
||||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.toggleFrameRendering",
|
||||
checked: (appState: AppState) => appState.shouldRenderFrames,
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
|
|
|
@ -4,7 +4,7 @@ import { arrayToMap, getShortcutKey } from "../utils";
|
|||
import { register } from "./register";
|
||||
import { UngroupIcon, GroupIcon } from "../components/icons";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import {
|
||||
getSelectedGroupIds,
|
||||
selectGroup,
|
||||
|
@ -22,7 +22,7 @@ import {
|
|||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { AppClassProperties, AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
|
@ -51,14 +51,12 @@ const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
|||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
);
|
||||
|
@ -68,13 +66,10 @@ export const actionGroup = register({
|
|||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
return { appState, elements, commitToHistory: false };
|
||||
|
@ -164,12 +159,13 @@ export const actionGroup = register({
|
|||
};
|
||||
},
|
||||
contextItemLabel: "labels.group",
|
||||
predicate: (elements, appState) => enableActionGroup(elements, appState),
|
||||
predicate: (elements, appState, _, app) =>
|
||||
enableActionGroup(elements, appState, app),
|
||||
keyTest: (event) =>
|
||||
!event.shiftKey && event[KEYS.CTRL_OR_CMD] && event.key === KEYS.G,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!enableActionGroup(elements, appState, app)}
|
||||
type="button"
|
||||
icon={<GroupIcon theme={appState.theme} />}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -191,7 +187,7 @@ export const actionUngroup = register({
|
|||
|
||||
let nextElements = [...elements];
|
||||
|
||||
const selectedElements = getSelectedElements(nextElements, appState);
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const frames = selectedElements
|
||||
.filter((element) => element.frameId)
|
||||
.map((element) =>
|
||||
|
@ -218,6 +214,8 @@ export const actionUngroup = register({
|
|||
const updateAppState = selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
null,
|
||||
);
|
||||
|
||||
frames.forEach((frame) => {
|
||||
|
@ -232,9 +230,18 @@ export const actionUngroup = register({
|
|||
});
|
||||
|
||||
// remove binded text elements from selection
|
||||
boundTextElementIds.forEach(
|
||||
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||
updateAppState.selectedElementIds = Object.entries(
|
||||
updateAppState.selectedElementIds,
|
||||
).reduce(
|
||||
(acc: { [key: ExcalidrawElement["id"]]: true }, [id, selected]) => {
|
||||
if (selected && !boundTextElementIds.includes(id)) {
|
||||
acc[id] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
appState: updateAppState,
|
||||
elements: nextElements,
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { ExcalidrawLinearElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
|
@ -10,21 +8,18 @@ export const actionToggleLinearEditor = register({
|
|||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
if (selectedElements.length === 1 && isLinearElement(selectedElements[0])) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
appState.editingLinearElement?.elementId === selectedElement.id
|
||||
|
@ -38,14 +33,11 @@ export const actionToggleLinearEditor = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
contextItemLabel: (elements, appState, app) => {
|
||||
const selectedElement = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
includeBoundTextElement: true,
|
||||
})[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
: "labels.lineEditor.edit";
|
||||
|
|
|
@ -41,6 +41,8 @@ export const actionSelectAll = register({
|
|||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
app,
|
||||
),
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
|
|
@ -122,6 +122,7 @@ export class ActionManager {
|
|||
event,
|
||||
this.getAppState(),
|
||||
this.getElementsIncludingDeleted(),
|
||||
this.app,
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -201,6 +202,7 @@ export class ActionManager {
|
|||
appState={this.getAppState()}
|
||||
updateData={updateData}
|
||||
appProps={this.app.props}
|
||||
app={this.app}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -91,7 +91,8 @@ export type ActionName =
|
|||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "zoomToFitSelection"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
|
@ -127,8 +128,9 @@ export type ActionName =
|
|||
| "toggleHandTool"
|
||||
| "selectAllElementsInFrame"
|
||||
| "removeAllElementsFromFrame"
|
||||
| "toggleFrameRendering"
|
||||
| "updateFrameRendering"
|
||||
| "setFrameAsActiveTool"
|
||||
| "setEmbeddableAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
|
||||
|
@ -138,6 +140,7 @@ export type PanelComponentProps = {
|
|||
updateData: (formData?: any) => void;
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
app: AppClassProperties;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
|
@ -149,12 +152,14 @@ export interface Action {
|
|||
event: React.KeyboardEvent | KeyboardEvent,
|
||||
appState: AppState,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
app: AppClassProperties,
|
||||
) => boolean;
|
||||
contextItemLabel?:
|
||||
| string
|
||||
| ((
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Readonly<AppState>,
|
||||
app: AppClassProperties,
|
||||
) => string);
|
||||
predicate?: (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
|
|
@ -5,19 +5,18 @@ export const trackEvent = (
|
|||
value?: number,
|
||||
) => {
|
||||
try {
|
||||
// place here categories that you want to track as events
|
||||
// KEEP IN MIND THE PRICING
|
||||
const ALLOWED_CATEGORIES_TO_TRACK = [] as string[];
|
||||
// Uncomment the next line to track locally
|
||||
// console.log("Track Event", { category, action, label, value });
|
||||
|
||||
if (typeof window === "undefined" || process.env.JEST_WORKER_ID) {
|
||||
if (typeof window === "undefined" || import.meta.env.VITE_WORKER_ID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.env.REACT_APP_GOOGLE_ANALYTICS_ID && window.gtag) {
|
||||
window.gtag("event", action, {
|
||||
event_category: category,
|
||||
event_label: label,
|
||||
value,
|
||||
});
|
||||
if (!ALLOWED_CATEGORIES_TO_TRACK.includes(category)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.sa_event) {
|
||||
|
@ -27,14 +26,6 @@ export const trackEvent = (
|
|||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if (window.fathom) {
|
||||
window.fathom.trackEvent(action, {
|
||||
category,
|
||||
label,
|
||||
value,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("error during analytics", error);
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ export const getDefaultAppState = (): Omit<
|
|||
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,
|
||||
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
|
||||
cursorButton: "up",
|
||||
activeEmbeddable: null,
|
||||
draggingElement: null,
|
||||
editingElement: null,
|
||||
editingGroupId: null,
|
||||
|
@ -84,7 +85,7 @@ export const getDefaultAppState = (): Omit<
|
|||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
shouldRenderFrames: true,
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
elementsToHighlight: null,
|
||||
|
@ -139,6 +140,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
currentItemStrokeWidth: { browser: true, export: false, server: false },
|
||||
currentItemTextAlign: { browser: true, export: false, server: false },
|
||||
cursorButton: { browser: true, export: false, server: false },
|
||||
activeEmbeddable: { browser: false, export: false, server: false },
|
||||
draggingElement: { browser: false, export: false, server: false },
|
||||
editingElement: { browser: false, export: false, server: false },
|
||||
editingGroupId: { browser: true, export: false, server: false },
|
||||
|
@ -193,7 +195,7 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
shouldRenderFrames: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
elementsToHighlight: { browser: false, export: false, server: false },
|
||||
|
|
|
@ -6,7 +6,6 @@ import {
|
|||
import {
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
ENV,
|
||||
VERTICAL_ALIGN,
|
||||
} from "./constants";
|
||||
import { newElement, newLinearElement, newTextElement } from "./element";
|
||||
|
@ -207,7 +206,7 @@ const commonProps = {
|
|||
locked: false,
|
||||
} as const;
|
||||
|
||||
const getChartDimentions = (spreadsheet: Spreadsheet) => {
|
||||
const getChartDimensions = (spreadsheet: Spreadsheet) => {
|
||||
const chartWidth =
|
||||
(BAR_WIDTH + BAR_GAP) * spreadsheet.values.length + BAR_GAP;
|
||||
const chartHeight = BAR_HEIGHT + BAR_GAP * 2;
|
||||
|
@ -284,7 +283,7 @@ const chartLines = (
|
|||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
|
@ -350,7 +349,7 @@ const chartBaseElements = (
|
|||
backgroundColor: string,
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
|
@ -424,7 +423,7 @@ const chartTypeBar = (
|
|||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||
import.meta.env.DEV,
|
||||
),
|
||||
];
|
||||
};
|
||||
|
@ -516,7 +515,7 @@ const chartTypeLine = (
|
|||
y,
|
||||
groupId,
|
||||
backgroundColor,
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT,
|
||||
import.meta.env.DEV,
|
||||
),
|
||||
line,
|
||||
...lines,
|
||||
|
|
|
@ -21,7 +21,7 @@ export type ColorPickerColor =
|
|||
export type ColorTuple = readonly [string, string, string, string, string];
|
||||
export type ColorPalette = Merge<
|
||||
Record<ColorPickerColor, ColorTuple>,
|
||||
{ black: string; white: string; transparent: string }
|
||||
{ black: "#1e1e1e"; white: "#ffffff"; transparent: "transparent" }
|
||||
>;
|
||||
|
||||
// used general type instead of specific type (ColorPalette) to support custom colors
|
||||
|
|
|
@ -37,7 +37,7 @@ import {
|
|||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { EmbedIcon, extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
|
@ -268,6 +268,7 @@ export const ShapesSwitcher = ({
|
|||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
activeEmbeddable: null,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
|
@ -285,39 +286,72 @@ export const ShapesSwitcher = ({
|
|||
<div className="App-toolbar__divider" />
|
||||
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
|
||||
{device.isMobile ? (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
<>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(
|
||||
t("toolBar.frame"),
|
||||
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid={`toolbar-frame`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={EmbedIcon}
|
||||
checked={activeTool.type === "embeddable"}
|
||||
name="editor-current-shape"
|
||||
title={capitalizeString(t("toolBar.embeddable"))}
|
||||
aria-label={capitalizeString(t("toolBar.embeddable"))}
|
||||
data-testid={`toolbar-embeddable`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
trackEvent("toolbar", "embeddable", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
|
@ -349,6 +383,22 @@ export const ShapesSwitcher = ({
|
|||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={EmbedIcon}
|
||||
data-testid="toolbar-embeddable"
|
||||
>
|
||||
{t("toolBar.embeddable")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
|
|
@ -4,8 +4,9 @@ import { reseed } from "../random";
|
|||
import { render, queryByTestId } from "../tests/test-utils";
|
||||
|
||||
import ExcalidrawApp from "../excalidraw-app";
|
||||
import { vi } from "vitest";
|
||||
|
||||
const renderScene = jest.spyOn(Renderer, "renderScene");
|
||||
const renderScene = vi.spyOn(Renderer, "renderScene");
|
||||
|
||||
describe("Test <App/>", () => {
|
||||
beforeEach(async () => {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -8,7 +8,7 @@ import {
|
|||
} from "./colorPickerUtils";
|
||||
import HotkeyLabel from "./HotkeyLabel";
|
||||
import { ColorPaletteCustom } from "../../colors";
|
||||
import { t } from "../../i18n";
|
||||
import { TranslationKeys, t } from "../../i18n";
|
||||
|
||||
interface PickerColorListProps {
|
||||
palette: ColorPaletteCustom;
|
||||
|
@ -48,7 +48,11 @@ const PickerColorList = ({
|
|||
(Array.isArray(value) ? value[activeShade] : value) || "transparent";
|
||||
|
||||
const keybinding = colorPickerHotkeyBindings[index];
|
||||
const label = t(`colors.${key.replace(/\d+/, "")}`, null, "");
|
||||
const label = t(
|
||||
`colors.${key.replace(/\d+/, "")}` as unknown as TranslationKeys,
|
||||
null,
|
||||
"",
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import clsx from "clsx";
|
||||
import { Popover } from "./Popover";
|
||||
import { t } from "../i18n";
|
||||
import { t, TranslationKeys } from "../i18n";
|
||||
|
||||
import "./ContextMenu.scss";
|
||||
import {
|
||||
|
@ -83,9 +83,15 @@ export const ContextMenu = React.memo(
|
|||
let label = "";
|
||||
if (item.contextItemLabel) {
|
||||
if (typeof item.contextItemLabel === "function") {
|
||||
label = t(item.contextItemLabel(elements, appState));
|
||||
label = t(
|
||||
item.contextItemLabel(
|
||||
elements,
|
||||
appState,
|
||||
actionManager.app,
|
||||
) as unknown as TranslationKeys,
|
||||
);
|
||||
} else {
|
||||
label = t(item.contextItemLabel);
|
||||
label = t(item.contextItemLabel as unknown as TranslationKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,16 +17,34 @@ import { useSetAtom } from "jotai";
|
|||
import { isLibraryMenuOpenAtom } from "./LibraryMenu";
|
||||
import { jotaiScope } from "../jotai";
|
||||
|
||||
export type DialogSize = number | "small" | "regular" | "wide" | undefined;
|
||||
|
||||
export interface DialogProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
size?: "small" | "regular" | "wide";
|
||||
size?: DialogSize;
|
||||
onCloseRequest(): void;
|
||||
title: React.ReactNode | false;
|
||||
autofocus?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
function getDialogSize(size: DialogSize): number {
|
||||
if (size && typeof size === "number") {
|
||||
return size;
|
||||
}
|
||||
|
||||
switch (size) {
|
||||
case "small":
|
||||
return 550;
|
||||
case "wide":
|
||||
return 1024;
|
||||
case "regular":
|
||||
default:
|
||||
return 800;
|
||||
}
|
||||
}
|
||||
|
||||
export const Dialog = (props: DialogProps) => {
|
||||
const [islandNode, setIslandNode] = useCallbackRefState<HTMLDivElement>();
|
||||
const [lastActiveElement] = useState(document.activeElement);
|
||||
|
@ -85,9 +103,7 @@ export const Dialog = (props: DialogProps) => {
|
|||
<Modal
|
||||
className={clsx("Dialog", props.className)}
|
||||
labelledBy="dialog-title"
|
||||
maxWidth={
|
||||
props.size === "wide" ? 1024 : props.size === "small" ? 550 : 800
|
||||
}
|
||||
maxWidth={getDialogSize(props.size)}
|
||||
onCloseRequest={onClose}
|
||||
closeOnClickOutside={props.closeOnClickOutside}
|
||||
>
|
||||
|
|
|
@ -58,7 +58,7 @@ export const EyeDropper: React.FC<{
|
|||
return;
|
||||
}
|
||||
|
||||
let currentColor = COLOR_PALETTE.black;
|
||||
let currentColor: string = COLOR_PALETTE.black;
|
||||
let isHoldingPointerDown = false;
|
||||
|
||||
const ctx = app.canvas.getContext("2d")!;
|
||||
|
@ -77,8 +77,8 @@ export const EyeDropper: React.FC<{
|
|||
colorPreviewDiv.style.left = `${clientX + 20}px`;
|
||||
|
||||
const pixel = ctx.getImageData(
|
||||
clientX * window.devicePixelRatio - appState.offsetLeft,
|
||||
clientY * window.devicePixelRatio - appState.offsetTop,
|
||||
(clientX - appState.offsetLeft) * window.devicePixelRatio,
|
||||
(clientY - appState.offsetTop) * window.devicePixelRatio,
|
||||
1,
|
||||
1,
|
||||
).data;
|
||||
|
|
|
@ -2,20 +2,140 @@
|
|||
|
||||
.excalidraw {
|
||||
.ExcButton {
|
||||
&--color-primary {
|
||||
color: var(--input-bg-color);
|
||||
--text-color: transparent;
|
||||
--border-color: transparent;
|
||||
--back-color: transparent;
|
||||
|
||||
--accent-color: var(--color-primary);
|
||||
--accent-color-hover: var(--color-primary-darker);
|
||||
--accent-color-active: var(--color-primary-darkest);
|
||||
color: var(--text-color);
|
||||
background-color: var(--back-color);
|
||||
border-color: var(--border-color);
|
||||
|
||||
&--color-primary {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--input-bg-color);
|
||||
--back-color: var(--color-primary);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-primary);
|
||||
--border-color: var(--color-primary);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-primary-darker);
|
||||
--border-color: var(--color-primary-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-primary-darkest);
|
||||
--border-color: var(--color-primary-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-danger {
|
||||
color: var(--input-bg-color);
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--color-danger-text);
|
||||
--back-color: var(--color-danger-dark);
|
||||
|
||||
--accent-color: var(--color-danger);
|
||||
--accent-color-hover: #d65550;
|
||||
--accent-color-active: #d1413c;
|
||||
&:hover {
|
||||
--back-color: var(--color-danger-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-danger-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-danger);
|
||||
--border-color: var(--color-danger);
|
||||
--back-color: transparent;
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-danger-darkest);
|
||||
--border-color: var(--color-danger-darkest);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-danger-darker);
|
||||
--border-color: var(--color-danger-darker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-muted {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: var(--island-bg-color);
|
||||
--back-color: var(--color-gray-50);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-gray-60);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-gray-80);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-muted-background);
|
||||
--border-color: var(--color-muted);
|
||||
--back-color: var(--island-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-muted-background-darker);
|
||||
--border-color: var(--color-muted-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--color-warning {
|
||||
&.ExcButton--variant-filled {
|
||||
--text-color: black;
|
||||
--back-color: var(--color-warning-dark);
|
||||
|
||||
&:hover {
|
||||
--back-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--back-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
|
||||
&.ExcButton--variant-outlined,
|
||||
&.ExcButton--variant-icon {
|
||||
--text-color: var(--color-warning-dark);
|
||||
--border-color: var(--color-warning-dark);
|
||||
--back-color: var(--input-bg-color);
|
||||
|
||||
&:hover {
|
||||
--text-color: var(--color-warning-darker);
|
||||
--border-color: var(--color-warning-darker);
|
||||
}
|
||||
|
||||
&:active {
|
||||
--text-color: var(--color-warning-darkest);
|
||||
--border-color: var(--color-warning-darkest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
display: flex;
|
||||
|
@ -25,6 +145,8 @@
|
|||
flex-wrap: nowrap;
|
||||
|
||||
border-radius: 0.5rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
|
||||
font-family: "Assistant";
|
||||
|
||||
|
@ -33,9 +155,9 @@
|
|||
transition: all 150ms ease-out;
|
||||
|
||||
&--size-large {
|
||||
font-weight: 400;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
height: 3rem;
|
||||
min-height: 3rem;
|
||||
padding: 0.5rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
|
@ -45,48 +167,22 @@
|
|||
&--size-medium {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
height: 2.5rem;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
&--variant-filled {
|
||||
background: var(--accent-color);
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-color-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--accent-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-outlined,
|
||||
&--variant-icon {
|
||||
border: 1px solid var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--accent-color-hover);
|
||||
color: var(--accent-color-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 1px solid var(--accent-color-active);
|
||||
color: var(--accent-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&--variant-icon {
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 3rem;
|
||||
}
|
||||
|
||||
&--fullWidth {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
|
|
|
@ -4,7 +4,7 @@ import clsx from "clsx";
|
|||
import "./FilledButton.scss";
|
||||
|
||||
export type ButtonVariant = "filled" | "outlined" | "icon";
|
||||
export type ButtonColor = "primary" | "danger";
|
||||
export type ButtonColor = "primary" | "danger" | "warning" | "muted";
|
||||
export type ButtonSize = "medium" | "large";
|
||||
|
||||
export type FilledButtonProps = {
|
||||
|
@ -17,6 +17,7 @@ export type FilledButtonProps = {
|
|||
color?: ButtonColor;
|
||||
size?: ButtonSize;
|
||||
className?: string;
|
||||
fullWidth?: boolean;
|
||||
|
||||
startIcon?: React.ReactNode;
|
||||
};
|
||||
|
@ -31,6 +32,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||
variant = "filled",
|
||||
color = "primary",
|
||||
size = "medium",
|
||||
fullWidth,
|
||||
className,
|
||||
},
|
||||
ref,
|
||||
|
@ -42,6 +44,7 @@ export const FilledButton = forwardRef<HTMLButtonElement, FilledButtonProps>(
|
|||
`ExcButton--color-${color}`,
|
||||
`ExcButton--variant-${variant}`,
|
||||
`ExcButton--size-${size}`,
|
||||
{ "ExcButton--fullWidth": fullWidth },
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
|
|
|
@ -12,7 +12,7 @@ const Header = () => (
|
|||
<div className="HelpDialog__header">
|
||||
<a
|
||||
className="HelpDialog__btn"
|
||||
href="https://github.com/excalidraw/excalidraw#documentation"
|
||||
href="https://docs.excalidraw.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { t } from "../i18n";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { Device, UIAppState } from "../types";
|
||||
import { AppClassProperties, Device, UIAppState } from "../types";
|
||||
import {
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
|
@ -15,17 +13,12 @@ import "./HintViewer.scss";
|
|||
|
||||
interface HintViewerProps {
|
||||
appState: UIAppState;
|
||||
elements: readonly NonDeletedExcalidrawElement[];
|
||||
isMobile: boolean;
|
||||
device: Device;
|
||||
app: AppClassProperties;
|
||||
}
|
||||
|
||||
const getHints = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
}: HintViewerProps) => {
|
||||
const getHints = ({ appState, isMobile, device, app }: HintViewerProps) => {
|
||||
const { activeTool, isResizing, isRotating, lastPointerDownWith } = appState;
|
||||
const multiMode = appState.multiElement !== null;
|
||||
|
||||
|
@ -51,11 +44,15 @@ const getHints = ({
|
|||
return t("hints.text");
|
||||
}
|
||||
|
||||
if (activeTool.type === "embeddable") {
|
||||
return t("hints.embeddable");
|
||||
}
|
||||
|
||||
if (appState.activeTool.type === "image" && appState.pendingImageElementId) {
|
||||
return t("hints.placeImage");
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (
|
||||
isResizing &&
|
||||
|
@ -115,15 +112,15 @@ const getHints = ({
|
|||
|
||||
export const HintViewer = ({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
app,
|
||||
}: HintViewerProps) => {
|
||||
let hint = getHints({
|
||||
appState,
|
||||
elements,
|
||||
isMobile,
|
||||
device,
|
||||
app,
|
||||
});
|
||||
if (!hint) {
|
||||
return null;
|
||||
|
|
|
@ -41,6 +41,7 @@ import { jotaiScope } from "../jotai";
|
|||
import { Provider, useAtom, useAtomValue } from "jotai";
|
||||
import MainMenu from "./main-menu/MainMenu";
|
||||
import { ActiveConfirmDialog } from "./ActiveConfirmDialog";
|
||||
import { OverwriteConfirmDialog } from "./OverwriteConfirm/OverwriteConfirm";
|
||||
import { HandButton } from "./HandButton";
|
||||
import { isHandToolActive } from "../appState";
|
||||
import { TunnelsContext, useInitializeTunnels } from "../context/tunnels";
|
||||
|
@ -71,6 +72,7 @@ interface LayerUIProps {
|
|||
onExportImage: AppClassProperties["onExportImage"];
|
||||
renderWelcomeScreen: boolean;
|
||||
children?: React.ReactNode;
|
||||
app: AppClassProperties;
|
||||
}
|
||||
|
||||
const DefaultMainMenu: React.FC<{
|
||||
|
@ -99,6 +101,15 @@ const DefaultMainMenu: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const DefaultOverwriteConfirmDialog = () => {
|
||||
return (
|
||||
<OverwriteConfirmDialog __fallback>
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
</OverwriteConfirmDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
|
@ -117,6 +128,7 @@ const LayerUI = ({
|
|||
onExportImage,
|
||||
renderWelcomeScreen,
|
||||
children,
|
||||
app,
|
||||
}: LayerUIProps) => {
|
||||
const device = useDevice();
|
||||
const tunnels = useInitializeTunnels();
|
||||
|
@ -230,9 +242,9 @@ const LayerUI = ({
|
|||
>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={device.isMobile}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
{heading}
|
||||
<Stack.Row gap={1}>
|
||||
|
@ -343,6 +355,7 @@ const LayerUI = ({
|
|||
>
|
||||
{t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
|
@ -374,6 +387,7 @@ const LayerUI = ({
|
|||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{appState.pasteDialog.shown && (
|
||||
|
@ -387,8 +401,9 @@ const LayerUI = ({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{device.isMobile && !eyeDropperState && (
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
app={app}
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
actionManager={actionManager}
|
||||
|
|
|
@ -29,6 +29,7 @@ import "./LibraryMenu.scss";
|
|||
import { LibraryMenuControlButtons } from "./LibraryMenuControlButtons";
|
||||
import { isShallowEqual } from "../utils";
|
||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||
import { LIBRARY_DISABLED_TYPES } from "../constants";
|
||||
|
||||
export const isLibraryMenuOpenAtom = atom(false);
|
||||
|
||||
|
@ -68,11 +69,12 @@ export const LibraryMenuContent = ({
|
|||
libraryItems: LibraryItems,
|
||||
) => {
|
||||
trackEvent("element", "addToLibrary", "ui");
|
||||
if (processedElements.some((element) => element.type === "image")) {
|
||||
return setAppState({
|
||||
errorMessage:
|
||||
"Support for adding images to the library coming soon!",
|
||||
});
|
||||
for (const type of LIBRARY_DISABLED_TYPES) {
|
||||
if (processedElements.some((element) => element.type === type)) {
|
||||
return setAppState({
|
||||
errorMessage: t(`errors.libraryElementTypeError.${type}`),
|
||||
});
|
||||
}
|
||||
}
|
||||
const nextItems: LibraryItems = [
|
||||
{
|
||||
|
@ -197,6 +199,7 @@ export const LibraryMenu = () => {
|
|||
setAppState({
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
activeEmbeddable: null,
|
||||
});
|
||||
}, [setAppState]);
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ const LibraryMenuBrowseButton = ({
|
|||
return (
|
||||
<a
|
||||
className="library-menu-browse-button"
|
||||
href={`${process.env.REACT_APP_LIBRARY_URL}?target=${
|
||||
href={`${import.meta.env.VITE_APP_LIBRARY_URL}?target=${
|
||||
window.name || "_blank"
|
||||
}&referrer=${referrer}&useHash=true&token=${id}&theme=${theme}&version=${
|
||||
VERSIONS.excalidrawLibrary
|
||||
|
|
|
@ -12,6 +12,11 @@
|
|||
box-sizing: border-box;
|
||||
border-radius: var(--border-radius-lg);
|
||||
|
||||
svg {
|
||||
// to prevent clicks on links and such
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
import React from "react";
|
||||
import { AppState, Device, ExcalidrawProps, UIAppState } from "../types";
|
||||
import {
|
||||
AppClassProperties,
|
||||
AppState,
|
||||
Device,
|
||||
ExcalidrawProps,
|
||||
UIAppState,
|
||||
} from "../types";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { t } from "../i18n";
|
||||
import Stack from "./Stack";
|
||||
|
@ -41,6 +47,7 @@ type MobileMenuProps = {
|
|||
renderSidebars: () => JSX.Element | null;
|
||||
device: Device;
|
||||
renderWelcomeScreen: boolean;
|
||||
app: AppClassProperties;
|
||||
};
|
||||
|
||||
export const MobileMenu = ({
|
||||
|
@ -58,6 +65,7 @@ export const MobileMenu = ({
|
|||
renderSidebars,
|
||||
device,
|
||||
renderWelcomeScreen,
|
||||
app,
|
||||
}: MobileMenuProps) => {
|
||||
const {
|
||||
WelcomeScreenCenterTunnel,
|
||||
|
@ -119,9 +127,9 @@ export const MobileMenu = ({
|
|||
</Section>
|
||||
<HintViewer
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
isMobile={true}
|
||||
device={device}
|
||||
app={app}
|
||||
/>
|
||||
</FixedSideContainer>
|
||||
);
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
.excalidraw {
|
||||
&.excalidraw-modal-container {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
z-index: var(--zIndex-modal);
|
||||
}
|
||||
|
||||
.Modal {
|
||||
|
|
126
src/components/OverwriteConfirm/OverwriteConfirm.scss
Normal file
126
src/components/OverwriteConfirm/OverwriteConfirm.scss
Normal file
|
@ -0,0 +1,126 @@
|
|||
@import "../../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.OverwriteConfirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
isolation: isolate;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
|
||||
font-weight: 700;
|
||||
font-size: 1.3125rem;
|
||||
line-height: 130%;
|
||||
align-self: flex-start;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
&__Description {
|
||||
box-sizing: border-box;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 1rem;
|
||||
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
padding: 2.5rem;
|
||||
|
||||
background: var(--color-danger-background);
|
||||
border-radius: 0.5rem;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-size: 1rem;
|
||||
line-height: 150%;
|
||||
|
||||
color: var(--color-danger-color);
|
||||
|
||||
&__spacer {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 2.5rem;
|
||||
background: var(--color-danger-icon-background);
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
|
||||
padding: 0.75rem;
|
||||
|
||||
svg {
|
||||
color: var(--color-danger-icon-color);
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.OverwriteConfirm__Description--color-warning {
|
||||
background: var(--color-warning-background);
|
||||
color: var(--color-warning-color);
|
||||
|
||||
.OverwriteConfirm__Description__icon {
|
||||
background: var(--color-warning-icon-background);
|
||||
flex: 0 0 auto;
|
||||
|
||||
svg {
|
||||
color: var(--color-warning-icon-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__Actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
@include isMobile {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__Action {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
flex-basis: 50%;
|
||||
flex-grow: 0;
|
||||
|
||||
&__content {
|
||||
height: 100%;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
line-height: 130%;
|
||||
|
||||
margin: 0;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
76
src/components/OverwriteConfirm/OverwriteConfirm.tsx
Normal file
76
src/components/OverwriteConfirm/OverwriteConfirm.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import React from "react";
|
||||
import { useAtom } from "jotai";
|
||||
|
||||
import { useTunnels } from "../../context/tunnels";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { Dialog } from "../Dialog";
|
||||
import { withInternalFallback } from "../hoc/withInternalFallback";
|
||||
import { overwriteConfirmStateAtom } from "./OverwriteConfirmState";
|
||||
|
||||
import { FilledButton } from "../FilledButton";
|
||||
import { alertTriangleIcon } from "../icons";
|
||||
import { Actions, Action } from "./OverwriteConfirmActions";
|
||||
import "./OverwriteConfirm.scss";
|
||||
|
||||
export type OverwriteConfirmDialogProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const OverwriteConfirmDialog = Object.assign(
|
||||
withInternalFallback(
|
||||
"OverwriteConfirmDialog",
|
||||
({ children }: OverwriteConfirmDialogProps) => {
|
||||
const { OverwriteConfirmDialogTunnel } = useTunnels();
|
||||
const [overwriteConfirmState, setState] = useAtom(
|
||||
overwriteConfirmStateAtom,
|
||||
jotaiScope,
|
||||
);
|
||||
|
||||
if (!overwriteConfirmState.active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
overwriteConfirmState.onClose();
|
||||
setState((state) => ({ ...state, active: false }));
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
overwriteConfirmState.onConfirm();
|
||||
setState((state) => ({ ...state, active: false }));
|
||||
};
|
||||
|
||||
return (
|
||||
<OverwriteConfirmDialogTunnel.In>
|
||||
<Dialog onCloseRequest={handleClose} title={false} size={916}>
|
||||
<div className="OverwriteConfirm">
|
||||
<h3>{overwriteConfirmState.title}</h3>
|
||||
<div
|
||||
className={`OverwriteConfirm__Description OverwriteConfirm__Description--color-${overwriteConfirmState.color}`}
|
||||
>
|
||||
<div className="OverwriteConfirm__Description__icon">
|
||||
{alertTriangleIcon}
|
||||
</div>
|
||||
<div>{overwriteConfirmState.description}</div>
|
||||
<div className="OverwriteConfirm__Description__spacer"></div>
|
||||
<FilledButton
|
||||
color={overwriteConfirmState.color}
|
||||
size="large"
|
||||
label={overwriteConfirmState.actionLabel}
|
||||
onClick={handleConfirm}
|
||||
/>
|
||||
</div>
|
||||
<Actions>{children}</Actions>
|
||||
</div>
|
||||
</Dialog>
|
||||
</OverwriteConfirmDialogTunnel.In>
|
||||
);
|
||||
},
|
||||
),
|
||||
{
|
||||
Actions,
|
||||
Action,
|
||||
},
|
||||
);
|
||||
|
||||
export { OverwriteConfirmDialog };
|
85
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx
Normal file
85
src/components/OverwriteConfirm/OverwriteConfirmActions.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
import { FilledButton } from "../FilledButton";
|
||||
import { useExcalidrawActionManager, useExcalidrawSetAppState } from "../App";
|
||||
import { actionSaveFileToDisk } from "../../actions";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { actionChangeExportEmbedScene } from "../../actions/actionExport";
|
||||
|
||||
export type ActionProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actionLabel: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
title,
|
||||
children,
|
||||
actionLabel,
|
||||
onClick,
|
||||
}: ActionProps) => {
|
||||
return (
|
||||
<div className="OverwriteConfirm__Actions__Action">
|
||||
<h4>{title}</h4>
|
||||
<div className="OverwriteConfirm__Actions__Action__content">
|
||||
{children}
|
||||
</div>
|
||||
<FilledButton
|
||||
variant="outlined"
|
||||
color="muted"
|
||||
label={actionLabel}
|
||||
size="large"
|
||||
fullWidth
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExportToImage = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
return (
|
||||
<Action
|
||||
title={t("overwriteConfirm.action.exportToImage.title")}
|
||||
actionLabel={t("overwriteConfirm.action.exportToImage.button")}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionChangeExportEmbedScene, "ui", true);
|
||||
setAppState({ openDialog: "imageExport" });
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.exportToImage.description")}
|
||||
</Action>
|
||||
);
|
||||
};
|
||||
|
||||
export const SaveToDisk = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
|
||||
return (
|
||||
<Action
|
||||
title={t("overwriteConfirm.action.saveToDisk.title")}
|
||||
actionLabel={t("overwriteConfirm.action.saveToDisk.button")}
|
||||
onClick={() => {
|
||||
actionManager.executeAction(actionSaveFileToDisk, "ui");
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.saveToDisk.description")}
|
||||
</Action>
|
||||
);
|
||||
};
|
||||
|
||||
const Actions = Object.assign(
|
||||
({ children }: { children: React.ReactNode }) => {
|
||||
return <div className="OverwriteConfirm__Actions">{children}</div>;
|
||||
},
|
||||
{
|
||||
ExportToImage,
|
||||
SaveToDisk,
|
||||
},
|
||||
);
|
||||
|
||||
export { Actions };
|
46
src/components/OverwriteConfirm/OverwriteConfirmState.ts
Normal file
46
src/components/OverwriteConfirm/OverwriteConfirmState.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { atom } from "jotai";
|
||||
import { jotaiStore } from "../../jotai";
|
||||
import React from "react";
|
||||
|
||||
export type OverwriteConfirmState =
|
||||
| {
|
||||
active: true;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
actionLabel: string;
|
||||
color: "danger" | "warning";
|
||||
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
onReject: () => void;
|
||||
}
|
||||
| { active: false };
|
||||
|
||||
export const overwriteConfirmStateAtom = atom<OverwriteConfirmState>({
|
||||
active: false,
|
||||
});
|
||||
|
||||
export async function openConfirmModal({
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
actionLabel: string;
|
||||
color: "danger" | "warning";
|
||||
}) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
jotaiStore.set(overwriteConfirmStateAtom, {
|
||||
active: true,
|
||||
onConfirm: () => resolve(true),
|
||||
onClose: () => resolve(false),
|
||||
onReject: () => resolve(false),
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
color,
|
||||
});
|
||||
});
|
||||
}
|
|
@ -319,7 +319,7 @@ const PublishLibrary = ({
|
|||
formData.append("twitterHandle", libraryData.twitterHandle);
|
||||
formData.append("website", libraryData.website);
|
||||
|
||||
fetch(`${process.env.REACT_APP_LIBRARY_BACKEND}/submit`, {
|
||||
fetch(`${import.meta.env.VITE_APP_LIBRARY_BACKEND}/submit`, {
|
||||
method: "post",
|
||||
body: formData,
|
||||
})
|
||||
|
|
|
@ -3,7 +3,7 @@ import { t } from "../i18n";
|
|||
import { useExcalidrawContainer } from "./App";
|
||||
|
||||
export const Section: React.FC<{
|
||||
heading: string;
|
||||
heading: "canvasActions" | "selectedShapeActions" | "shapes";
|
||||
children?: React.ReactNode | ((heading: React.ReactNode) => React.ReactNode);
|
||||
className?: string;
|
||||
}> = ({ heading, children, ...props }) => {
|
||||
|
|
91
src/components/ShareableLinkDialog.scss
Normal file
91
src/components/ShareableLinkDialog.scss
Normal file
|
@ -0,0 +1,91 @@
|
|||
@import "../css/variables.module";
|
||||
|
||||
.excalidraw {
|
||||
.ShareableLinkDialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
|
||||
color: var(--text-primary-color);
|
||||
|
||||
::selection {
|
||||
background: var(--color-primary-light-darker);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-family: "Assistant";
|
||||
font-weight: 700;
|
||||
font-size: 1.313rem;
|
||||
line-height: 130%;
|
||||
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__popover {
|
||||
@keyframes RoomDialog__popover__scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 0.125rem 0.5rem;
|
||||
gap: 0.125rem;
|
||||
|
||||
height: 1.125rem;
|
||||
|
||||
border: none;
|
||||
border-radius: 0.6875rem;
|
||||
|
||||
font-family: "Assistant";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
line-height: 110%;
|
||||
|
||||
background: var(--color-success-lighter);
|
||||
color: var(--color-success);
|
||||
|
||||
& > svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
transform-origin: var(--radix-popover-content-transform-origin);
|
||||
animation: RoomDialog__popover__scaleIn 150ms ease-out;
|
||||
}
|
||||
|
||||
&__linkRow {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
&__description {
|
||||
border-top: 1px solid var(--color-gray-20);
|
||||
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
line-height: 150%;
|
||||
|
||||
& p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
91
src/components/ShareableLinkDialog.tsx
Normal file
91
src/components/ShareableLinkDialog.tsx
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { useRef, useState } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { copyTextToSystemClipboard } from "../clipboard";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
import { Dialog } from "./Dialog";
|
||||
import { TextField } from "./TextField";
|
||||
import { FilledButton } from "./FilledButton";
|
||||
import { copyIcon, tablerCheckIcon } from "./icons";
|
||||
|
||||
import "./ShareableLinkDialog.scss";
|
||||
|
||||
export type ShareableLinkDialogProps = {
|
||||
link: string;
|
||||
|
||||
onCloseRequest: () => void;
|
||||
setErrorMessage: (error: string) => void;
|
||||
};
|
||||
|
||||
export const ShareableLinkDialog = ({
|
||||
link,
|
||||
onCloseRequest,
|
||||
setErrorMessage,
|
||||
}: ShareableLinkDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
const [justCopied, setJustCopied] = useState(false);
|
||||
const timerRef = useRef<number>(0);
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(link);
|
||||
|
||||
setJustCopied(true);
|
||||
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setJustCopied(false);
|
||||
}, 3000);
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message);
|
||||
}
|
||||
|
||||
ref.current?.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onCloseRequest={onCloseRequest} title={false} size="small">
|
||||
<div className="ShareableLinkDialog">
|
||||
<h3>Shareable link</h3>
|
||||
<div className="ShareableLinkDialog__linkRow">
|
||||
<TextField
|
||||
ref={ref}
|
||||
label="Link"
|
||||
readonly
|
||||
fullWidth
|
||||
value={link}
|
||||
selectOnRender
|
||||
/>
|
||||
<Popover.Root open={justCopied}>
|
||||
<Popover.Trigger asChild>
|
||||
<FilledButton
|
||||
size="large"
|
||||
label="Copy link"
|
||||
startIcon={copyIcon}
|
||||
onClick={copyRoomLink}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
className="ShareableLinkDialog__popover"
|
||||
side="top"
|
||||
align="end"
|
||||
sideOffset={5.5}
|
||||
>
|
||||
{tablerCheckIcon} copied
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
<div className="ShareableLinkDialog__description">
|
||||
🔒 {t("alerts.uploadedSecurly")}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
|
@ -10,6 +10,7 @@ import {
|
|||
waitFor,
|
||||
withExcalidrawDimensions,
|
||||
} from "../../tests/test-utils";
|
||||
import { vi } from "vitest";
|
||||
|
||||
export const assertSidebarDockButton = async <T extends boolean>(
|
||||
hasDockButton: T,
|
||||
|
@ -205,7 +206,7 @@ describe("Sidebar", () => {
|
|||
});
|
||||
|
||||
it("<Sidebar.Header> should render close button", async () => {
|
||||
const onStateChange = jest.fn();
|
||||
const onStateChange = vi.fn();
|
||||
const CustomExcalidraw = () => {
|
||||
return (
|
||||
<Excalidraw
|
||||
|
|
|
@ -53,7 +53,7 @@ export const SidebarInner = forwardRef(
|
|||
}: SidebarProps & Omit<React.RefAttributes<HTMLDivElement>, "onSelect">,
|
||||
ref: React.ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
if (process.env.NODE_ENV === "development" && onDock && docked == null) {
|
||||
if (import.meta.env.DEV && onDock && docked == null) {
|
||||
console.warn(
|
||||
"Sidebar: `docked` must be set when `onDock` is supplied for the sidebar to be user-dockable. To hide this message, either pass `docked` or remove `onDock`",
|
||||
);
|
||||
|
|
|
@ -1,4 +1,10 @@
|
|||
import { forwardRef, useRef, useImperativeHandle, KeyboardEvent } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useRef,
|
||||
useImperativeHandle,
|
||||
KeyboardEvent,
|
||||
useLayoutEffect,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import "./TextField.scss";
|
||||
|
@ -12,6 +18,7 @@ export type TextFieldProps = {
|
|||
|
||||
readonly?: boolean;
|
||||
fullWidth?: boolean;
|
||||
selectOnRender?: boolean;
|
||||
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
|
@ -19,13 +26,28 @@ export type TextFieldProps = {
|
|||
|
||||
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
|
||||
(
|
||||
{ value, onChange, label, fullWidth, placeholder, readonly, onKeyDown },
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
fullWidth,
|
||||
placeholder,
|
||||
readonly,
|
||||
selectOnRender,
|
||||
onKeyDown,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const innerRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => innerRef.current!);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (selectOnRender) {
|
||||
innerRef.current?.select();
|
||||
}
|
||||
}, [selectOnRender]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("ExcTextField", {
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
Roboto, Helvetica, Arial, sans-serif;
|
||||
font-family: var(--ui-font);
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
z-index: var(--zIndex-popup);
|
||||
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
|
|
|
@ -3,6 +3,7 @@ import { render } from "@testing-library/react";
|
|||
import fallbackLangData from "../locales/en.json";
|
||||
|
||||
import Trans from "./Trans";
|
||||
import { TranslationKeys } from "../i18n";
|
||||
|
||||
describe("Test <Trans/>", () => {
|
||||
it("should translate the the strings correctly", () => {
|
||||
|
@ -18,24 +19,27 @@ describe("Test <Trans/>", () => {
|
|||
const { getByTestId } = render(
|
||||
<>
|
||||
<div data-testid="test1">
|
||||
<Trans i18nKey="transTest.key1" audience="world" />
|
||||
<Trans
|
||||
i18nKey={"transTest.key1" as unknown as TranslationKeys}
|
||||
audience="world"
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test2">
|
||||
<Trans
|
||||
i18nKey="transTest.key2"
|
||||
i18nKey={"transTest.key2" as unknown as TranslationKeys}
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test3">
|
||||
<Trans
|
||||
i18nKey="transTest.key3"
|
||||
i18nKey={"transTest.key3" as unknown as TranslationKeys}
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
/>
|
||||
</div>
|
||||
<div data-testid="test4">
|
||||
<Trans
|
||||
i18nKey="transTest.key4"
|
||||
i18nKey={"transTest.key4" as unknown as TranslationKeys}
|
||||
link={(el) => <a href="https://example.com">{el}</a>}
|
||||
location="the button"
|
||||
bold={(el) => <strong>{el}</strong>}
|
||||
|
@ -43,7 +47,7 @@ describe("Test <Trans/>", () => {
|
|||
</div>
|
||||
<div data-testid="test5">
|
||||
<Trans
|
||||
i18nKey="transTest.key5"
|
||||
i18nKey={"transTest.key5" as unknown as TranslationKeys}
|
||||
connect-link={(el) => <a href="https://example.com">{el}</a>}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
|
||||
import { useI18n } from "../i18n";
|
||||
import { TranslationKeys, useI18n } from "../i18n";
|
||||
|
||||
// Used for splitting i18nKey into tokens in Trans component
|
||||
// Example:
|
||||
|
@ -153,7 +153,7 @@ const Trans = ({
|
|||
children,
|
||||
...props
|
||||
}: {
|
||||
i18nKey: string;
|
||||
i18nKey: TranslationKeys;
|
||||
[key: string]: React.ReactNode | ((el: React.ReactNode) => React.ReactNode);
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Test <App/> should show error modal when using brave and measureText API is not working 1`] = `
|
||||
exports[`Test <App/> > should show error modal when using brave and measureText API is not working 1`] = `
|
||||
<div
|
||||
data-testid="brave-measure-text-error"
|
||||
>
|
||||
|
|
|
@ -396,6 +396,14 @@ export const TrashIcon = createIcon(
|
|||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const EmbedIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<polyline points="12 16 18 10 12 4" />
|
||||
<polyline points="8 4 2 10 8 16" />
|
||||
</g>,
|
||||
modifiedTablerIconProps,
|
||||
);
|
||||
|
||||
export const DuplicateIcon = createIcon(
|
||||
<g strokeWidth="1.25">
|
||||
<path d="M14.375 6.458H8.958a2.5 2.5 0 0 0-2.5 2.5v5.417a2.5 2.5 0 0 0 2.5 2.5h5.417a2.5 2.5 0 0 0 2.5-2.5V8.958a2.5 2.5 0 0 0-2.5-2.5Z" />
|
||||
|
@ -1608,6 +1616,16 @@ export const tablerCheckIcon = createIcon(
|
|||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const alertTriangleIcon = createIcon(
|
||||
<>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const eyeDropperIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
|
||||
import {
|
||||
useExcalidrawSetAppState,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawElements,
|
||||
useAppProps,
|
||||
} from "../App";
|
||||
import {
|
||||
ExportIcon,
|
||||
ExportImageIcon,
|
||||
|
@ -29,19 +34,42 @@ import { useSetAtom } from "jotai";
|
|||
import { activeConfirmDialogAtom } from "../ActiveConfirmDialog";
|
||||
import { jotaiScope } from "../../jotai";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { openConfirmModal } from "../OverwriteConfirm/OverwriteConfirmState";
|
||||
import Trans from "../Trans";
|
||||
|
||||
export const LoadScene = () => {
|
||||
const { t } = useI18n();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const elements = useExcalidrawElements();
|
||||
|
||||
if (!actionManager.isActionEnabled(actionLoadScene)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSelect = async () => {
|
||||
if (
|
||||
!elements.length ||
|
||||
(await openConfirmModal({
|
||||
title: t("overwriteConfirm.modal.loadFromFile.title"),
|
||||
actionLabel: t("overwriteConfirm.modal.loadFromFile.button"),
|
||||
color: "warning",
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="overwriteConfirm.modal.loadFromFile.description"
|
||||
bold={(text) => <strong>{text}</strong>}
|
||||
br={() => <br />}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
) {
|
||||
actionManager.executeAction(actionLoadScene);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
icon={LoadIcon}
|
||||
onSelect={() => actionManager.executeAction(actionLoadScene)}
|
||||
onSelect={handleSelect}
|
||||
data-testid="load-button"
|
||||
shortcut={getShortcutFromShortcutName("loadScene")}
|
||||
aria-label={t("buttons.load")}
|
||||
|
@ -171,13 +199,20 @@ export const ChangeCanvasBackground = () => {
|
|||
const { t } = useI18n();
|
||||
const appState = useUIAppState();
|
||||
const actionManager = useExcalidrawActionManager();
|
||||
const appProps = useAppProps();
|
||||
|
||||
if (appState.viewModeEnabled) {
|
||||
if (
|
||||
appState.viewModeEnabled ||
|
||||
!appProps.UIOptions.canvasActions.changeViewBackgroundColor
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<div style={{ fontSize: ".75rem", marginBottom: ".5rem" }}>
|
||||
<div
|
||||
data-testid="canvas-background-label"
|
||||
style={{ fontSize: ".75rem", marginBottom: ".5rem" }}
|
||||
>
|
||||
{t("labels.canvasBackground")}
|
||||
</div>
|
||||
<div style={{ padding: "0 0.625rem" }}>
|
||||
|
|
|
@ -71,8 +71,18 @@ export enum EVENT {
|
|||
// custom events
|
||||
EXCALIDRAW_LINK = "excalidraw-link",
|
||||
MENU_ITEM_SELECT = "menu.itemSelect",
|
||||
MESSAGE = "message",
|
||||
}
|
||||
|
||||
export const YOUTUBE_STATES = {
|
||||
UNSTARTED: -1,
|
||||
ENDED: 0,
|
||||
PLAYING: 1,
|
||||
PAUSED: 2,
|
||||
BUFFERING: 3,
|
||||
CUED: 5,
|
||||
} as const;
|
||||
|
||||
export const ENV = {
|
||||
TEST: "test",
|
||||
DEVELOPMENT: "development",
|
||||
|
@ -92,7 +102,7 @@ export const FONT_FAMILY = {
|
|||
export const THEME = {
|
||||
LIGHT: "light",
|
||||
DARK: "dark",
|
||||
};
|
||||
} as const;
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
|
@ -107,6 +117,7 @@ export const FRAME_STYLE = {
|
|||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const MIN_FONT_SIZE = 1;
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
export const DEFAULT_FONT_FAMILY: FontFamilyValues = FONT_FAMILY.Virgil;
|
||||
export const DEFAULT_TEXT_ALIGN = "left";
|
||||
|
@ -229,6 +240,8 @@ export const VERSIONS = {
|
|||
} as const;
|
||||
|
||||
export const BOUND_TEXT_PADDING = 5;
|
||||
export const ARROW_LABEL_WIDTH_FRACTION = 0.7;
|
||||
export const ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO = 11;
|
||||
|
||||
export const VERTICAL_ALIGN = {
|
||||
TOP: "top",
|
||||
|
@ -300,3 +313,5 @@ export const DEFAULT_SIDEBAR = {
|
|||
name: "default",
|
||||
defaultTab: LIBRARY_SIDEBAR_TAB,
|
||||
} as const;
|
||||
|
||||
export const LIBRARY_DISABLED_TYPES = new Set(["embeddable", "image"] as const);
|
||||
|
|
|
@ -12,6 +12,7 @@ type TunnelsContextValue = {
|
|||
FooterCenterTunnel: Tunnel;
|
||||
DefaultSidebarTriggerTunnel: Tunnel;
|
||||
DefaultSidebarTabTriggersTunnel: Tunnel;
|
||||
OverwriteConfirmDialogTunnel: Tunnel;
|
||||
jotaiScope: symbol;
|
||||
};
|
||||
|
||||
|
@ -30,6 +31,7 @@ export const useInitializeTunnels = () => {
|
|||
FooterCenterTunnel: tunnel(),
|
||||
DefaultSidebarTriggerTunnel: tunnel(),
|
||||
DefaultSidebarTabTriggersTunnel: tunnel(),
|
||||
OverwriteConfirmDialogTunnel: tunnel(),
|
||||
jotaiScope: Symbol(),
|
||||
};
|
||||
}, []);
|
||||
|
|
|
@ -5,6 +5,15 @@
|
|||
--zIndex-canvas: 1;
|
||||
--zIndex-wysiwyg: 2;
|
||||
--zIndex-layerUI: 3;
|
||||
|
||||
--zIndex-modal: 1000;
|
||||
--zIndex-popup: 1001;
|
||||
--zIndex-toast: 999999;
|
||||
|
||||
--sab: env(safe-area-inset-bottom);
|
||||
--sal: env(safe-area-inset-left);
|
||||
--sar: env(safe-area-inset-right);
|
||||
--sat: env(safe-area-inset-top);
|
||||
}
|
||||
|
||||
.excalidraw {
|
||||
|
@ -68,6 +77,19 @@
|
|||
position: absolute;
|
||||
}
|
||||
|
||||
&__embeddable {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&__embeddable-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform-origin: top left;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.theme--dark {
|
||||
// The percentage is inspired by
|
||||
// https://material.io/design/color/dark-theme.html#properties, which
|
||||
|
@ -652,3 +674,33 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__embeddable-container {
|
||||
.excalidraw__embeddable-container__inner {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--embeddable-radius);
|
||||
}
|
||||
|
||||
.excalidraw__embeddable__outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
& > * {
|
||||
border-radius: var(--embeddable-radius);
|
||||
}
|
||||
}
|
||||
|
||||
.excalidraw__embeddable-hint {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
padding: 1rem 1.6rem;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
letter-spacing: 0.6px;
|
||||
font-family: "Assistant";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -27,10 +27,6 @@
|
|||
--popup-secondary-bg-color: #{$oc-gray-1};
|
||||
--popup-text-color: #{$oc-black};
|
||||
--popup-text-inverted-color: #{$oc-white};
|
||||
--sab: env(safe-area-inset-bottom);
|
||||
--sal: env(safe-area-inset-left);
|
||||
--sar: env(safe-area-inset-right);
|
||||
--sat: env(safe-area-inset-top);
|
||||
--select-highlight-color: #{$oc-blue-5};
|
||||
--shadow-island: 0px 7px 14px rgba(0, 0, 0, 0.05),
|
||||
0px 0px 3.12708px rgba(0, 0, 0, 0.0798),
|
||||
|
@ -99,9 +95,33 @@
|
|||
--color-gray-100: #121212;
|
||||
|
||||
--color-warning: #fceeca;
|
||||
--color-warning-dark: #f5c354;
|
||||
--color-warning-darker: #f3ab2c;
|
||||
--color-warning-darkest: #ec8b14;
|
||||
--color-text-warning: var(--text-primary-color);
|
||||
|
||||
--color-danger: #db6965;
|
||||
--color-danger-dark: #db6965;
|
||||
--color-danger-darker: #d65550;
|
||||
--color-danger-darkest: #d1413c;
|
||||
--color-danger-text: black;
|
||||
|
||||
--color-danger-background: #fff0f0;
|
||||
--color-danger-icon-background: #ffdad6;
|
||||
--color-danger-color: #700000;
|
||||
--color-danger-icon-color: #700000;
|
||||
|
||||
--color-warning-background: var(--color-warning);
|
||||
--color-warning-icon-background: var(--color-warning-dark);
|
||||
--color-warning-color: var(--text-primary-color);
|
||||
--color-warning-icon-color: var(--text-primary-color);
|
||||
|
||||
--color-muted: var(--color-gray-30);
|
||||
--color-muted-darker: var(--color-gray-60);
|
||||
--color-muted-darkest: var(--color-gray-100);
|
||||
--color-muted-background: var(--color-gray-80);
|
||||
--color-muted-background-darker: var(--color-gray-100);
|
||||
|
||||
--color-promo: #e70078;
|
||||
--color-success: #268029;
|
||||
--color-success-lighter: #cafccc;
|
||||
|
@ -177,6 +197,27 @@
|
|||
--color-text-warning: var(--color-gray-80);
|
||||
|
||||
--color-danger: #ffa8a5;
|
||||
--color-danger-dark: #672120;
|
||||
--color-danger-darker: #8f2625;
|
||||
--color-danger-darkest: #ac2b29;
|
||||
--color-danger-text: #fbcbcc;
|
||||
|
||||
--color-danger-background: #fbcbcc;
|
||||
--color-danger-icon-background: #672120;
|
||||
--color-danger-color: #261919;
|
||||
--color-danger-icon-color: #fbcbcc;
|
||||
|
||||
--color-warning-background: var(--color-warning);
|
||||
--color-warning-icon-background: var(--color-warning-dark);
|
||||
--color-warning-color: var(--color-gray-80);
|
||||
--color-warning-icon-color: var(--color-gray-80);
|
||||
|
||||
--color-muted: var(--color-gray-80);
|
||||
--color-muted-darker: var(--color-gray-60);
|
||||
--color-muted-darkest: var(--color-gray-20);
|
||||
--color-muted-background: var(--color-gray-40);
|
||||
--color-muted-background-darker: var(--color-gray-20);
|
||||
|
||||
--color-promo: #d297ff;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
ExcalidrawSelectionElement,
|
||||
ExcalidrawTextElement,
|
||||
FontFamilyValues,
|
||||
PointBinding,
|
||||
StrokeRoundness,
|
||||
} from "../element/types";
|
||||
import {
|
||||
|
@ -42,6 +43,7 @@ import {
|
|||
measureTextElement,
|
||||
} from "../element/textElement";
|
||||
import { COLOR_PALETTE } from "../colors";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
|
@ -64,6 +66,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||
eraser: false,
|
||||
custom: true,
|
||||
frame: true,
|
||||
embeddable: true,
|
||||
hand: true,
|
||||
};
|
||||
|
||||
|
@ -82,6 +85,13 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
|
|||
return DEFAULT_FONT_FAMILY;
|
||||
};
|
||||
|
||||
const repairBinding = (binding: PointBinding | null) => {
|
||||
if (!binding) {
|
||||
return null;
|
||||
}
|
||||
return { ...binding, focus: binding.focus || 0 };
|
||||
};
|
||||
|
||||
const restoreElementWithProperties = <
|
||||
T extends Required<Omit<ExcalidrawElement, "subtype" | "customData">> & {
|
||||
subtype?: ExcalidrawElement["subtype"];
|
||||
|
@ -144,7 +154,7 @@ const restoreElementWithProperties = <
|
|||
? element.boundElementIds.map((id) => ({ type: "arrow", id }))
|
||||
: element.boundElements ?? [],
|
||||
updated: element.updated ?? getUpdatedTimestamp(),
|
||||
link: element.link ?? null,
|
||||
link: element.link ? normalizeLink(element.link) : null,
|
||||
locked: element.locked ?? false,
|
||||
};
|
||||
|
||||
|
@ -257,8 +267,8 @@ const restoreElement = (
|
|||
(element.type as ExcalidrawElement["type"] | "draw") === "draw"
|
||||
? "line"
|
||||
: element.type,
|
||||
startBinding: element.startBinding,
|
||||
endBinding: element.endBinding,
|
||||
startBinding: repairBinding(element.startBinding),
|
||||
endBinding: repairBinding(element.endBinding),
|
||||
lastCommittedPoint: null,
|
||||
startArrowhead,
|
||||
endArrowhead,
|
||||
|
@ -275,6 +285,10 @@ const restoreElement = (
|
|||
return restoreElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "embeddable":
|
||||
return restoreElementWithProperties(element, {
|
||||
validated: undefined,
|
||||
});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
name: element.name ?? null,
|
||||
|
@ -371,6 +385,24 @@ const repairBoundElement = (
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove an element's frameId if its containing frame is non-existent
|
||||
*
|
||||
* NOTE mutates elements.
|
||||
*/
|
||||
const repairFrameMembership = (
|
||||
element: Mutable<ExcalidrawElement>,
|
||||
elementsMap: Map<string, Mutable<ExcalidrawElement>>,
|
||||
) => {
|
||||
if (element.frameId) {
|
||||
const containingFrame = elementsMap.get(element.frameId);
|
||||
|
||||
if (!containingFrame) {
|
||||
element.frameId = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const restoreElements = (
|
||||
elements: ImportedDataState["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
|
@ -411,6 +443,10 @@ export const restoreElements = (
|
|||
// repair binding. Mutates elements.
|
||||
const restoredElementsMap = arrayToMap(restoredElements);
|
||||
for (const element of restoredElements) {
|
||||
if (element.frameId) {
|
||||
repairFrameMembership(element, restoredElementsMap);
|
||||
}
|
||||
|
||||
if (isTextElement(element) && element.containerId) {
|
||||
repairBoundElement(element, restoredElementsMap);
|
||||
} else if (element.boundElements) {
|
||||
|
|
30
src/data/url.test.tsx
Normal file
30
src/data/url.test.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { normalizeLink } from "./url";
|
||||
|
||||
describe("normalizeLink", () => {
|
||||
// NOTE not an extensive XSS test suite, just to check if we're not
|
||||
// regressing in sanitization
|
||||
it("should sanitize links", () => {
|
||||
expect(
|
||||
// eslint-disable-next-line no-script-url
|
||||
normalizeLink(`javascript://%0aalert(document.domain)`).startsWith(
|
||||
// eslint-disable-next-line no-script-url
|
||||
`javascript:`,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(normalizeLink("ola")).toBe("ola");
|
||||
expect(normalizeLink(" ola")).toBe("ola");
|
||||
|
||||
expect(normalizeLink("https://www.excalidraw.com")).toBe(
|
||||
"https://www.excalidraw.com",
|
||||
);
|
||||
expect(normalizeLink("www.excalidraw.com")).toBe("www.excalidraw.com");
|
||||
expect(normalizeLink("/ola")).toBe("/ola");
|
||||
expect(normalizeLink("http://test")).toBe("http://test");
|
||||
expect(normalizeLink("ftp://test")).toBe("ftp://test");
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("file://")).toBe("file://");
|
||||
expect(normalizeLink("[test](https://test)")).toBe("[test](https://test)");
|
||||
expect(normalizeLink("[[test]]")).toBe("[[test]]");
|
||||
expect(normalizeLink("<test>")).toBe("<test>");
|
||||
});
|
||||
});
|
35
src/data/url.ts
Normal file
35
src/data/url.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (!link) {
|
||||
return link;
|
||||
}
|
||||
return sanitizeUrl(link);
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns URL sanitized and safe for usage in places such as
|
||||
* iframe's src attribute or <a> href attributes.
|
||||
*/
|
||||
export const toValidURL = (link: string) => {
|
||||
link = normalizeLink(link);
|
||||
|
||||
// make relative links into fully-qualified urls
|
||||
if (link.startsWith("/")) {
|
||||
return `${location.origin}${link}`;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(link);
|
||||
} catch {
|
||||
// if link does not parse as URL, assume invalid and return blank page
|
||||
return "about:blank";
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
|
@ -55,10 +55,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.d-none {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&--remove .ToolIcon__icon svg {
|
||||
color: $oc-red-6;
|
||||
}
|
||||
|
|
|
@ -5,8 +5,12 @@ import {
|
|||
viewportCoordsToSceneCoords,
|
||||
wrapEvent,
|
||||
} from "../utils";
|
||||
import { getEmbedLink, embeddableURLValidator } from "./embeddable";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import { NonDeletedExcalidrawElement } from "./types";
|
||||
import {
|
||||
ExcalidrawEmbeddableElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./types";
|
||||
|
||||
import { register } from "../actions/register";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
|
@ -21,7 +25,10 @@ import {
|
|||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { KEYS } from "../keys";
|
||||
import { DEFAULT_LINK_SIZE } from "../renderer/renderElement";
|
||||
import {
|
||||
DEFAULT_LINK_SIZE,
|
||||
invalidateShapeForElement,
|
||||
} from "../renderer/renderElement";
|
||||
import { rotate } from "../math";
|
||||
import { EVENT, HYPERLINK_TOOLTIP_DELAY, MIME_TYPES } from "../constants";
|
||||
import { Bounds } from "./bounds";
|
||||
|
@ -29,10 +36,12 @@ import { getTooltipDiv, updateTooltipPosition } from "../components/Tooltip";
|
|||
import { getSelectedElements } from "../scene";
|
||||
import { isPointHittingElementBoundingBox } from "./collision";
|
||||
import { getElementAbsoluteCoords } from "./";
|
||||
import { isLocalLink, normalizeLink } from "../data/url";
|
||||
|
||||
import "./Hyperlink.scss";
|
||||
import { trackEvent } from "../analytics";
|
||||
import { useExcalidrawAppState } from "../components/App";
|
||||
import { useAppProps, useExcalidrawAppState } from "../components/App";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
|
||||
const CONTAINER_WIDTH = 320;
|
||||
const SPACE_BOTTOM = 85;
|
||||
|
@ -47,37 +56,112 @@ EXTERNAL_LINK_IMG.src = `data:${MIME_TYPES.svg}, ${encodeURIComponent(
|
|||
|
||||
let IS_HYPERLINK_TOOLTIP_VISIBLE = false;
|
||||
|
||||
const embeddableLinkCache = new Map<
|
||||
ExcalidrawEmbeddableElement["id"],
|
||||
string
|
||||
>();
|
||||
|
||||
export const Hyperlink = ({
|
||||
element,
|
||||
setAppState,
|
||||
onLinkOpen,
|
||||
setToast,
|
||||
}: {
|
||||
element: NonDeletedExcalidrawElement;
|
||||
setAppState: React.Component<any, AppState>["setState"];
|
||||
onLinkOpen: ExcalidrawProps["onLinkOpen"];
|
||||
setToast: (
|
||||
toast: { message: string; closable?: boolean; duration?: number } | null,
|
||||
) => void;
|
||||
}) => {
|
||||
const appState = useExcalidrawAppState();
|
||||
const appProps = useAppProps();
|
||||
|
||||
const linkVal = element.link || "";
|
||||
|
||||
const [inputVal, setInputVal] = useState(linkVal);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isEditing = appState.showHyperlinkPopup === "editor" || !linkVal;
|
||||
const isEditing = appState.showHyperlinkPopup === "editor";
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = normalizeLink(inputRef.current.value);
|
||||
const link = normalizeLink(inputRef.current.value) || null;
|
||||
|
||||
if (!element.link && link) {
|
||||
trackEvent("hyperlink", "create");
|
||||
}
|
||||
|
||||
mutateElement(element, { link });
|
||||
setAppState({ showHyperlinkPopup: "info" });
|
||||
}, [element, setAppState]);
|
||||
if (isEmbeddableElement(element)) {
|
||||
if (appState.activeEmbeddable?.element === element) {
|
||||
setAppState({ activeEmbeddable: null });
|
||||
}
|
||||
if (!link) {
|
||||
mutateElement(element, {
|
||||
validated: false,
|
||||
link: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!embeddableURLValidator(link, appProps.validateEmbeddable)) {
|
||||
if (link) {
|
||||
setToast({ message: t("toast.unableToEmbed"), closable: true });
|
||||
}
|
||||
element.link && embeddableLinkCache.set(element.id, element.link);
|
||||
mutateElement(element, {
|
||||
validated: false,
|
||||
link,
|
||||
});
|
||||
invalidateShapeForElement(element);
|
||||
} else {
|
||||
const { width, height } = element;
|
||||
const embedLink = getEmbedLink(link);
|
||||
if (embedLink?.warning) {
|
||||
setToast({ message: embedLink.warning, closable: true });
|
||||
}
|
||||
const ar = embedLink
|
||||
? embedLink.aspectRatio.w / embedLink.aspectRatio.h
|
||||
: 1;
|
||||
const hasLinkChanged =
|
||||
embeddableLinkCache.get(element.id) !== element.link;
|
||||
mutateElement(element, {
|
||||
...(hasLinkChanged
|
||||
? {
|
||||
width:
|
||||
embedLink?.type === "video"
|
||||
? width > height
|
||||
? width
|
||||
: height * ar
|
||||
: width,
|
||||
height:
|
||||
embedLink?.type === "video"
|
||||
? width > height
|
||||
? width / ar
|
||||
: height
|
||||
: height,
|
||||
}
|
||||
: {}),
|
||||
validated: true,
|
||||
link,
|
||||
});
|
||||
invalidateShapeForElement(element);
|
||||
if (embeddableLinkCache.has(element.id)) {
|
||||
embeddableLinkCache.delete(element.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mutateElement(element, { link });
|
||||
}
|
||||
}, [
|
||||
element,
|
||||
setToast,
|
||||
appProps.validateEmbeddable,
|
||||
appState.activeEmbeddable,
|
||||
setAppState,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
return () => {
|
||||
|
@ -131,10 +215,12 @@ export const Hyperlink = ({
|
|||
appState.draggingElement ||
|
||||
appState.resizingElement ||
|
||||
appState.isRotating ||
|
||||
appState.openMenu
|
||||
appState.openMenu ||
|
||||
appState.viewModeEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="excalidraw-hyperlinkContainer"
|
||||
|
@ -144,6 +230,11 @@ export const Hyperlink = ({
|
|||
width: CONTAINER_WIDTH,
|
||||
padding: CONTAINER_PADDING,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!element.link && !isEditing) {
|
||||
setAppState({ showHyperlinkPopup: "editor" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
|
@ -161,15 +252,14 @@ export const Hyperlink = ({
|
|||
}
|
||||
if (event.key === KEYS.ENTER || event.key === KEYS.ESCAPE) {
|
||||
handleSubmit();
|
||||
setAppState({ showHyperlinkPopup: "info" });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
) : element.link ? (
|
||||
<a
|
||||
href={element.link || ""}
|
||||
className={clsx("excalidraw-hyperlinkContainer-link", {
|
||||
"d-none": isEditing,
|
||||
})}
|
||||
href={normalizeLink(element.link || "")}
|
||||
className="excalidraw-hyperlinkContainer-link"
|
||||
target={isLocalLink(element.link) ? "_self" : "_blank"}
|
||||
onClick={(event) => {
|
||||
if (element.link && onLinkOpen) {
|
||||
|
@ -177,7 +267,13 @@ export const Hyperlink = ({
|
|||
EVENT.EXCALIDRAW_LINK,
|
||||
event.nativeEvent,
|
||||
);
|
||||
onLinkOpen(element, customEvent);
|
||||
onLinkOpen(
|
||||
{
|
||||
...element,
|
||||
link: normalizeLink(element.link),
|
||||
},
|
||||
customEvent,
|
||||
);
|
||||
if (customEvent.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
@ -187,6 +283,10 @@ export const Hyperlink = ({
|
|||
>
|
||||
{element.link}
|
||||
</a>
|
||||
) : (
|
||||
<div className="excalidraw-hyperlinkContainer-link">
|
||||
{t("labels.link.empty")}
|
||||
</div>
|
||||
)}
|
||||
<div className="excalidraw-hyperlinkContainer__buttons">
|
||||
{!isEditing && (
|
||||
|
@ -200,8 +300,7 @@ export const Hyperlink = ({
|
|||
icon={FreedrawIcon}
|
||||
/>
|
||||
)}
|
||||
|
||||
{linkVal && (
|
||||
{linkVal && !isEmbeddableElement(element) && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
title={t("buttons.remove")}
|
||||
|
@ -231,21 +330,6 @@ const getCoordsForPopover = (
|
|||
return { x, y };
|
||||
};
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
link = link.trim();
|
||||
if (link) {
|
||||
// prefix with protocol if not fully-qualified
|
||||
if (!link.includes("://") && !/^[[\\/]/.test(link)) {
|
||||
link = `https://${link}`;
|
||||
}
|
||||
}
|
||||
return link;
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
||||
|
||||
export const actionLink = register({
|
||||
name: "hyperlink",
|
||||
perform: (elements, appState) => {
|
||||
|
@ -279,7 +363,11 @@ export const actionLink = register({
|
|||
type="button"
|
||||
icon={LinkIcon}
|
||||
aria-label={t(getContextMenuLabel(elements, appState))}
|
||||
title={`${t("labels.link.label")} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
title={`${
|
||||
isEmbeddableElement(elements[0])
|
||||
? t("labels.link.labelEmbed")
|
||||
: t("labels.link.label")
|
||||
} - ${getShortcutKey("CtrlOrCmd+K")}`}
|
||||
onClick={() => updateData(null)}
|
||||
selected={selectedElements.length === 1 && !!selectedElements[0].link}
|
||||
/>
|
||||
|
@ -293,7 +381,11 @@ export const getContextMenuLabel = (
|
|||
) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const label = selectedElements[0]!.link
|
||||
? "labels.link.edit"
|
||||
? isEmbeddableElement(selectedElements[0])
|
||||
? "labels.link.editEmbed"
|
||||
: "labels.link.edit"
|
||||
: isEmbeddableElement(selectedElements[0])
|
||||
? "labels.link.createEmbed"
|
||||
: "labels.link.create";
|
||||
return label;
|
||||
};
|
||||
|
@ -335,6 +427,26 @@ export const isPointHittingLinkIcon = (
|
|||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
[x, y]: Point,
|
||||
) => {
|
||||
const threshold = 4 / appState.zoom.value;
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
element.angle,
|
||||
appState,
|
||||
);
|
||||
const hitLink =
|
||||
x > linkX - threshold &&
|
||||
x < linkX + threshold + linkWidth &&
|
||||
y > linkY - threshold &&
|
||||
y < linkY + linkHeight + threshold;
|
||||
return hitLink;
|
||||
};
|
||||
|
||||
export const isPointHittingLink = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
[x, y]: Point,
|
||||
isMobile: boolean,
|
||||
) => {
|
||||
if (!element.link || appState.selectedElementIds[element.id]) {
|
||||
|
@ -348,19 +460,7 @@ export const isPointHittingLinkIcon = (
|
|||
) {
|
||||
return true;
|
||||
}
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
|
||||
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
|
||||
[x1, y1, x2, y2],
|
||||
element.angle,
|
||||
appState,
|
||||
);
|
||||
const hitLink =
|
||||
x > linkX - threshold &&
|
||||
x < linkX + threshold + linkWidth &&
|
||||
y > linkY - threshold &&
|
||||
y < linkY + linkHeight + threshold;
|
||||
return hitLink;
|
||||
return isPointHittingLinkIcon(element, appState, [x, y]);
|
||||
};
|
||||
|
||||
let HYPERLINK_TOOLTIP_TIMEOUT_ID: number | null = null;
|
||||
|
|
|
@ -18,6 +18,7 @@ import {
|
|||
ExcalidrawBindableElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawRectangleElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawDiamondElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEllipseElement,
|
||||
|
@ -39,7 +40,11 @@ import { FrameNameBoundsCache, Point } from "../types";
|
|||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
import { hasBoundTextElement, isImageElement } from "./typeChecks";
|
||||
import {
|
||||
hasBoundTextElement,
|
||||
isEmbeddableElement,
|
||||
isImageElement,
|
||||
} from "./typeChecks";
|
||||
import { isTextElement } from ".";
|
||||
import { isTransparent } from "../utils";
|
||||
import { shouldShowBoundingBox } from "./transformHandles";
|
||||
|
@ -57,7 +62,9 @@ const isElementDraggableFromInside = (
|
|||
return true;
|
||||
}
|
||||
const isDraggableFromInside =
|
||||
!isTransparent(element.backgroundColor) || hasBoundTextElement(element);
|
||||
!isTransparent(element.backgroundColor) ||
|
||||
hasBoundTextElement(element) ||
|
||||
isEmbeddableElement(element);
|
||||
if (element.type === "line") {
|
||||
return isDraggableFromInside && isPathALoop(element.points);
|
||||
}
|
||||
|
@ -248,6 +255,7 @@ type HitTestArgs = {
|
|||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
switch (args.element.type) {
|
||||
case "rectangle":
|
||||
case "embeddable":
|
||||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
|
@ -306,6 +314,7 @@ export const distanceToBindableElement = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
|
@ -337,6 +346,7 @@ const distanceToRectangle = (
|
|||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
|
@ -645,17 +655,23 @@ export const determineFocusDistance = (
|
|||
const c = line[1];
|
||||
const mabs = Math.abs(m);
|
||||
const nabs = Math.abs(n);
|
||||
let ret;
|
||||
switch (element.type) {
|
||||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
ret = c / (hwidth * (nabs + q * mabs));
|
||||
break;
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
ret = mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
break;
|
||||
case "ellipse":
|
||||
return c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||
ret = c / (hwidth * Math.sqrt(n ** 2 + q ** 2 * m ** 2));
|
||||
break;
|
||||
}
|
||||
return ret || 0;
|
||||
};
|
||||
|
||||
export const determineFocusPoint = (
|
||||
|
@ -682,6 +698,7 @@ export const determineFocusPoint = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
|
@ -733,6 +750,7 @@ const getSortedElementLineIntersections = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
|
@ -768,6 +786,7 @@ const getCorners = (
|
|||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
|
@ -777,6 +796,7 @@ const getCorners = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "embeddable":
|
||||
case "frame":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
|
@ -926,6 +946,7 @@ export const findFocusPointForRectangulars = (
|
|||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
|
|
350
src/element/embeddable.ts
Normal file
350
src/element/embeddable.ts
Normal file
|
@ -0,0 +1,350 @@
|
|||
import { register } from "../actions/register";
|
||||
import { FONT_FAMILY, VERTICAL_ALIGN } from "../constants";
|
||||
import { t } from "../i18n";
|
||||
import { ExcalidrawProps } from "../types";
|
||||
import { getFontString, setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { newTextElement } from "./newElement";
|
||||
import { getContainerElement, wrapText } from "./textElement";
|
||||
import { isEmbeddableElement } from "./typeChecks";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
Theme,
|
||||
} from "./types";
|
||||
|
||||
type EmbeddedLink =
|
||||
| ({
|
||||
aspectRatio: { w: number; h: number };
|
||||
warning?: string;
|
||||
} & (
|
||||
| { type: "video" | "generic"; link: string }
|
||||
| { type: "document"; srcdoc: (theme: Theme) => string }
|
||||
))
|
||||
| null;
|
||||
|
||||
const embeddedLinkCache = new Map<string, EmbeddedLink>();
|
||||
|
||||
const RE_YOUTUBE =
|
||||
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
|
||||
const RE_VIMEO =
|
||||
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
|
||||
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
|
||||
|
||||
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
|
||||
const RE_GH_GIST_EMBED =
|
||||
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
|
||||
|
||||
// not anchored to start to allow <blockquote> twitter embeds
|
||||
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
|
||||
const RE_TWITTER_EMBED =
|
||||
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
|
||||
|
||||
const RE_VALTOWN =
|
||||
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
|
||||
|
||||
const RE_GENERIC_EMBED =
|
||||
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
|
||||
|
||||
const ALLOWED_DOMAINS = new Set([
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"vimeo.com",
|
||||
"player.vimeo.com",
|
||||
"figma.com",
|
||||
"link.excalidraw.com",
|
||||
"gist.github.com",
|
||||
"twitter.com",
|
||||
"*.simplepdf.eu",
|
||||
"stackblitz.com",
|
||||
"val.town",
|
||||
]);
|
||||
|
||||
const createSrcDoc = (body: string) => {
|
||||
return `<html><body>${body}</body></html>`;
|
||||
};
|
||||
|
||||
export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (embeddedLinkCache.has(link)) {
|
||||
return embeddedLinkCache.get(link)!;
|
||||
}
|
||||
|
||||
const originalLink = link;
|
||||
|
||||
let type: "video" | "generic" = "generic";
|
||||
let aspectRatio = { w: 560, h: 840 };
|
||||
const ytLink = link.match(RE_YOUTUBE);
|
||||
if (ytLink?.[2]) {
|
||||
const time = ytLink[3] ? `&start=${ytLink[3]}` : ``;
|
||||
const isPortrait = link.includes("shorts");
|
||||
type = "video";
|
||||
switch (ytLink[1]) {
|
||||
case "embed/":
|
||||
case "watch?v=":
|
||||
case "shorts/":
|
||||
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
|
||||
break;
|
||||
case "playlist?list=":
|
||||
case "embed/videoseries?list=":
|
||||
link = `https://www.youtube.com/embed/videoseries?list=${ytLink[2]}&enablejsapi=1${time}`;
|
||||
break;
|
||||
default:
|
||||
link = `https://www.youtube.com/embed/${ytLink[2]}?enablejsapi=1${time}`;
|
||||
break;
|
||||
}
|
||||
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
const vimeoLink = link.match(RE_VIMEO);
|
||||
if (vimeoLink?.[1]) {
|
||||
const target = vimeoLink?.[1];
|
||||
const warning = !/^\d+$/.test(target)
|
||||
? t("toast.unrecognizedLinkFormat")
|
||||
: undefined;
|
||||
type = "video";
|
||||
link = `https://player.vimeo.com/video/${target}?api=1`;
|
||||
aspectRatio = { w: 560, h: 315 };
|
||||
//warning deliberately ommited so it is displayed only once per link
|
||||
//same link next time will be served from cache
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type, warning };
|
||||
}
|
||||
|
||||
const figmaLink = link.match(RE_FIGMA);
|
||||
if (figmaLink) {
|
||||
type = "generic";
|
||||
link = `https://www.figma.com/embed?embed_host=share&url=${encodeURIComponent(
|
||||
link,
|
||||
)}`;
|
||||
aspectRatio = { w: 550, h: 550 };
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
const valLink = link.match(RE_VALTOWN);
|
||||
if (valLink) {
|
||||
link =
|
||||
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
|
||||
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
}
|
||||
|
||||
if (RE_TWITTER.test(link)) {
|
||||
let ret: EmbeddedLink;
|
||||
// assume embed code
|
||||
if (/<blockquote/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
aspectRatio: { w: 480, h: 480 },
|
||||
};
|
||||
// assume regular tweet url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: (theme: string) =>
|
||||
createSrcDoc(
|
||||
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
|
||||
),
|
||||
aspectRatio: { w: 480, h: 480 },
|
||||
};
|
||||
}
|
||||
embeddedLinkCache.set(originalLink, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
if (RE_GH_GIST.test(link)) {
|
||||
let ret: EmbeddedLink;
|
||||
// assume embed code
|
||||
if (/<script>/.test(link)) {
|
||||
const srcDoc = createSrcDoc(link);
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () => srcDoc,
|
||||
aspectRatio: { w: 550, h: 720 },
|
||||
};
|
||||
// assume regular url
|
||||
} else {
|
||||
ret = {
|
||||
type: "document",
|
||||
srcdoc: () =>
|
||||
createSrcDoc(`
|
||||
<script src="${link}.js"></script>
|
||||
<style type="text/css">
|
||||
* { margin: 0px; }
|
||||
table, .gist { height: 100%; }
|
||||
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
|
||||
</style>
|
||||
`),
|
||||
aspectRatio: { w: 550, h: 720 },
|
||||
};
|
||||
}
|
||||
embeddedLinkCache.set(link, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
embeddedLinkCache.set(link, { link, aspectRatio, type });
|
||||
return { link, aspectRatio, type };
|
||||
};
|
||||
|
||||
export const isEmbeddableOrFrameLabel = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
): Boolean => {
|
||||
if (isEmbeddableElement(element)) {
|
||||
return true;
|
||||
}
|
||||
if (element.type === "text") {
|
||||
const container = getContainerElement(element);
|
||||
if (container && isEmbeddableElement(container)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createPlaceholderEmbeddableLabel = (
|
||||
element: ExcalidrawEmbeddableElement,
|
||||
): ExcalidrawElement => {
|
||||
const text =
|
||||
!element.link || element?.link === "" ? "Empty Web-Embed" : element.link;
|
||||
const fontSize = Math.max(
|
||||
Math.min(element.width / 2, element.width / text.length),
|
||||
element.width / 30,
|
||||
);
|
||||
const fontFamily = FONT_FAMILY.Helvetica;
|
||||
|
||||
const fontString = getFontString({
|
||||
fontSize,
|
||||
fontFamily,
|
||||
});
|
||||
|
||||
return newTextElement({
|
||||
x: element.x + element.width / 2,
|
||||
y: element.y + element.height / 2,
|
||||
strokeColor:
|
||||
element.strokeColor !== "transparent" ? element.strokeColor : "black",
|
||||
backgroundColor: "transparent",
|
||||
fontFamily,
|
||||
fontSize,
|
||||
text: wrapText(text, fontString, element.width - 20),
|
||||
textAlign: "center",
|
||||
verticalAlign: VERTICAL_ALIGN.MIDDLE,
|
||||
angle: element.angle ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const actionSetEmbeddableAsActiveTool = register({
|
||||
name: "setEmbeddableAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "embeddable",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const validateHostname = (
|
||||
url: string,
|
||||
/** using a Set assumes it already contains normalized bare domains */
|
||||
allowedHostnames: Set<string> | string,
|
||||
): boolean => {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
|
||||
const bareDomain = hostname.replace(/^www\./, "");
|
||||
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
|
||||
/^([^.]+)/,
|
||||
"*",
|
||||
);
|
||||
|
||||
if (allowedHostnames instanceof Set) {
|
||||
return (
|
||||
ALLOWED_DOMAINS.has(bareDomain) ||
|
||||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
|
||||
);
|
||||
}
|
||||
|
||||
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const extractSrc = (htmlString: string): string => {
|
||||
const twitterMatch = htmlString.match(RE_TWITTER_EMBED);
|
||||
if (twitterMatch && twitterMatch.length === 2) {
|
||||
return twitterMatch[1];
|
||||
}
|
||||
|
||||
const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
|
||||
if (gistMatch && gistMatch.length === 2) {
|
||||
return gistMatch[1];
|
||||
}
|
||||
|
||||
const match = htmlString.match(RE_GENERIC_EMBED);
|
||||
if (match && match.length === 2) {
|
||||
return match[1];
|
||||
}
|
||||
return htmlString;
|
||||
};
|
||||
|
||||
export const embeddableURLValidator = (
|
||||
url: string | null | undefined,
|
||||
validateEmbeddable: ExcalidrawProps["validateEmbeddable"],
|
||||
): boolean => {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
if (validateEmbeddable != null) {
|
||||
if (typeof validateEmbeddable === "function") {
|
||||
const ret = validateEmbeddable(url);
|
||||
// if return value is undefined, leave validation to default
|
||||
if (typeof ret === "boolean") {
|
||||
return ret;
|
||||
}
|
||||
} else if (typeof validateEmbeddable === "boolean") {
|
||||
return validateEmbeddable;
|
||||
} else if (validateEmbeddable instanceof RegExp) {
|
||||
return validateEmbeddable.test(url);
|
||||
} else if (Array.isArray(validateEmbeddable)) {
|
||||
for (const domain of validateEmbeddable) {
|
||||
if (domain instanceof RegExp) {
|
||||
if (url.match(domain)) {
|
||||
return true;
|
||||
}
|
||||
} else if (validateHostname(url, domain)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return validateHostname(url, ALLOWED_DOMAINS);
|
||||
};
|
|
@ -264,11 +264,11 @@ export class LinearElementEditor {
|
|||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
handleBindTextResize(element, false);
|
||||
}
|
||||
|
||||
// suggest bindings for first and last point if selected
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
} from "../element/types";
|
||||
import { arrayToMap, getUpdatedTimestamp, isTestEnv } from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
|
@ -156,6 +157,18 @@ export const newElement = (
|
|||
return _newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
};
|
||||
|
||||
export const newEmbeddableElement = (
|
||||
opts: {
|
||||
type: "embeddable";
|
||||
validated: boolean | undefined;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawEmbeddableElement> => {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawEmbeddableElement>("embeddable", opts),
|
||||
validated: opts.validated,
|
||||
};
|
||||
};
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
|
@ -203,7 +216,6 @@ export const newTextElement = (
|
|||
containerId?: ExcalidrawTextContainer["id"];
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
isFrameName?: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
|
@ -240,7 +252,6 @@ export const newTextElement = (
|
|||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
isFrameName: opts.isFrameName || false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
@ -462,7 +473,7 @@ const _deepCopyElement = (val: any, depth: number = 0) => {
|
|||
// we're not cloning non-array & non-plain-object objects because we
|
||||
// don't support them on excalidraw elements yet. If we do, we need to make
|
||||
// sure we start cloning them, so let's warn about it.
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
if (import.meta.env.DEV) {
|
||||
if (
|
||||
objectType !== "[object Object]" &&
|
||||
objectType !== "[object Array]" &&
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { MIN_FONT_SIZE, SHIFT_LOCKING_ANGLE } from "../constants";
|
||||
import { rescalePoints } from "../points";
|
||||
|
||||
import {
|
||||
|
@ -204,8 +204,6 @@ const rescalePointsInElement = (
|
|||
}
|
||||
: {};
|
||||
|
||||
const MIN_FONT_SIZE = 1;
|
||||
|
||||
const measureFontSizeFromWidth = (
|
||||
element: NonDeleted<ExcalidrawTextElement>,
|
||||
nextWidth: number,
|
||||
|
@ -585,24 +583,42 @@ export const resizeSingleElement = (
|
|||
});
|
||||
}
|
||||
|
||||
if (
|
||||
isArrowElement(element) &&
|
||||
boundTextElement &&
|
||||
shouldMaintainAspectRatio
|
||||
) {
|
||||
const fontSize =
|
||||
(resizedElement.width / element.width) * boundTextElement.fontSize;
|
||||
if (fontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
boundTextFont.fontSize = fontSize;
|
||||
}
|
||||
|
||||
if (
|
||||
resizedElement.width !== 0 &&
|
||||
resizedElement.height !== 0 &&
|
||||
Number.isFinite(resizedElement.x) &&
|
||||
Number.isFinite(resizedElement.y)
|
||||
) {
|
||||
mutateElement(element, resizedElement);
|
||||
|
||||
updateBoundElements(element, {
|
||||
newSize: { width: resizedElement.width, height: resizedElement.height },
|
||||
});
|
||||
|
||||
mutateElement(element, resizedElement);
|
||||
if (boundTextElement && boundTextFont != null) {
|
||||
mutateElement(boundTextElement, {
|
||||
fontSize: boundTextFont.fontSize,
|
||||
baseline: boundTextFont.baseline,
|
||||
});
|
||||
}
|
||||
handleBindTextResize(element, transformHandleDirection);
|
||||
handleBindTextResize(
|
||||
element,
|
||||
transformHandleDirection,
|
||||
shouldMaintainAspectRatio,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -718,12 +734,8 @@ export const resizeMultipleElements = (
|
|||
fontSize?: ExcalidrawTextElement["fontSize"];
|
||||
baseline?: ExcalidrawTextElement["baseline"];
|
||||
scale?: ExcalidrawImageElement["scale"];
|
||||
boundTextFontSize?: ExcalidrawTextElement["fontSize"];
|
||||
};
|
||||
boundText: {
|
||||
element: ExcalidrawTextElementWithContainer;
|
||||
fontSize: ExcalidrawTextElement["fontSize"];
|
||||
baseline: ExcalidrawTextElement["baseline"];
|
||||
} | null;
|
||||
}[] = [];
|
||||
|
||||
for (const { orig, latest } of targetElements) {
|
||||
|
@ -794,50 +806,39 @@ export const resizeMultipleElements = (
|
|||
}
|
||||
}
|
||||
|
||||
let boundText: typeof elementsAndUpdates[0]["boundText"] = null;
|
||||
|
||||
const boundTextElement = getBoundTextElement(latest);
|
||||
|
||||
if (boundTextElement || isTextElement(orig)) {
|
||||
const updatedElement = {
|
||||
...latest,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
const metrics = measureFontSizeFromWidth(
|
||||
boundTextElement ?? (orig as ExcalidrawTextElement),
|
||||
boundTextElement
|
||||
? getBoundTextMaxWidth(updatedElement)
|
||||
: updatedElement.width,
|
||||
boundTextElement
|
||||
? getBoundTextMaxHeight(updatedElement, boundTextElement)
|
||||
: updatedElement.height,
|
||||
);
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
const metrics = measureFontSizeFromWidth(orig, width, height);
|
||||
if (!metrics) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextElement(orig)) {
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
if (boundTextElement) {
|
||||
boundText = {
|
||||
element: boundTextElement,
|
||||
fontSize: metrics.size,
|
||||
baseline: metrics.baseline,
|
||||
};
|
||||
}
|
||||
update.fontSize = metrics.size;
|
||||
update.baseline = metrics.baseline;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({ element: latest, update, boundText });
|
||||
const boundTextElement = pointerDownState.originalElements.get(
|
||||
getBoundTextElementId(orig) ?? "",
|
||||
) as ExcalidrawTextElementWithContainer | undefined;
|
||||
|
||||
if (boundTextElement) {
|
||||
const newFontSize = boundTextElement.fontSize * scale;
|
||||
if (newFontSize < MIN_FONT_SIZE) {
|
||||
return;
|
||||
}
|
||||
update.boundTextFontSize = newFontSize;
|
||||
}
|
||||
|
||||
elementsAndUpdates.push({
|
||||
element: latest,
|
||||
update,
|
||||
});
|
||||
}
|
||||
|
||||
const elementsToUpdate = elementsAndUpdates.map(({ element }) => element);
|
||||
|
||||
for (const { element, update, boundText } of elementsAndUpdates) {
|
||||
for (const {
|
||||
element,
|
||||
update: { boundTextFontSize, ...update },
|
||||
} of elementsAndUpdates) {
|
||||
const { width, height, angle } = update;
|
||||
|
||||
mutateElement(element, update, false);
|
||||
|
@ -847,17 +848,17 @@ export const resizeMultipleElements = (
|
|||
newSize: { width, height },
|
||||
});
|
||||
|
||||
if (boundText) {
|
||||
const { element: boundTextElement, ...boundTextUpdates } = boundText;
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && boundTextFontSize) {
|
||||
mutateElement(
|
||||
boundTextElement,
|
||||
{
|
||||
...boundTextUpdates,
|
||||
fontSize: boundTextFontSize,
|
||||
angle: isLinearElement(element) ? undefined : angle,
|
||||
},
|
||||
false,
|
||||
);
|
||||
handleBindTextResize(element, transformHandleType);
|
||||
handleBindTextResize(element, transformHandleType, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -7,11 +7,11 @@ export const showSelectedShapeActions = (
|
|||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) =>
|
||||
Boolean(
|
||||
(!appState.viewModeEnabled &&
|
||||
appState.activeTool.type !== "custom" &&
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
getSelectedElements(elements, appState).length,
|
||||
!appState.viewModeEnabled &&
|
||||
((appState.activeTool.type !== "custom" &&
|
||||
(appState.editingElement ||
|
||||
(appState.activeTool.type !== "selection" &&
|
||||
appState.activeTool.type !== "eraser" &&
|
||||
appState.activeTool.type !== "hand"))) ||
|
||||
getSelectedElements(elements, appState).length),
|
||||
);
|
||||
|
|
|
@ -1,19 +1,32 @@
|
|||
import { vi } from "vitest";
|
||||
import { getPerfectElementSize } from "./sizeHelpers";
|
||||
import * as constants from "../constants";
|
||||
|
||||
const EPSILON_DIGITS = 3;
|
||||
|
||||
// Needed so that we can mock the value of constants which is done in
|
||||
// below tests. In Jest this wasn't needed as global override was possible
|
||||
// but vite doesn't allow that hence we need to mock
|
||||
vi.mock(
|
||||
"../constants.ts",
|
||||
//@ts-ignore
|
||||
async (importOriginal) => {
|
||||
const module: any = await importOriginal();
|
||||
return { ...module };
|
||||
},
|
||||
);
|
||||
describe("getPerfectElementSize", () => {
|
||||
it("should return height:0 if `elementType` is line and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 149, 10);
|
||||
expect(width).toBeCloseTo(149, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return width:0 if `elementType` is line and locked angle is 90 deg (Math.PI/2)", () => {
|
||||
const { height, width } = getPerfectElementSize("line", 10, 140);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(140, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 if `elementType` is arrow and locked angle is 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 200, 20);
|
||||
expect(width).toBeCloseTo(200, EPSILON_DIGITS);
|
||||
|
@ -24,16 +37,19 @@ describe("getPerfectElementSize", () => {
|
|||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(100, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return adjust height to be width * tan(locked angle)", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 120, 185);
|
||||
expect(width).toBeCloseTo(120, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(207.846, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height equals to width if locked angle is 45 deg", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 135, 145);
|
||||
expect(width).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
expect(height).toBeCloseTo(135, EPSILON_DIGITS);
|
||||
});
|
||||
|
||||
it("should return height:0 and width:0 when width and height are 0", () => {
|
||||
const { height, width } = getPerfectElementSize("arrow", 0, 0);
|
||||
expect(width).toBeCloseTo(0, EPSILON_DIGITS);
|
||||
|
|
|
@ -11,6 +11,8 @@ import {
|
|||
} from "./types";
|
||||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO,
|
||||
ARROW_LABEL_WIDTH_FRACTION,
|
||||
BOUND_TEXT_PADDING,
|
||||
DEFAULT_FONT_FAMILY,
|
||||
DEFAULT_FONT_SIZE,
|
||||
|
@ -90,7 +92,7 @@ export const redrawTextBoundingBox = (
|
|||
boundTextUpdates.text = textElement.text;
|
||||
|
||||
if (container) {
|
||||
maxWidth = getBoundTextMaxWidth(container);
|
||||
maxWidth = getBoundTextMaxWidth(container, textElement);
|
||||
boundTextUpdates.text = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
const metrics = measureTextElement(textElement, {
|
||||
|
@ -110,13 +112,12 @@ export const redrawTextBoundingBox = (
|
|||
}
|
||||
}
|
||||
if (container) {
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxContainerHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
|
||||
let nextHeight = containerDims.height;
|
||||
let nextHeight = container.height;
|
||||
if (metrics.height > maxContainerHeight) {
|
||||
nextHeight = computeContainerDimensionForBoundText(
|
||||
metrics.height,
|
||||
|
@ -182,6 +183,7 @@ export const bindTextToShapeAfterDuplication = (
|
|||
export const handleBindTextResize = (
|
||||
container: NonDeletedExcalidrawElement,
|
||||
transformHandleType: MaybeTransformHandleType,
|
||||
shouldMaintainAspectRatio = false,
|
||||
) => {
|
||||
const boundTextElementId = getBoundTextElementId(container);
|
||||
if (!boundTextElementId) {
|
||||
|
@ -202,15 +204,17 @@ export const handleBindTextResize = (
|
|||
let text = textElement.text;
|
||||
let nextHeight = textElement.height;
|
||||
let nextWidth = textElement.width;
|
||||
const containerDims = getContainerDims(container);
|
||||
const maxWidth = getBoundTextMaxWidth(container);
|
||||
const maxHeight = getBoundTextMaxHeight(
|
||||
container,
|
||||
textElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
let containerHeight = containerDims.height;
|
||||
let containerHeight = container.height;
|
||||
let nextBaseLine = textElement.baseline;
|
||||
if (transformHandleType !== "n" && transformHandleType !== "s") {
|
||||
if (
|
||||
shouldMaintainAspectRatio ||
|
||||
(transformHandleType !== "n" && transformHandleType !== "s")
|
||||
) {
|
||||
if (text) {
|
||||
text = wrapTextElement(textElement, maxWidth);
|
||||
}
|
||||
|
@ -226,7 +230,7 @@ export const handleBindTextResize = (
|
|||
container.type,
|
||||
);
|
||||
|
||||
const diff = containerHeight - containerDims.height;
|
||||
const diff = containerHeight - container.height;
|
||||
// fix the y coord when resizing from ne/nw/n
|
||||
const updatedY =
|
||||
!isArrowElement(container) &&
|
||||
|
@ -706,16 +710,6 @@ export const getContainerElement = (
|
|||
return null;
|
||||
};
|
||||
|
||||
export const getContainerDims = (element: ExcalidrawElement) => {
|
||||
const MIN_WIDTH = 300;
|
||||
if (isArrowElement(element)) {
|
||||
const width = Math.max(element.width, MIN_WIDTH);
|
||||
const height = element.height;
|
||||
return { width, height };
|
||||
}
|
||||
return { width: element.width, height: element.height };
|
||||
};
|
||||
|
||||
export const getContainerCenter = (
|
||||
container: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
|
@ -881,7 +875,6 @@ const VALID_CONTAINER_TYPES = new Set([
|
|||
"rectangle",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"image",
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
|
@ -907,12 +900,19 @@ export const computeContainerDimensionForBoundText = (
|
|||
return dimension + padding;
|
||||
};
|
||||
|
||||
export const getBoundTextMaxWidth = (container: ExcalidrawElement) => {
|
||||
const width = getContainerDims(container).width;
|
||||
export const getBoundTextMaxWidth = (
|
||||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElement | null = getBoundTextElement(
|
||||
container,
|
||||
),
|
||||
) => {
|
||||
const { width } = container;
|
||||
if (isArrowElement(container)) {
|
||||
return width - BOUND_TEXT_PADDING * 8 * 2;
|
||||
const minWidth =
|
||||
(boundTextElement?.fontSize ?? DEFAULT_FONT_SIZE) *
|
||||
ARROW_LABEL_FONT_SIZE_TO_MIN_WIDTH_RATIO;
|
||||
return Math.max(ARROW_LABEL_WIDTH_FRACTION * width, minWidth);
|
||||
}
|
||||
|
||||
if (container.type === "ellipse") {
|
||||
// The width of the largest rectangle inscribed inside an ellipse is
|
||||
// Math.round((ellipse.width / 2) * Math.sqrt(2)) which is derived from
|
||||
|
@ -931,7 +931,7 @@ export const getBoundTextMaxHeight = (
|
|||
container: ExcalidrawElement,
|
||||
boundTextElement: ExcalidrawTextElementWithContainer,
|
||||
) => {
|
||||
const height = getContainerDims(container).height;
|
||||
const { height } = container;
|
||||
if (isArrowElement(container)) {
|
||||
const containerHeight = height - BOUND_TEXT_PADDING * 8 * 2;
|
||||
if (containerHeight <= 0) {
|
||||
|
|
|
@ -955,7 +955,7 @@ describe("textWysiwyg", () => {
|
|||
// should center align horizontally and vertically by default
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
85,
|
||||
4.5,
|
||||
]
|
||||
|
@ -979,7 +979,7 @@ describe("textWysiwyg", () => {
|
|||
// should left align horizontally and bottom vertically after resize
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
15,
|
||||
65,
|
||||
]
|
||||
|
@ -1001,7 +1001,7 @@ describe("textWysiwyg", () => {
|
|||
// should right align horizontally and top vertically after resize
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
375,
|
||||
-539,
|
||||
]
|
||||
|
@ -1279,7 +1279,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Left"));
|
||||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
15,
|
||||
25,
|
||||
]
|
||||
|
@ -1290,7 +1290,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center"));
|
||||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
30,
|
||||
25,
|
||||
]
|
||||
|
@ -1302,7 +1302,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Align top"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
45,
|
||||
25,
|
||||
]
|
||||
|
@ -1313,7 +1313,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
fireEvent.click(screen.getByTitle("Left"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
15,
|
||||
45,
|
||||
]
|
||||
|
@ -1325,7 +1325,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
30,
|
||||
45,
|
||||
]
|
||||
|
@ -1337,7 +1337,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center vertically"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
45,
|
||||
45,
|
||||
]
|
||||
|
@ -1349,7 +1349,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
15,
|
||||
65,
|
||||
]
|
||||
|
@ -1360,7 +1360,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Center"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
30,
|
||||
65,
|
||||
]
|
||||
|
@ -1371,7 +1371,7 @@ describe("textWysiwyg", () => {
|
|||
fireEvent.click(screen.getByTitle("Right"));
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
expect([h.elements[1].x, h.elements[1].y]).toMatchInlineSnapshot(`
|
||||
Array [
|
||||
[
|
||||
45,
|
||||
65,
|
||||
]
|
||||
|
@ -1459,5 +1459,54 @@ describe("textWysiwyg", () => {
|
|||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shouldn't bind to container if container has bound text not centered and text tool is used", async () => {
|
||||
expect(h.elements.length).toBe(1);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
// Bind first text
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Hello!");
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
||||
).toBe(VERTICAL_ALIGN.MIDDLE);
|
||||
|
||||
fireEvent.click(screen.getByTitle("Align bottom"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
editor.blur();
|
||||
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).verticalAlign,
|
||||
).toBe(VERTICAL_ALIGN.BOTTOM);
|
||||
|
||||
// Attempt to Bind 2nd text using text tool
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(
|
||||
rectangle.x + rectangle.width / 2,
|
||||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
updateTextEditor(editor, "Excalidraw");
|
||||
editor.blur();
|
||||
|
||||
expect(h.elements.length).toBe(3);
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
]);
|
||||
text = h.elements[2] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(null);
|
||||
expect(text.text).toBe("Excalidraw");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -23,7 +23,6 @@ import { AppState } from "../types";
|
|||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
|
@ -201,20 +200,19 @@ export const textWysiwyg = ({
|
|||
updatedTextElement,
|
||||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
containerDims.height,
|
||||
container.height,
|
||||
);
|
||||
} else {
|
||||
originalContainerData = originalContainerCache[container.id];
|
||||
if (!originalContainerData) {
|
||||
originalContainerData = updateOriginalContainerCache(
|
||||
container.id,
|
||||
containerDims.height,
|
||||
container.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -238,7 +236,7 @@ export const textWysiwyg = ({
|
|||
// autoshrink container height until original container height
|
||||
// is reached when text is removed
|
||||
!isArrowElement(container) &&
|
||||
containerDims.height > originalContainerData.height &&
|
||||
container.height > originalContainerData.height &&
|
||||
textElementHeight < maxHeight
|
||||
) {
|
||||
const targetContainerHeight = computeContainerDimensionForBoundText(
|
||||
|
|
|
@ -30,15 +30,6 @@ describe("Test TypeChecks", () => {
|
|||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return false for text bindable containers without bound text", () => {
|
||||
|
@ -62,5 +53,14 @@ describe("Test TypeChecks", () => {
|
|||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(
|
||||
hasBoundTextElement(
|
||||
API.createElement({
|
||||
type: "image",
|
||||
boundElements: [{ type: "text", id: "text-id" }],
|
||||
}),
|
||||
),
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,6 +4,7 @@ import { MarkNonNullable } from "../utility-types";
|
|||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawTextElement,
|
||||
ExcalidrawEmbeddableElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawBindableElement,
|
||||
ExcalidrawGenericElement,
|
||||
|
@ -24,7 +25,8 @@ export const isGenericElement = (
|
|||
(element.type === "selection" ||
|
||||
element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse")
|
||||
element.type === "ellipse" ||
|
||||
element.type === "embeddable")
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -40,6 +42,12 @@ export const isImageElement = (
|
|||
return !!element && element.type === "image";
|
||||
};
|
||||
|
||||
export const isEmbeddableElement = (
|
||||
element: ExcalidrawElement | null | undefined,
|
||||
): element is ExcalidrawEmbeddableElement => {
|
||||
return !!element && element.type === "embeddable";
|
||||
};
|
||||
|
||||
export const isTextElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawTextElement => {
|
||||
|
@ -112,6 +120,7 @@ export const isBindableElement = (
|
|||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
element.type === "embeddable" ||
|
||||
(element.type === "text" && !element.containerId))
|
||||
);
|
||||
};
|
||||
|
@ -126,7 +135,6 @@ export const isTextBindableContainer = (
|
|||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
isArrowElement(element))
|
||||
);
|
||||
};
|
||||
|
@ -136,6 +144,7 @@ export const isExcalidrawElement = (element: any): boolean => {
|
|||
element?.type === "text" ||
|
||||
element?.type === "diamond" ||
|
||||
element?.type === "rectangle" ||
|
||||
element?.type === "embeddable" ||
|
||||
element?.type === "ellipse" ||
|
||||
element?.type === "arrow" ||
|
||||
element?.type === "freedraw" ||
|
||||
|
@ -163,7 +172,8 @@ export const isBoundToContainer = (
|
|||
);
|
||||
};
|
||||
|
||||
export const isUsingAdaptiveRadius = (type: string) => type === "rectangle";
|
||||
export const isUsingAdaptiveRadius = (type: string) =>
|
||||
type === "rectangle" || type === "embeddable";
|
||||
|
||||
export const isUsingProportionalRadius = (type: string) =>
|
||||
type === "line" || type === "arrow" || type === "diamond";
|
||||
|
@ -194,17 +204,13 @@ export const canApplyRoundnessTypeToElement = (
|
|||
export const getDefaultRoundnessTypeForElement = (
|
||||
element: ExcalidrawElement,
|
||||
) => {
|
||||
if (
|
||||
element.type === "arrow" ||
|
||||
element.type === "line" ||
|
||||
element.type === "diamond"
|
||||
) {
|
||||
if (isUsingProportionalRadius(element.type)) {
|
||||
return {
|
||||
type: ROUNDNESS.PROPORTIONAL_RADIUS,
|
||||
};
|
||||
}
|
||||
|
||||
if (element.type === "rectangle") {
|
||||
if (isUsingAdaptiveRadius(element.type)) {
|
||||
return {
|
||||
type: ROUNDNESS.ADAPTIVE_RADIUS,
|
||||
};
|
||||
|
|
|
@ -86,6 +86,19 @@ export type ExcalidrawEllipseElement = _ExcalidrawElementBase & {
|
|||
type: "ellipse";
|
||||
};
|
||||
|
||||
export type ExcalidrawEmbeddableElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
/**
|
||||
* indicates whether the embeddable src (url) has been validated for rendering.
|
||||
* nullish value indicates that the validation is pending. We reset the
|
||||
* value on each restore (or url change) so that we can guarantee
|
||||
* the validation came from a trusted source (the editor). Also because we
|
||||
* may not have access to host-app supplied url validator during restore.
|
||||
*/
|
||||
validated?: boolean;
|
||||
type: "embeddable";
|
||||
}>;
|
||||
|
||||
export type ExcalidrawImageElement = _ExcalidrawElementBase &
|
||||
Readonly<{
|
||||
type: "image";
|
||||
|
@ -112,6 +125,7 @@ export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
|||
export type ExcalidrawGenericElement =
|
||||
| ExcalidrawSelectionElement
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement;
|
||||
|
||||
|
@ -158,13 +172,13 @@ export type ExcalidrawBindableElement =
|
|||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawEmbeddableElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawArrowElement;
|
||||
|
||||
export type ExcalidrawTextElementWithContainer = {
|
||||
|
|
|
@ -171,10 +171,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
) {
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
Object.defineProperties(window, {
|
||||
collab: {
|
||||
|
@ -333,7 +330,7 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
* Indicates whether to fetch files that are errored or pending and older
|
||||
* than 10 seconds.
|
||||
*
|
||||
* Use this as a machanism to fetch files which may be ok but for some
|
||||
* Use this as a mechanism to fetch files which may be ok but for some
|
||||
* reason their status was not updated correctly.
|
||||
*/
|
||||
forceFetchFiles?: boolean;
|
||||
|
@ -860,10 +857,7 @@ declare global {
|
|||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
process.env.NODE_ENV === ENV.DEVELOPMENT
|
||||
) {
|
||||
if (import.meta.env.MODE === ENV.TEST || import.meta.env.DEV) {
|
||||
window.collab = window.collab || ({} as Window["collab"]);
|
||||
}
|
||||
|
||||
|
|
|
@ -19,7 +19,9 @@ export const AppWelcomeScreen: React.FC<{
|
|||
return (
|
||||
<a
|
||||
style={{ pointerEvents: "all" }}
|
||||
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenSignedInUser`}
|
||||
key={idx}
|
||||
>
|
||||
Excalidraw+
|
||||
|
|
|
@ -6,7 +6,9 @@ export const ExcalidrawPlusAppLink = () => {
|
|||
}
|
||||
return (
|
||||
<a
|
||||
href={`${process.env.REACT_APP_PLUS_APP}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||
href={`${
|
||||
import.meta.env.VITE_APP_PLUS_APP
|
||||
}?utm_source=excalidraw&utm_medium=app&utm_content=signedInUserRedirectButton#excalidraw-redirect`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="plus-button"
|
||||
|
|
|
@ -16,7 +16,7 @@ import { MIME_TYPES } from "../../constants";
|
|||
import { trackEvent } from "../../analytics";
|
||||
import { getFrame } from "../../utils";
|
||||
|
||||
const exportToExcalidrawPlus = async (
|
||||
export const exportToExcalidrawPlus = async (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
|
|
|
@ -21,10 +21,12 @@ import { ResolutionType } from "../../utility-types";
|
|||
|
||||
let FIREBASE_CONFIG: Record<string, any>;
|
||||
try {
|
||||
FIREBASE_CONFIG = JSON.parse(process.env.REACT_APP_FIREBASE_CONFIG);
|
||||
FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_APP_FIREBASE_CONFIG);
|
||||
} catch (error: any) {
|
||||
console.warn(
|
||||
`Error JSON parsing firebase config. Supplied value: ${process.env.REACT_APP_FIREBASE_CONFIG}`,
|
||||
`Error JSON parsing firebase config. Supplied value: ${
|
||||
import.meta.env.VITE_APP_FIREBASE_CONFIG
|
||||
}`,
|
||||
);
|
||||
FIREBASE_CONFIG = {};
|
||||
}
|
||||
|
|
|
@ -47,8 +47,8 @@ export const getSyncableElements = (elements: readonly ExcalidrawElement[]) =>
|
|||
isSyncableElement(element),
|
||||
) as SyncableExcalidrawElement[];
|
||||
|
||||
const BACKEND_V2_GET = process.env.REACT_APP_BACKEND_V2_GET_URL;
|
||||
const BACKEND_V2_POST = process.env.REACT_APP_BACKEND_V2_POST_URL;
|
||||
const BACKEND_V2_GET = import.meta.env.VITE_APP_BACKEND_V2_GET_URL;
|
||||
const BACKEND_V2_POST = import.meta.env.VITE_APP_BACKEND_V2_POST_URL;
|
||||
|
||||
const generateRoomId = async () => {
|
||||
const buffer = new Uint8Array(ROOM_ID_BYTES);
|
||||
|
@ -67,16 +67,16 @@ export const getCollabServer = async (): Promise<{
|
|||
url: string;
|
||||
polling: boolean;
|
||||
}> => {
|
||||
if (process.env.REACT_APP_WS_SERVER_URL) {
|
||||
if (import.meta.env.VITE_APP_WS_SERVER_URL) {
|
||||
return {
|
||||
url: process.env.REACT_APP_WS_SERVER_URL,
|
||||
url: import.meta.env.VITE_APP_WS_SERVER_URL,
|
||||
polling: true,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${process.env.REACT_APP_PORTAL_URL}/collab-server`,
|
||||
`${import.meta.env.VITE_APP_PORTAL_URL}/collab-server`,
|
||||
);
|
||||
return await resp.json();
|
||||
} catch (error) {
|
||||
|
@ -282,11 +282,15 @@ export const loadScene = async (
|
|||
};
|
||||
};
|
||||
|
||||
type ExportToBackendResult =
|
||||
| { url: null; errorMessage: string }
|
||||
| { url: string; errorMessage: null };
|
||||
|
||||
export const exportToBackend = async (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
files: BinaryFiles,
|
||||
) => {
|
||||
): Promise<ExportToBackendResult> => {
|
||||
const encryptionKey = await generateEncryptionKey("string");
|
||||
|
||||
const payload = await compressData(
|
||||
|
@ -327,14 +331,18 @@ export const exportToBackend = async (
|
|||
files: filesToUpload,
|
||||
});
|
||||
|
||||
window.prompt(`🔒${t("alerts.uploadedSecurly")}`, urlString);
|
||||
return { url: urlString, errorMessage: null };
|
||||
} else if (json.error_class === "RequestTooLargeError") {
|
||||
window.alert(t("alerts.couldNotCreateShareableLinkTooBig"));
|
||||
} else {
|
||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||
return {
|
||||
url: null,
|
||||
errorMessage: t("alerts.couldNotCreateShareableLinkTooBig"),
|
||||
};
|
||||
}
|
||||
|
||||
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
window.alert(t("alerts.couldNotCreateShareableLink"));
|
||||
|
||||
return { url: null, errorMessage: t("alerts.couldNotCreateShareableLink") };
|
||||
}
|
||||
};
|
||||
|
|
135
src/excalidraw-app/debug.ts
Normal file
135
src/excalidraw-app/debug.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
declare global {
|
||||
interface Window {
|
||||
debug: typeof Debug;
|
||||
}
|
||||
}
|
||||
|
||||
const lessPrecise = (num: number, precision = 5) =>
|
||||
parseFloat(num.toPrecision(precision));
|
||||
|
||||
const getAvgFrameTime = (times: number[]) =>
|
||||
lessPrecise(times.reduce((a, b) => a + b) / times.length);
|
||||
|
||||
const getFps = (frametime: number) => lessPrecise(1000 / frametime);
|
||||
|
||||
export class Debug {
|
||||
public static DEBUG_LOG_TIMES = true;
|
||||
|
||||
private static TIMES_AGGR: Record<string, { t: number; times: number[] }> =
|
||||
{};
|
||||
private static TIMES_AVG: Record<
|
||||
string,
|
||||
{ t: number; times: number[]; avg: number | null }
|
||||
> = {};
|
||||
private static LAST_DEBUG_LOG_CALL = 0;
|
||||
private static DEBUG_LOG_INTERVAL_ID: null | number = null;
|
||||
|
||||
private static setupInterval = () => {
|
||||
if (Debug.DEBUG_LOG_INTERVAL_ID === null) {
|
||||
console.info("%c(starting perf recording)", "color: lime");
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = window.setInterval(Debug.debugLogger, 1000);
|
||||
}
|
||||
Debug.LAST_DEBUG_LOG_CALL = Date.now();
|
||||
};
|
||||
|
||||
private static debugLogger = () => {
|
||||
if (
|
||||
Date.now() - Debug.LAST_DEBUG_LOG_CALL > 600 &&
|
||||
Debug.DEBUG_LOG_INTERVAL_ID !== null
|
||||
) {
|
||||
window.clearInterval(Debug.DEBUG_LOG_INTERVAL_ID);
|
||||
Debug.DEBUG_LOG_INTERVAL_ID = null;
|
||||
for (const [name, { avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (avg != null) {
|
||||
console.info(
|
||||
`%c${name} run avg: ${avg}ms (${getFps(avg)} fps)`,
|
||||
"color: blue",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.info("%c(stopping perf recording)", "color: red");
|
||||
Debug.TIMES_AGGR = {};
|
||||
Debug.TIMES_AVG = {};
|
||||
return;
|
||||
}
|
||||
if (Debug.DEBUG_LOG_TIMES) {
|
||||
for (const [name, { t, times }] of Object.entries(Debug.TIMES_AGGR)) {
|
||||
if (times.length) {
|
||||
console.info(
|
||||
name,
|
||||
lessPrecise(times.reduce((a, b) => a + b)),
|
||||
times.sort((a, b) => a - b).map((x) => lessPrecise(x)),
|
||||
);
|
||||
Debug.TIMES_AGGR[name] = { t, times: [] };
|
||||
}
|
||||
}
|
||||
for (const [name, { t, times, avg }] of Object.entries(Debug.TIMES_AVG)) {
|
||||
if (times.length) {
|
||||
const avgFrameTime = getAvgFrameTime(times);
|
||||
console.info(name, `${avgFrameTime}ms (${getFps(avgFrameTime)} fps)`);
|
||||
Debug.TIMES_AVG[name] = {
|
||||
t,
|
||||
times: [],
|
||||
avg:
|
||||
avg != null ? getAvgFrameTime([avg, avgFrameTime]) : avgFrameTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public static logTime = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AGGR[name] = Debug.TIMES_AGGR[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AGGR[name].t = now;
|
||||
};
|
||||
public static logTimeAverage = (time?: number, name = "default") => {
|
||||
Debug.setupInterval();
|
||||
const now = performance.now();
|
||||
const { t, times } = (Debug.TIMES_AVG[name] = Debug.TIMES_AVG[name] || {
|
||||
t: 0,
|
||||
times: [],
|
||||
});
|
||||
if (t) {
|
||||
times.push(time != null ? time : now - t);
|
||||
}
|
||||
Debug.TIMES_AVG[name].t = now;
|
||||
};
|
||||
|
||||
private static logWrapper =
|
||||
(type: "logTime" | "logTimeAverage") =>
|
||||
<T extends any[], R>(fn: (...args: T) => R, name = "default") => {
|
||||
return (...args: T) => {
|
||||
const t0 = performance.now();
|
||||
const ret = fn(...args);
|
||||
Debug.logTime(performance.now() - t0, name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
|
||||
public static logTimeWrap = Debug.logWrapper("logTime");
|
||||
public static logTimeAverageWrap = Debug.logWrapper("logTimeAverage");
|
||||
|
||||
public static perfWrap = <T extends any[], R>(
|
||||
fn: (...args: T) => R,
|
||||
name = "default",
|
||||
) => {
|
||||
return (...args: T) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.time(name);
|
||||
const ret = fn(...args);
|
||||
// eslint-disable-next-line no-console
|
||||
console.timeEnd(name);
|
||||
return ret;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
window.debug = Debug;
|
|
@ -70,7 +70,10 @@ import {
|
|||
} from "./data/localStorage";
|
||||
import CustomStats from "./CustomStats";
|
||||
import { restore, restoreAppState, RestoredDataState } from "../data/restore";
|
||||
import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
|
||||
import {
|
||||
ExportToExcalidrawPlus,
|
||||
exportToExcalidrawPlus,
|
||||
} from "./components/ExportToExcalidrawPlus";
|
||||
import { updateStaleImageStatuses } from "./data/FileManager";
|
||||
import { newElementWith } from "../element/mutateElement";
|
||||
import { isInitializedImageElement } from "../element/typeChecks";
|
||||
|
@ -89,16 +92,47 @@ import { appJotaiStore } from "./app-jotai";
|
|||
|
||||
import "./index.scss";
|
||||
import { ResolutionType } from "../utility-types";
|
||||
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../components/Trans";
|
||||
|
||||
polyfill();
|
||||
|
||||
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
||||
|
||||
let isSelfEmbedding = false;
|
||||
|
||||
if (window.self !== window.top) {
|
||||
try {
|
||||
const parentUrl = new URL(document.referrer);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
if (parentUrl.origin === currentUrl.origin) {
|
||||
isSelfEmbedding = true;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const languageDetector = new LanguageDetector();
|
||||
languageDetector.init({
|
||||
languageUtils: {},
|
||||
});
|
||||
|
||||
const shareableLinkConfirmDialog = {
|
||||
title: t("overwriteConfirm.modal.shareableLink.title"),
|
||||
description: (
|
||||
<Trans
|
||||
i18nKey="overwriteConfirm.modal.shareableLink.description"
|
||||
bold={(text) => <strong>{text}</strong>}
|
||||
br={() => <br />}
|
||||
/>
|
||||
),
|
||||
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
|
||||
color: "danger",
|
||||
} as const;
|
||||
|
||||
const initializeScene = async (opts: {
|
||||
collabAPI: CollabAPI | null;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
|
@ -130,7 +164,7 @@ const initializeScene = async (opts: {
|
|||
// don't prompt for collab scenes because we don't override local storage
|
||||
roomLinkData ||
|
||||
// otherwise, prompt whether user wants to override current scene
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
if (jsonBackendMatch) {
|
||||
scene = await loadScene(
|
||||
|
@ -169,7 +203,7 @@ const initializeScene = async (opts: {
|
|||
const data = await loadFromBlob(await request.blob(), null, null);
|
||||
if (
|
||||
!scene.elements.length ||
|
||||
window.confirm(t("alerts.loadSceneOverridePrompt"))
|
||||
(await openConfirmModal(shareableLinkConfirmDialog))
|
||||
) {
|
||||
return { scene: data, isExternalScene };
|
||||
}
|
||||
|
@ -501,7 +535,9 @@ const ExcalidrawWrapper = () => {
|
|||
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() =>
|
||||
localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_THEME) ||
|
||||
(localStorage.getItem(
|
||||
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
||||
) as Theme | null) ||
|
||||
// FIXME migration from old LS scheme. Can be removed later. #5660
|
||||
importFromLocalStorage().appState?.theme ||
|
||||
THEME.LIGHT,
|
||||
|
@ -559,6 +595,10 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onExportToBackend = async (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
|
@ -570,7 +610,7 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
if (canvas) {
|
||||
try {
|
||||
await exportToBackend(
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
|
@ -580,6 +620,14 @@ const ExcalidrawWrapper = () => {
|
|||
},
|
||||
files,
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
setErrorMessage(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = canvas;
|
||||
|
@ -614,6 +662,25 @@ const ExcalidrawWrapper = () => {
|
|||
|
||||
const isOffline = useAtomValue(isOfflineAtom);
|
||||
|
||||
// browsers generally prevent infinite self-embedding, there are
|
||||
// cases where it still happens, and while we disallow self-embedding
|
||||
// by not whitelisting our own origin, this serves as an additional guard
|
||||
if (isSelfEmbedding) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<h1>I'm not a pretzel!</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ height: "100%" }}
|
||||
|
@ -679,21 +746,47 @@ const ExcalidrawWrapper = () => {
|
|||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<OverwriteConfirmDialog>
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
{excalidrawAPI && (
|
||||
<OverwriteConfirmDialog.Action
|
||||
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
||||
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
||||
onClick={() => {
|
||||
exportToExcalidrawPlus(
|
||||
excalidrawAPI.getSceneElements(),
|
||||
excalidrawAPI.getAppState(),
|
||||
excalidrawAPI.getFiles(),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
||||
</OverwriteConfirmDialog.Action>
|
||||
)}
|
||||
</OverwriteConfirmDialog>
|
||||
<AppFooter />
|
||||
{isCollaborating && isOffline && (
|
||||
<div className="collab-offline-warning">
|
||||
{t("alerts.collabOfflineWarning")}
|
||||
</div>
|
||||
)}
|
||||
{latestShareableLink && (
|
||||
<ShareableLinkDialog
|
||||
link={latestShareableLink}
|
||||
onCloseRequest={() => setLatestShareableLink(null)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
{excalidrawAPI && !isCollabDisabled && (
|
||||
<Collab excalidrawAPI={excalidrawAPI} />
|
||||
)}
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</Excalidraw>
|
||||
{errorMessage && (
|
||||
<ErrorDialog onClose={() => setErrorMessage("")}>
|
||||
{errorMessage}
|
||||
</ErrorDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import { register as registerServiceWorker } from "../serviceWorkerRegistration";
|
||||
import { EVENT } from "../constants";
|
||||
|
||||
// On Apple mobile devices add the proprietary app icon and splashscreen markup.
|
||||
// No one should have to do this manually, and eventually this annoyance will
|
||||
// go away once https://bugs.webkit.org/show_bug.cgi?id=183937 is fixed.
|
||||
if (
|
||||
/\b(iPad|iPhone|iPod|Safari)\b/.test(navigator.userAgent) &&
|
||||
!matchMedia("(display-mode: standalone)").matches
|
||||
) {
|
||||
import(/* webpackChunkName: "pwacompat" */ "pwacompat");
|
||||
}
|
||||
|
||||
registerServiceWorker({
|
||||
onUpdate: (registration) => {
|
||||
const waitingServiceWorker = registration.waiting;
|
||||
if (waitingServiceWorker) {
|
||||
waitingServiceWorker.addEventListener(
|
||||
EVENT.STATE_CHANGE,
|
||||
(event: Event) => {
|
||||
const target = event.target as ServiceWorker;
|
||||
const state = target.state as ServiceWorkerState;
|
||||
if (state === "activated") {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
);
|
||||
waitingServiceWorker.postMessage({ type: "SKIP_WAITING" });
|
||||
}
|
||||
},
|
||||
});
|
|
@ -6,12 +6,11 @@ const SentryEnvHostnameMap: { [key: string]: string } = {
|
|||
"vercel.app": "staging",
|
||||
};
|
||||
|
||||
const REACT_APP_DISABLE_SENTRY =
|
||||
process.env.REACT_APP_DISABLE_SENTRY === "true";
|
||||
const SENTRY_DISABLED = import.meta.env.VITE_APP_DISABLE_SENTRY === "true";
|
||||
|
||||
// Disable Sentry locally or inside the Docker to avoid noise/respect privacy
|
||||
const onlineEnv =
|
||||
!REACT_APP_DISABLE_SENTRY &&
|
||||
!SENTRY_DISABLED &&
|
||||
Object.keys(SentryEnvHostnameMap).find(
|
||||
(item) => window.location.hostname.indexOf(item) >= 0,
|
||||
);
|
||||
|
@ -21,7 +20,7 @@ Sentry.init({
|
|||
? "https://7bfc596a5bf945eda6b660d3015a5460@sentry.io/5179260"
|
||||
: undefined,
|
||||
environment: onlineEnv ? SentryEnvHostnameMap[onlineEnv] : undefined,
|
||||
release: process.env.REACT_APP_GIT_SHA,
|
||||
release: import.meta.env.VITE_APP_GIT_SHA,
|
||||
ignoreErrors: [
|
||||
"undefined is not an object (evaluating 'window.__pad.performLoop')", // Only happens on Safari, but spams our servers. Doesn't break anything
|
||||
],
|
||||
|
|
16
src/frame.ts
16
src/frame.ts
|
@ -16,7 +16,7 @@ import {
|
|||
} from "./element/textElement";
|
||||
import { arrayToMap, findIndex } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppState } from "./types";
|
||||
import { AppClassProperties, AppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
import { isFrameElement } from "./element";
|
||||
import { moveOneRight } from "./zindex";
|
||||
|
@ -304,7 +304,7 @@ export const groupsAreCompletelyOutOfFrame = (
|
|||
/**
|
||||
* Returns a map of frameId to frame elements. Includes empty frames.
|
||||
*/
|
||||
export const groupByFrames = (elements: ExcalidrawElementsIncludingDeleted) => {
|
||||
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
|
||||
const frameElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement[]
|
||||
|
@ -571,8 +571,13 @@ export const replaceAllElementsInFrame = (
|
|||
export const updateFrameMembershipOfSelectedElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
appState: AppState,
|
||||
app: AppClassProperties,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(allElements, appState);
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
// supplying elements explicitly in case we're passed non-state elements
|
||||
elements: allElements,
|
||||
});
|
||||
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
|
@ -591,6 +596,7 @@ export const updateFrameMembershipOfSelectedElements = (
|
|||
|
||||
elementsToFilter.forEach((element) => {
|
||||
if (
|
||||
element.frameId &&
|
||||
!isFrameElement(element) &&
|
||||
!isElementInFrame(element, allElements, appState)
|
||||
) {
|
||||
|
@ -598,7 +604,9 @@ export const updateFrameMembershipOfSelectedElements = (
|
|||
}
|
||||
});
|
||||
|
||||
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
|
||||
return elementsToRemove.size > 0
|
||||
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
|
||||
: allElements;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
16
src/global.d.ts
vendored
16
src/global.d.ts
vendored
|
@ -39,16 +39,6 @@ interface CanvasRenderingContext2D {
|
|||
) => void;
|
||||
}
|
||||
|
||||
// https://github.com/facebook/create-react-app/blob/ddcb7d5/packages/react-scripts/lib/react-app.d.ts
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly REACT_APP_BACKEND_V2_GET_URL: string;
|
||||
readonly REACT_APP_BACKEND_V2_POST_URL: string;
|
||||
readonly REACT_APP_PORTAL_URL: string;
|
||||
readonly REACT_APP_FIREBASE_CONFIG: string;
|
||||
}
|
||||
}
|
||||
|
||||
interface Clipboard extends EventTarget {
|
||||
write(data: any[]): Promise<void>;
|
||||
}
|
||||
|
@ -121,3 +111,9 @@ declare module "image-blob-reduce" {
|
|||
const reduce: ImageBlobReduce.ImageBlobReduceStatic;
|
||||
export = reduce;
|
||||
}
|
||||
|
||||
declare namespace jest {
|
||||
interface Expect {
|
||||
toBeNonNaNNumber(): void;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,13 @@
|
|||
import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
|
||||
import { AppState } from "./types";
|
||||
import {
|
||||
GroupId,
|
||||
ExcalidrawElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { AppClassProperties, AppState } from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { makeNextSelectedElementIds } from "./scene/selection";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
|
@ -66,14 +72,33 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
|
|||
*/
|
||||
export const selectGroupsForSelectedElements = (
|
||||
appState: AppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
prevAppState: AppState,
|
||||
/**
|
||||
* supply null in cases where you don't have access to App instance and
|
||||
* you don't care about optimizing selectElements retrieval
|
||||
*/
|
||||
app: AppClassProperties | null,
|
||||
): AppState => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
const selectedElements = app
|
||||
? app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
// supplying elements explicitly in case we're passed non-state elements
|
||||
elements,
|
||||
})
|
||||
: getSelectedElements(elements, appState);
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return { ...nextAppState, editingGroupId: null };
|
||||
return {
|
||||
...nextAppState,
|
||||
editingGroupId: null,
|
||||
selectedElementIds: makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
for (const selectedElement of selectedElements) {
|
||||
|
@ -91,6 +116,11 @@ export const selectGroupsForSelectedElements = (
|
|||
}
|
||||
}
|
||||
|
||||
nextAppState.selectedElementIds = makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
);
|
||||
|
||||
return nextAppState;
|
||||
};
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ const exportLibraryItemToSvg = async (elements: LibraryItem["elements"]) => {
|
|||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
},
|
||||
files: null,
|
||||
renderEmbeddables: false,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
12
src/i18n.ts
12
src/i18n.ts
|
@ -1,8 +1,8 @@
|
|||
import fallbackLangData from "./locales/en.json";
|
||||
import percentages from "./locales/percentages.json";
|
||||
import { ENV } from "./constants";
|
||||
import { jotaiScope, jotaiStore } from "./jotai";
|
||||
import { atom, useAtomValue } from "jotai";
|
||||
import { NestedKeyOf } from "./utility-types";
|
||||
|
||||
const COMPLETION_THRESHOLD = 85;
|
||||
|
||||
|
@ -12,6 +12,8 @@ export interface Language {
|
|||
rtl?: boolean;
|
||||
}
|
||||
|
||||
export type TranslationKeys = NestedKeyOf<typeof fallbackLangData>;
|
||||
|
||||
export const defaultLang = { code: "en", label: "English" };
|
||||
|
||||
export const languages: Language[] = [
|
||||
|
@ -71,7 +73,7 @@ export const languages: Language[] = [
|
|||
];
|
||||
|
||||
const TEST_LANG_CODE = "__test__";
|
||||
if (process.env.NODE_ENV === ENV.DEVELOPMENT) {
|
||||
if (import.meta.env.DEV) {
|
||||
languages.unshift(
|
||||
{ code: TEST_LANG_CODE, label: "test language" },
|
||||
{
|
||||
|
@ -150,7 +152,9 @@ const findPartsForData = (data: any, parts: string[]) => {
|
|||
};
|
||||
|
||||
export const t = (
|
||||
path: string,
|
||||
path:
|
||||
| NestedKeyOf<typeof fallbackLangData>
|
||||
| `${NestedKeyOf<typeof fallbackLangData>}.${string}`,
|
||||
replacement?: { [key: string]: string | number } | null,
|
||||
fallback?: string,
|
||||
) => {
|
||||
|
@ -176,7 +180,7 @@ export const t = (
|
|||
if (translation === undefined) {
|
||||
const errorMessage = `Can't find translation for ${path}`;
|
||||
// in production, don't blow up the app on a missing translation key
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (import.meta.env.PROD) {
|
||||
console.warn(errorMessage);
|
||||
return "";
|
||||
}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import ExcalidrawApp from "./excalidraw-app";
|
||||
import { registerSW } from "virtual:pwa-register";
|
||||
|
||||
import "./excalidraw-app/pwa";
|
||||
import "./excalidraw-app/sentry";
|
||||
window.__EXCALIDRAW_SHA__ = process.env.REACT_APP_GIT_SHA;
|
||||
window.__EXCALIDRAW_SHA__ = import.meta.env.VITE_APP_GIT_SHA;
|
||||
const rootElement = document.getElementById("root")!;
|
||||
const root = createRoot(rootElement);
|
||||
registerSW();
|
||||
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<ExcalidrawApp />
|
||||
|
|
|
@ -10,6 +10,7 @@ export const CODES = {
|
|||
BRACKET_LEFT: "BracketLeft",
|
||||
ONE: "Digit1",
|
||||
TWO: "Digit2",
|
||||
THREE: "Digit3",
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
|
@ -64,6 +65,7 @@ export const KEYS = {
|
|||
Y: "y",
|
||||
Z: "z",
|
||||
K: "k",
|
||||
W: "w",
|
||||
|
||||
0: "0",
|
||||
1: "1",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "نُشر",
|
||||
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "وضع القلم - امنع اللمس",
|
||||
"link": "إضافة/تحديث الرابط للشكل المحدد",
|
||||
"eraser": "ممحاة",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "إجراءات اللوحة",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Действия по платното",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "প্রকাশিত",
|
||||
"sidebarLock": "লক",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "একটি নির্বাচিত আকৃতির জন্য লিঙ্ক যোগ বা আপডেট করুন",
|
||||
"eraser": "ঝাড়ন",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "ক্যানভাস কার্যকলাপ",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "Publicat",
|
||||
"sidebarLock": "Manté la barra lateral oberta",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "Mode de llapis - evita tocar",
|
||||
"link": "Afegeix / actualitza l'enllaç per a la forma seleccionada",
|
||||
"eraser": "Esborrador",
|
||||
"hand": "Mà (eina de desplaçament)"
|
||||
"frame": "",
|
||||
"hand": "Mà (eina de desplaçament)",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Accions del llenç",
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue