mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge remote-tracking branch 'origin/master' into aakansha-create-text-containers-programmatically
This commit is contained in:
commit
2ff0528a4f
186 changed files with 8275 additions and 1805 deletions
|
@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.some((element) => element.type === "image")) {
|
||||
return {
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { ToolButton } from "../components/ToolButton";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
|
@ -17,10 +18,20 @@ import { AppState } from "../types";
|
|||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const enableActionGroup = (
|
||||
const alignActionsPredicate = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable aligning frames when implemented properly
|
||||
!selectedElements.some((el) => el.type === "frame")
|
||||
);
|
||||
};
|
||||
|
||||
const alignSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -36,14 +47,16 @@ const alignSelectedElements = (
|
|||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
);
|
||||
};
|
||||
|
||||
export const actionAlignTop = register({
|
||||
name: "alignTop",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -58,7 +71,7 @@ export const actionAlignTop = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignTopIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -74,6 +87,7 @@ export const actionAlignTop = register({
|
|||
export const actionAlignBottom = register({
|
||||
name: "alignBottom",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignBottomIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
|
|||
export const actionAlignLeft = register({
|
||||
name: "alignLeft",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignLeftIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
|
|||
export const actionAlignRight = register({
|
||||
name: "alignRight",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -149,7 +164,7 @@ export const actionAlignRight = register({
|
|||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={AlignRightIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -165,7 +180,7 @@ export const actionAlignRight = register({
|
|||
export const actionAlignVerticallyCentered = register({
|
||||
name: "alignVerticallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={CenterVerticallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
|
|||
export const actionAlignHorizontallyCentered = register({
|
||||
name: "alignHorizontallyCentered",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: alignActionsPredicate,
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
appState,
|
||||
|
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<ToolButton
|
||||
hidden={!enableActionGroup(elements, appState)}
|
||||
hidden={!alignActionsPredicate(elements, appState)}
|
||||
type="button"
|
||||
icon={CenterHorizontallyIcon}
|
||||
onClick={() => updateData(null)}
|
||||
|
|
|
@ -31,6 +31,7 @@ import {
|
|||
} from "../element/types";
|
||||
import { getSelectedElements } from "../scene";
|
||||
import { AppState } from "../types";
|
||||
import { Mutable } from "../utility-types";
|
||||
import { getFontString } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
|
@ -211,7 +212,7 @@ export const actionWrapTextInContainer = register({
|
|||
appState,
|
||||
);
|
||||
let updatedElements: readonly ExcalidrawElement[] = elements.slice();
|
||||
const containerIds: AppState["selectedElementIds"] = {};
|
||||
const containerIds: Mutable<AppState["selectedElementIds"]> = {};
|
||||
|
||||
for (const textElement of selectedElements) {
|
||||
if (isTextElement(textElement)) {
|
||||
|
@ -249,6 +250,7 @@ export const actionWrapTextInContainer = register({
|
|||
"rectangle",
|
||||
),
|
||||
groupIds: textElement.groupIds,
|
||||
frameId: textElement.frameId,
|
||||
});
|
||||
|
||||
// update bindings
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
isHandToolActive,
|
||||
} from "../appState";
|
||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||
import { Bounds } from "../element/bounds";
|
||||
|
||||
export const actionChangeViewBackgroundColor = register({
|
||||
name: "changeViewBackgroundColor",
|
||||
|
@ -206,7 +207,7 @@ export const actionResetZoom = register({
|
|||
});
|
||||
|
||||
const zoomValueToFitBoundsOnViewport = (
|
||||
bounds: [number, number, number, number],
|
||||
bounds: Bounds,
|
||||
viewportDimensions: { width: number; height: number },
|
||||
) => {
|
||||
const [x1, y1, x2, y2] = bounds;
|
||||
|
@ -224,50 +225,96 @@ 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(selectedElements)
|
||||
: getCommonBounds(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) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
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 &&
|
||||
|
@ -275,11 +322,34 @@ export const actionZoomToSelected = register({
|
|||
!event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionZoomToFitSelection = register({
|
||||
name: "zoomToFitSelection",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
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 &&
|
||||
|
|
|
@ -16,9 +16,12 @@ export const actionCopy = register({
|
|||
name: "copy",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const elementsToCopy = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
copyToClipboard(selectedElements, app.files);
|
||||
copyToClipboard(elementsToCopy, app.files);
|
||||
|
||||
return {
|
||||
commitToHistory: false,
|
||||
|
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
|
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
try {
|
||||
await exportCanvas(
|
||||
|
@ -172,7 +181,9 @@ export const copyText = register({
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
|
||||
const text = selectedElements
|
||||
|
@ -191,7 +202,9 @@ export const copyText = register({
|
|||
predicate: (elements, appState) => {
|
||||
return (
|
||||
probablySupportsClipboardWriteText &&
|
||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
}).some(isTextElement)
|
||||
);
|
||||
},
|
||||
contextItemLabel: "labels.copyText",
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isSomeElementSelected } from "../scene";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { KEYS } from "../keys";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
|
@ -18,11 +18,23 @@ const deleteSelectedElements = (
|
|||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const framesToBeDeleted = new Set(
|
||||
getSelectedElements(
|
||||
elements.filter((el) => el.type === "frame"),
|
||||
appState,
|
||||
).map((el) => el.id),
|
||||
);
|
||||
|
||||
return {
|
||||
elements: elements.map((el) => {
|
||||
if (appState.selectedElementIds[el.id]) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||
return newElementWith(el, { isDeleted: true });
|
||||
}
|
||||
|
||||
if (
|
||||
isBoundToContainer(el) &&
|
||||
appState.selectedElementIds[el.containerId]
|
||||
|
|
|
@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||
import { distributeElements, Distribution } from "../distribute";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
import { t } from "../i18n";
|
||||
import { CODES, KEYS } from "../keys";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
|
@ -16,7 +17,17 @@ import { register } from "./register";
|
|||
const enableActionGroup = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => getSelectedElements(getNonDeletedElements(elements), appState).length > 1;
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
return (
|
||||
selectedElements.length > 1 &&
|
||||
// TODO enable distributing frames when implemented properly
|
||||
!selectedElements.some((el) => el.type === "frame")
|
||||
);
|
||||
};
|
||||
|
||||
const distributeSelectedElements = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
@ -32,8 +43,9 @@ const distributeSelectedElements = (
|
|||
|
||||
const updatedElementsMap = arrayToMap(updatedElements);
|
||||
|
||||
return elements.map(
|
||||
(element) => updatedElementsMap.get(element.id) || element,
|
||||
return updateFrameMembershipOfSelectedElements(
|
||||
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||
appState,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
|||
import { register } from "./register";
|
||||
import { ExcalidrawElement } from "../element/types";
|
||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||
import { isSomeElementSelected } from "../scene";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { t } from "../i18n";
|
||||
import { arrayToMap, getShortcutKey } from "../utils";
|
||||
|
@ -20,9 +20,17 @@ import {
|
|||
bindTextToShapeAfterDuplication,
|
||||
getBoundTextElement,
|
||||
} from "../element/textElement";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
|
||||
import { normalizeElementOrder } from "../element/sortElements";
|
||||
import { DuplicateIcon } from "../components/icons";
|
||||
import {
|
||||
bindElementsToFramesAfterDuplication,
|
||||
getFrameElements,
|
||||
} from "../frame";
|
||||
import {
|
||||
excludeElementsInFramesFromSelection,
|
||||
getSelectedElements,
|
||||
} from "../scene/selection";
|
||||
|
||||
export const actionDuplicateSelection = register({
|
||||
name: "duplicateSelection",
|
||||
|
@ -94,8 +102,11 @@ const duplicateElements = (
|
|||
return newElement;
|
||||
};
|
||||
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, true),
|
||||
const idsOfElementsToDuplicate = arrayToMap(
|
||||
getSelectedElements(sortedElements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Ids of elements that have already been processed so we don't push them
|
||||
|
@ -129,12 +140,25 @@ const duplicateElements = (
|
|||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (selectedElementIds.get(element.id)) {
|
||||
// if a group or a container/bound-text, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement) {
|
||||
const isElementAFrame = isFrameElement(element);
|
||||
|
||||
if (idsOfElementsToDuplicate.get(element.id)) {
|
||||
// if a group or a container/bound-text or frame, duplicate atomically
|
||||
if (element.groupIds.length || boundTextElement || isElementAFrame) {
|
||||
const groupId = getSelectedGroupForElement(appState, element);
|
||||
if (groupId) {
|
||||
const groupElements = getElementsInGroup(sortedElements, groupId);
|
||||
// TODO:
|
||||
// remove `.flatMap...`
|
||||
// if the elements in a frame are grouped when the frame is grouped
|
||||
const groupElements = getElementsInGroup(
|
||||
sortedElements,
|
||||
groupId,
|
||||
).flatMap((element) =>
|
||||
isFrameElement(element)
|
||||
? [...getFrameElements(elements, element.id), element]
|
||||
: [element],
|
||||
);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...groupElements,
|
||||
|
@ -156,10 +180,34 @@ const duplicateElements = (
|
|||
);
|
||||
continue;
|
||||
}
|
||||
if (isElementAFrame) {
|
||||
const elementsInFrame = getFrameElements(sortedElements, element.id);
|
||||
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([
|
||||
...elementsInFrame,
|
||||
element,
|
||||
...elementsInFrame.map((e) => duplicateAndOffsetElement(e)),
|
||||
duplicateAndOffsetElement(element),
|
||||
]),
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// since elements in frames have a lower z-index than the frame itself,
|
||||
// they will be looped first and if their frames are selected as well,
|
||||
// they will have been copied along with the frame atomically in the
|
||||
// above branch, so we must skip those elements here
|
||||
//
|
||||
// now, for elements do not belong any frames or elements whose frames
|
||||
// are selected (or elements that are left out from the above
|
||||
// steps for whatever reason) we (should at least) duplicate them here
|
||||
if (!element.frameId || !idsOfElementsToDuplicate.has(element.frameId)) {
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
);
|
||||
}
|
||||
elementsWithClones.push(
|
||||
...markAsProcessed([element, duplicateAndOffsetElement(element)]),
|
||||
);
|
||||
} else {
|
||||
elementsWithClones.push(...markAsProcessed([element]));
|
||||
}
|
||||
|
@ -200,6 +248,14 @@ const duplicateElements = (
|
|||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
bindElementsToFramesAfterDuplication(
|
||||
finalElements,
|
||||
oldElements,
|
||||
oldIdToDuplicatedId,
|
||||
);
|
||||
|
||||
const nextElementsToSelect =
|
||||
excludeElementsInFramesFromSelection(newElements);
|
||||
|
||||
return {
|
||||
elements: finalElements,
|
||||
|
@ -207,7 +263,7 @@ const duplicateElements = (
|
|||
{
|
||||
...appState,
|
||||
selectedGroupIds: {},
|
||||
selectedElementIds: newElements.reduce(
|
||||
selectedElementIds: nextElementsToSelect.reduce(
|
||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (!isBoundToContainer(element)) {
|
||||
acc[element.id] = true;
|
||||
|
@ -218,6 +274,7 @@ const duplicateElements = (
|
|||
),
|
||||
},
|
||||
getNonDeletedElements(finalElements),
|
||||
appState,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
|
|
@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||
export const actionToggleElementLock = register({
|
||||
name: "toggleElementLock",
|
||||
trackEvent: { category: "element" },
|
||||
predicate: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState);
|
||||
return !selectedElements.some(
|
||||
(element) => element.locked && element.frameId,
|
||||
);
|
||||
},
|
||||
perform: (elements, appState) => {
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
|
||||
if (!selectedElements.length) {
|
||||
return false;
|
||||
|
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
|
|||
};
|
||||
},
|
||||
contextItemLabel: (elements, appState) => {
|
||||
const selected = getSelectedElements(elements, appState, false);
|
||||
if (selected.length === 1) {
|
||||
const selected = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: false,
|
||||
});
|
||||
if (selected.length === 1 && selected[0].type !== "frame") {
|
||||
return selected[0].locked
|
||||
? "labels.elementLock.unlock"
|
||||
: "labels.elementLock.lock";
|
||||
|
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
|
|||
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||
event[KEYS.CTRL_OR_CMD] &&
|
||||
event.shiftKey &&
|
||||
getSelectedElements(elements, appState, false).length > 0
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: false,
|
||||
}).length > 0
|
||||
);
|
||||
},
|
||||
});
|
||||
|
|
|
@ -125,13 +125,6 @@ export const actionFinalize = register({
|
|||
{ x, y },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
) {
|
||||
appState.selectedElementIds[multiPointElement.id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
|
@ -12,13 +12,17 @@ import {
|
|||
isBindingEnabled,
|
||||
unbindLinearElements,
|
||||
} from "../element/binding";
|
||||
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||
|
||||
export const actionFlipHorizontal = register({
|
||||
name: "flipHorizontal",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "horizontal"),
|
||||
appState,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -32,7 +36,10 @@ export const actionFlipVertical = register({
|
|||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
||||
elements: updateFrameMembershipOfSelectedElements(
|
||||
flipSelectedElements(elements, appState, "vertical"),
|
||||
appState,
|
||||
),
|
||||
appState,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
@ -50,6 +57,9 @@ const flipSelectedElements = (
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
{
|
||||
includeElementsInFrames: true,
|
||||
},
|
||||
);
|
||||
|
||||
const updatedElements = flipElements(
|
||||
|
|
143
src/actions/actionFrame.ts
Normal file
143
src/actions/actionFrame.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import { getNonDeletedElements } from "../element";
|
||||
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 { setCursorForShape, updateActiveTool } from "../utils";
|
||||
import { register } from "./register";
|
||||
|
||||
const isSingleFrameSelected = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
);
|
||||
|
||||
return selectedElements.length === 1 && selectedElements[0].type === "frame";
|
||||
};
|
||||
|
||||
export const actionSelectAllElementsInFrame = register({
|
||||
name: "selectAllElementsInFrame",
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedFrame = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
const elementsInFrame = getFrameElements(
|
||||
getNonDeletedElements(elements),
|
||||
selectedFrame.id,
|
||||
).filter((element) => !(element.type === "text" && element.containerId));
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: elementsInFrame.reduce((acc, element) => {
|
||||
acc[element.id] = true;
|
||||
return acc;
|
||||
}, {} as Record<ExcalidrawElement["id"], true>),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.selectAllElementsInFrame",
|
||||
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
|
||||
});
|
||||
|
||||
export const actionRemoveAllElementsFromFrame = register({
|
||||
name: "removeAllElementsFromFrame",
|
||||
trackEvent: { category: "history" },
|
||||
perform: (elements, appState) => {
|
||||
const selectedFrame = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
)[0];
|
||||
|
||||
if (selectedFrame && selectedFrame.type === "frame") {
|
||||
return {
|
||||
elements: removeAllElementsFromFrame(elements, selectedFrame, appState),
|
||||
appState: {
|
||||
...appState,
|
||||
selectedElementIds: {
|
||||
[selectedFrame.id]: true,
|
||||
},
|
||||
},
|
||||
commitToHistory: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState,
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.removeAllElementsFromFrame",
|
||||
predicate: (elements, appState) => isSingleFrameSelected(elements, appState),
|
||||
});
|
||||
|
||||
export const actionupdateFrameRendering = register({
|
||||
name: "updateFrameRendering",
|
||||
viewMode: true,
|
||||
trackEvent: { category: "canvas" },
|
||||
perform: (elements, appState) => {
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
frameRendering: {
|
||||
...appState.frameRendering,
|
||||
enabled: !appState.frameRendering.enabled,
|
||||
},
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
contextItemLabel: "labels.updateFrameRendering",
|
||||
checked: (appState: AppState) => appState.frameRendering.enabled,
|
||||
});
|
||||
|
||||
export const actionSetFrameAsActiveTool = register({
|
||||
name: "setFrameAsActiveTool",
|
||||
trackEvent: { category: "toolbar" },
|
||||
perform: (elements, appState, _, app) => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
|
||||
setCursorForShape(app.canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
|
||||
return {
|
||||
elements,
|
||||
appState: {
|
||||
...appState,
|
||||
activeTool: updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
}),
|
||||
},
|
||||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) =>
|
||||
!event[KEYS.CTRL_OR_CMD] &&
|
||||
!event.shiftKey &&
|
||||
!event.altKey &&
|
||||
event.key.toLocaleLowerCase() === KEYS.F,
|
||||
});
|
|
@ -17,9 +17,19 @@ import {
|
|||
import { getNonDeletedElements } from "../element";
|
||||
import { randomId } from "../random";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
ExcalidrawTextElement,
|
||||
} from "../element/types";
|
||||
import { AppState } from "../types";
|
||||
import { isBoundToContainer } from "../element/typeChecks";
|
||||
import {
|
||||
getElementsInResizingFrame,
|
||||
groupByFrames,
|
||||
removeElementsFromFrame,
|
||||
replaceAllElementsInFrame,
|
||||
} from "../frame";
|
||||
|
||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||
if (elements.length >= 2) {
|
||||
|
@ -45,7 +55,9 @@ const enableActionGroup = (
|
|||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
return (
|
||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||
|
@ -55,11 +67,13 @@ const enableActionGroup = (
|
|||
export const actionGroup = register({
|
||||
name: "group",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const selectedElements = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
);
|
||||
if (selectedElements.length < 2) {
|
||||
// nothing to group
|
||||
|
@ -86,9 +100,31 @@ export const actionGroup = register({
|
|||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
||||
// this includes the case where we are grouping elements inside a frame
|
||||
// and elements outside that frame
|
||||
const groupingElementsFromDifferentFrames =
|
||||
new Set(selectedElements.map((element) => element.frameId)).size > 1;
|
||||
// when it happens, we want to remove elements that are in the frame
|
||||
// and are going to be grouped from the frame (mouthful, I know)
|
||||
if (groupingElementsFromDifferentFrames) {
|
||||
const frameElementsMap = groupByFrames(selectedElements);
|
||||
|
||||
frameElementsMap.forEach((elementsInFrame, frameId) => {
|
||||
nextElements = removeElementsFromFrame(
|
||||
nextElements,
|
||||
elementsInFrame,
|
||||
appState,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const newGroupId = randomId();
|
||||
const selectElementIds = arrayToMap(selectedElements);
|
||||
const updatedElements = elements.map((element) => {
|
||||
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (!selectElementIds.get(element.id)) {
|
||||
return element;
|
||||
}
|
||||
|
@ -102,17 +138,16 @@ export const actionGroup = register({
|
|||
});
|
||||
// keep the z order within the group the same, but move them
|
||||
// to the z order of the highest element in the layer stack
|
||||
const elementsInGroup = getElementsInGroup(updatedElements, newGroupId);
|
||||
const elementsInGroup = getElementsInGroup(nextElements, newGroupId);
|
||||
const lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||
const lastGroupElementIndex =
|
||||
updatedElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = updatedElements
|
||||
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||
const elementsBeforeGroup = nextElements
|
||||
.slice(0, lastGroupElementIndex)
|
||||
.filter(
|
||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||
);
|
||||
const updatedElementsInOrder = [
|
||||
nextElements = [
|
||||
...elementsBeforeGroup,
|
||||
...elementsInGroup,
|
||||
...elementsAfterGroup,
|
||||
|
@ -122,9 +157,9 @@ export const actionGroup = register({
|
|||
appState: selectGroup(
|
||||
newGroupId,
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(updatedElementsInOrder),
|
||||
getNonDeletedElements(nextElements),
|
||||
),
|
||||
elements: updatedElementsInOrder,
|
||||
elements: nextElements,
|
||||
commitToHistory: true,
|
||||
};
|
||||
},
|
||||
|
@ -148,14 +183,23 @@ export const actionGroup = register({
|
|||
export const actionUngroup = register({
|
||||
name: "ungroup",
|
||||
trackEvent: { category: "element" },
|
||||
perform: (elements, appState) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const groupIds = getSelectedGroupIds(appState);
|
||||
if (groupIds.length === 0) {
|
||||
return { appState, elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
let nextElements = [...elements];
|
||||
|
||||
const selectedElements = getSelectedElements(nextElements, appState);
|
||||
const frames = selectedElements
|
||||
.filter((element) => element.frameId)
|
||||
.map((element) =>
|
||||
app.scene.getElement(element.frameId!),
|
||||
) as ExcalidrawFrameElement[];
|
||||
|
||||
const boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||
const nextElements = elements.map((element) => {
|
||||
nextElements = nextElements.map((element) => {
|
||||
if (isBoundToContainer(element)) {
|
||||
boundTextElementIds.push(element.id);
|
||||
}
|
||||
|
@ -174,15 +218,35 @@ export const actionUngroup = register({
|
|||
const updateAppState = selectGroupsForSelectedElements(
|
||||
{ ...appState, selectedGroupIds: {} },
|
||||
getNonDeletedElements(nextElements),
|
||||
appState,
|
||||
);
|
||||
|
||||
frames.forEach((frame) => {
|
||||
if (frame) {
|
||||
nextElements = replaceAllElementsInFrame(
|
||||
nextElements,
|
||||
getElementsInResizingFrame(nextElements, frame, appState),
|
||||
frame,
|
||||
appState,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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,
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
|
|
@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
|
|||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
|
||||
const editingLinearElement =
|
||||
|
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
|
|||
const selectedElement = getSelectedElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
true,
|
||||
{
|
||||
includeBoundTextElement: true,
|
||||
},
|
||||
)[0] as ExcalidrawLinearElement;
|
||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||
? "labels.lineEditor.exit"
|
||||
|
|
|
@ -67,7 +67,6 @@ export const actionFullScreen = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
||||
});
|
||||
|
||||
export const actionShortcuts = register({
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { getClientColors } from "../clients";
|
||||
import { getClientColor } from "../clients";
|
||||
import { Avatar } from "../components/Avatar";
|
||||
import { centerScrollOn } from "../scene/scroll";
|
||||
import { Collaborator } from "../types";
|
||||
|
@ -31,15 +31,14 @@ export const actionGoToCollaborator = register({
|
|||
commitToHistory: false,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, data }) => {
|
||||
PanelComponent: ({ updateData, data }) => {
|
||||
const [clientId, collaborator] = data as [string, Collaborator];
|
||||
|
||||
const { background, stroke } = getClientColors(clientId, appState);
|
||||
const background = getClientColor(clientId);
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
color={background}
|
||||
border={stroke}
|
||||
onClick={() => updateData(collaborator.pointer)}
|
||||
name={collaborator.username || ""}
|
||||
src={collaborator.avatarUrl}
|
||||
|
|
|
@ -102,8 +102,11 @@ const changeProperty = (
|
|||
includeBoundText = false,
|
||||
) => {
|
||||
const selectedElementIds = arrayToMap(
|
||||
getSelectedElements(elements, appState, includeBoundText),
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: includeBoundText,
|
||||
}),
|
||||
);
|
||||
|
||||
return elements.map((element) => {
|
||||
if (
|
||||
selectedElementIds.get(element.id) ||
|
||||
|
|
|
@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { isLinearElement } from "../element/typeChecks";
|
||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||
|
||||
export const actionSelectAll = register({
|
||||
name: "selectAll",
|
||||
|
@ -13,19 +14,18 @@ export const actionSelectAll = register({
|
|||
if (appState.editingLinearElement) {
|
||||
return false;
|
||||
}
|
||||
const selectedElementIds = elements.reduce(
|
||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
if (
|
||||
|
||||
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||
elements.filter(
|
||||
(element) =>
|
||||
!element.isDeleted &&
|
||||
!(isTextElement(element) && element.containerId) &&
|
||||
!element.locked
|
||||
) {
|
||||
map[element.id] = true;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
{},
|
||||
);
|
||||
!element.locked,
|
||||
),
|
||||
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||
map[element.id] = true;
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
appState: selectGroupsForSelectedElements(
|
||||
|
@ -41,6 +41,7 @@ export const actionSelectAll = register({
|
|||
selectedElementIds,
|
||||
},
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
),
|
||||
commitToHistory: true,
|
||||
};
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
hasBoundTextElement,
|
||||
canApplyRoundnessTypeToElement,
|
||||
getDefaultRoundnessTypeForElement,
|
||||
isFrameElement,
|
||||
} from "../element/typeChecks";
|
||||
import { getSelectedElements } from "../scene";
|
||||
|
||||
|
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
|
|||
return { elements, commitToHistory: false };
|
||||
}
|
||||
|
||||
const selectedElements = getSelectedElements(elements, appState, true);
|
||||
const selectedElements = getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
});
|
||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
|
@ -127,6 +130,13 @@ export const actionPasteStyles = register({
|
|||
});
|
||||
}
|
||||
|
||||
if (isFrameElement(element)) {
|
||||
newElement = newElementWith(newElement, {
|
||||
roundness: null,
|
||||
backgroundColor: "transparent",
|
||||
});
|
||||
}
|
||||
|
||||
return newElement;
|
||||
}
|
||||
return element;
|
||||
|
|
|
@ -82,7 +82,8 @@ export type ActionName =
|
|||
| "zoomOut"
|
||||
| "resetZoom"
|
||||
| "zoomToFit"
|
||||
| "zoomToSelection"
|
||||
| "zoomToFitSelection"
|
||||
| "zoomToFitSelectionInViewport"
|
||||
| "changeFontFamily"
|
||||
| "changeTextAlign"
|
||||
| "changeVerticalAlign"
|
||||
|
@ -116,6 +117,11 @@ export type ActionName =
|
|||
| "toggleLinearEditor"
|
||||
| "toggleEraserTool"
|
||||
| "toggleHandTool"
|
||||
| "selectAllElementsInFrame"
|
||||
| "removeAllElementsFromFrame"
|
||||
| "updateFrameRendering"
|
||||
| "setFrameAsActiveTool"
|
||||
| "createContainerFromText"
|
||||
| "wrapTextInContainer";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { ExcalidrawElement } from "./element/types";
|
||||
import { newElementWith } from "./element/mutateElement";
|
||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
||||
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||
import { getMaximumGroups } from "./groups";
|
||||
|
||||
export interface Alignment {
|
||||
|
@ -33,7 +33,7 @@ export const alignElements = (
|
|||
|
||||
const calculateTranslation = (
|
||||
group: ExcalidrawElement[],
|
||||
selectionBoundingBox: Box,
|
||||
selectionBoundingBox: BoundingBox,
|
||||
{ axis, position }: Alignment,
|
||||
): { x: number; y: number } => {
|
||||
const groupBoundingBox = getCommonBoundingBox(group);
|
||||
|
|
|
@ -5,6 +5,9 @@ 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 });
|
||||
|
||||
|
@ -12,12 +15,8 @@ export const trackEvent = (
|
|||
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);
|
||||
}
|
||||
|
|
|
@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
|
|||
scrollY: 0,
|
||||
selectedElementIds: {},
|
||||
selectedGroupIds: {},
|
||||
selectedElementsAreBeingDragged: false,
|
||||
selectionElement: null,
|
||||
shouldCacheIgnoreZoom: false,
|
||||
showStats: false,
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
frameRendering: { enabled: true, clip: true, name: true, outline: true },
|
||||
frameToHighlight: null,
|
||||
editingFrame: null,
|
||||
elementsToHighlight: null,
|
||||
toast: null,
|
||||
viewBackgroundColor: COLOR_PALETTE.white,
|
||||
zenModeEnabled: false,
|
||||
|
@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
|
|||
scrollY: { browser: true, export: false, server: false },
|
||||
selectedElementIds: { browser: true, export: false, server: false },
|
||||
selectedGroupIds: { browser: true, export: false, server: false },
|
||||
selectedElementsAreBeingDragged: {
|
||||
browser: false,
|
||||
export: false,
|
||||
server: false,
|
||||
},
|
||||
selectionElement: { browser: false, export: false, server: false },
|
||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||
showStats: { browser: true, export: false, server: false },
|
||||
startBoundElement: { browser: false, export: false, server: false },
|
||||
suggestedBindings: { browser: false, export: false, server: false },
|
||||
frameRendering: { browser: false, export: false, server: false },
|
||||
frameToHighlight: { browser: false, export: false, server: false },
|
||||
editingFrame: { browser: false, export: false, server: false },
|
||||
elementsToHighlight: { browser: false, export: false, server: false },
|
||||
toast: { browser: false, export: false, server: false },
|
||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||
width: { browser: false, export: false, server: false },
|
||||
|
|
|
@ -180,7 +180,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;
|
||||
|
@ -250,7 +250,7 @@ const chartLines = (
|
|||
groupId: string,
|
||||
backgroundColor: string,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
const xLine = newLinearElement({
|
||||
backgroundColor,
|
||||
groupIds: [groupId],
|
||||
|
@ -313,7 +313,7 @@ const chartBaseElements = (
|
|||
backgroundColor: string,
|
||||
debug?: boolean,
|
||||
): ChartElements => {
|
||||
const { chartWidth, chartHeight } = getChartDimentions(spreadsheet);
|
||||
const { chartWidth, chartHeight } = getChartDimensions(spreadsheet);
|
||||
|
||||
const title = spreadsheet.title
|
||||
? newTextElement({
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
import {
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
getAllColorsSpecificShade,
|
||||
} from "./colors";
|
||||
import { AppState } from "./types";
|
||||
|
||||
const BG_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_BACKGROUND_COLOR_INDEX,
|
||||
);
|
||||
const STROKE_COLORS = getAllColorsSpecificShade(
|
||||
DEFAULT_ELEMENT_STROKE_COLOR_INDEX,
|
||||
);
|
||||
|
||||
export const getClientColors = (clientId: string, appState: AppState) => {
|
||||
if (appState?.collaborators) {
|
||||
const currentUser = appState.collaborators.get(clientId);
|
||||
if (currentUser?.color) {
|
||||
return currentUser.color;
|
||||
}
|
||||
function hashToInteger(id: string) {
|
||||
let hash = 0;
|
||||
if (id.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
// Naive way of getting an integer out of the clientId
|
||||
const sum = clientId.split("").reduce((a, str) => a + str.charCodeAt(0), 0);
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const char = id.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
return {
|
||||
background: BG_COLORS[sum % BG_COLORS.length],
|
||||
stroke: STROKE_COLORS[sum % STROKE_COLORS.length],
|
||||
};
|
||||
export const getClientColor = (
|
||||
/**
|
||||
* any uniquely identifying key, such as user id or socket id
|
||||
*/
|
||||
id: string,
|
||||
) => {
|
||||
// to get more even distribution in case `id` is not uniformly distributed to
|
||||
// begin with, we hash it
|
||||
const hash = Math.abs(hashToInteger(id));
|
||||
// we want to get a multiple of 10 number in the range of 0-360 (in other
|
||||
// words a hue value of step size 10). There are 37 such values including 0.
|
||||
const hue = (hash % 37) * 10;
|
||||
const saturation = 100;
|
||||
const lightness = 83;
|
||||
|
||||
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
|
|||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||
import { isInitializedImageElement } from "./element/typeChecks";
|
||||
import { deepCopyElement } from "./element/newElement";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { getContainingFrame } from "./frame";
|
||||
import { isPromiseLike, isTestEnv } from "./utils";
|
||||
|
||||
type ElementsClipboard = {
|
||||
|
@ -57,6 +60,9 @@ export const copyToClipboard = async (
|
|||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
files: BinaryFiles | null,
|
||||
) => {
|
||||
const framesToCopy = new Set(
|
||||
elements.filter((element) => element.type === "frame"),
|
||||
);
|
||||
let foundFile = false;
|
||||
|
||||
const _files = elements.reduce((acc, element) => {
|
||||
|
@ -78,7 +84,20 @@ export const copyToClipboard = async (
|
|||
// select binded text elements when copying
|
||||
const contents: ElementsClipboard = {
|
||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
||||
elements,
|
||||
elements: elements.map((element) => {
|
||||
if (
|
||||
getContainingFrame(element) &&
|
||||
!framesToCopy.has(getContainingFrame(element)!)
|
||||
) {
|
||||
const copiedElement = deepCopyElement(element);
|
||||
mutateElement(copiedElement, {
|
||||
frameId: null,
|
||||
});
|
||||
return copiedElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
}),
|
||||
files: files ? _files : undefined,
|
||||
};
|
||||
const json = JSON.stringify(contents);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { ActionManager } from "../actions/manager";
|
||||
import { getNonDeletedElements } from "../element";
|
||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||
|
@ -35,6 +35,9 @@ import {
|
|||
} from "../element/textElement";
|
||||
|
||||
import "./Actions.scss";
|
||||
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||
import { extraToolsIcon, frameToolIcon } from "./icons";
|
||||
import { KEYS } from "../keys";
|
||||
|
||||
export const SelectedShapeActions = ({
|
||||
appState,
|
||||
|
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
|
|||
<div>
|
||||
{((hasStrokeColor(appState.activeTool.type) &&
|
||||
appState.activeTool.type !== "image" &&
|
||||
commonSelectedType !== "image") ||
|
||||
commonSelectedType !== "image" &&
|
||||
commonSelectedType !== "frame") ||
|
||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||
renderAction("changeStrokeColor")}
|
||||
</div>
|
||||
|
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
|
|||
setAppState: React.Component<any, UIAppState>["setState"];
|
||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||
appState: UIAppState;
|
||||
}) => (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
}) => {
|
||||
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||
const device = useDevice();
|
||||
return (
|
||||
<>
|
||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||
const label = t(`toolBar.${value}`);
|
||||
const letter =
|
||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||
const shortcut = letter
|
||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
||||
: `${numericKey}`;
|
||||
return (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
onPointerDown={({ pointerType }) => {
|
||||
if (!appState.penDetected && pointerType === "pen") {
|
||||
setAppState({
|
||||
penDetected: true,
|
||||
penMode: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="App-toolbar__divider" />
|
||||
{/* TEMP HACK because dropdown doesn't work well inside mobile toolbar */}
|
||||
{device.isMobile ? (
|
||||
<ToolButton
|
||||
className={clsx("Shape", { fillable })}
|
||||
key={value}
|
||||
className={clsx("Shape", { fillable: false })}
|
||||
type="radio"
|
||||
icon={icon}
|
||||
checked={activeTool.type === value}
|
||||
icon={frameToolIcon}
|
||||
checked={activeTool.type === "frame"}
|
||||
name="editor-current-shape"
|
||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
||||
keyBindingLabel={numericKey || letter}
|
||||
aria-label={capitalizeString(label)}
|
||||
aria-keyshortcuts={shortcut}
|
||||
data-testid={`toolbar-${value}`}
|
||||
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({
|
||||
|
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
|
|||
}
|
||||
}}
|
||||
onChange={({ pointerType }) => {
|
||||
if (appState.activeTool.type !== value) {
|
||||
trackEvent("toolbar", value, "ui");
|
||||
}
|
||||
trackEvent("toolbar", "frame", "ui");
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: value,
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
setCursorForShape(canvas, {
|
||||
...appState,
|
||||
activeTool: nextActiveTool,
|
||||
});
|
||||
if (value === "image") {
|
||||
onImageAction({ pointerType });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
) : (
|
||||
<DropdownMenu open={isExtraToolsMenuOpen}>
|
||||
<DropdownMenu.Trigger
|
||||
className="App-toolbar__extra-tools-trigger"
|
||||
onToggle={() => setIsExtraToolsMenuOpen(!isExtraToolsMenuOpen)}
|
||||
title={t("toolBar.extraTools")}
|
||||
>
|
||||
{extraToolsIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
onClickOutside={() => setIsExtraToolsMenuOpen(false)}
|
||||
onSelect={() => setIsExtraToolsMenuOpen(false)}
|
||||
className="App-toolbar__extra-tools-dropdown"
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
const nextActiveTool = updateActiveTool(appState, {
|
||||
type: "frame",
|
||||
});
|
||||
setAppState({
|
||||
activeTool: nextActiveTool,
|
||||
multiElement: null,
|
||||
selectedElementIds: {},
|
||||
});
|
||||
}}
|
||||
icon={frameToolIcon}
|
||||
shortcut={KEYS.F.toLocaleUpperCase()}
|
||||
data-testid="toolbar-frame"
|
||||
>
|
||||
{t("toolBar.frame")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ZoomActions = ({
|
||||
renderAction,
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -10,10 +10,9 @@
|
|||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: $oc-white;
|
||||
cursor: pointer;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
|
||||
&-img {
|
||||
|
|
|
@ -6,7 +6,6 @@ import { getNameInitial } from "../clients";
|
|||
type AvatarProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
|
||||
color: string;
|
||||
border: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
};
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { isTransparent, isWritableElement } from "../../utils";
|
||||
import { isInteractive, isTransparent, isWritableElement } from "../../utils";
|
||||
import { ExcalidrawElement } from "../../element/types";
|
||||
import { AppState } from "../../types";
|
||||
import { TopPicks } from "./TopPicks";
|
||||
|
@ -121,11 +121,14 @@ const ColorPickerPopupContent = ({
|
|||
}
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// prevents focusing the trigger
|
||||
e.preventDefault();
|
||||
|
||||
// return focus to excalidraw container
|
||||
if (container) {
|
||||
// return focus to excalidraw container unless
|
||||
// user focuses an interactive element, such as a button, or
|
||||
// enters the text editor by clicking on canvas with the text tool
|
||||
if (container && !isInteractive(document.activeElement)) {
|
||||
container.focus();
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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"
|
||||
>
|
||||
|
@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||
label={t("toolBar.eraser")}
|
||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||
/>
|
||||
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||
<Shortcut
|
||||
label={t("labels.eyeDropper")}
|
||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||
|
|
|
@ -84,7 +84,10 @@ const ImageExportModal = ({
|
|||
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||
|
||||
const exportedElements = exportSelected
|
||||
? getSelectedElements(elements, appState, true)
|
||||
? getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
})
|
||||
: elements;
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
@ -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";
|
||||
|
@ -99,6 +100,15 @@ const DefaultMainMenu: React.FC<{
|
|||
);
|
||||
};
|
||||
|
||||
const DefaultOverwriteConfirmDialog = () => {
|
||||
return (
|
||||
<OverwriteConfirmDialog __fallback>
|
||||
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
||||
<OverwriteConfirmDialog.Actions.ExportToImage />
|
||||
</OverwriteConfirmDialog>
|
||||
);
|
||||
};
|
||||
|
||||
const LayerUI = ({
|
||||
actionManager,
|
||||
appState,
|
||||
|
@ -204,12 +214,7 @@ const LayerUI = ({
|
|||
return (
|
||||
<FixedSideContainer side="top">
|
||||
<div className="App-menu App-menu_top">
|
||||
<Stack.Col
|
||||
gap={6}
|
||||
className={clsx("App-menu_top__left", {
|
||||
"disable-pointerEvents": appState.zenModeEnabled,
|
||||
})}
|
||||
>
|
||||
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||
{renderCanvasActions()}
|
||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||
</Stack.Col>
|
||||
|
@ -254,7 +259,7 @@ const LayerUI = ({
|
|||
title={t("toolBar.lock")}
|
||||
/>
|
||||
|
||||
<div className="App-toolbar__divider"></div>
|
||||
<div className="App-toolbar__divider" />
|
||||
|
||||
<HandButton
|
||||
checked={isHandToolActive(appState)}
|
||||
|
@ -348,6 +353,7 @@ const LayerUI = ({
|
|||
>
|
||||
{t("toolBar.library")}
|
||||
</DefaultSidebar.Trigger>
|
||||
<DefaultOverwriteConfirmDialog />
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
{appState.isLoading && <LoadingMessage delay={250} />}
|
||||
|
@ -379,6 +385,7 @@ const LayerUI = ({
|
|||
/>
|
||||
)}
|
||||
<ActiveConfirmDialog />
|
||||
<tunnels.OverwriteConfirmDialogTunnel.Out />
|
||||
{renderImageExportDialog()}
|
||||
{renderJSONExportDialog()}
|
||||
{appState.pasteDialog.shown && (
|
||||
|
@ -392,7 +399,7 @@ const LayerUI = ({
|
|||
}
|
||||
/>
|
||||
)}
|
||||
{device.isMobile && !eyeDropperState && (
|
||||
{device.isMobile && (
|
||||
<MobileMenu
|
||||
appState={appState}
|
||||
elements={elements}
|
||||
|
|
|
@ -148,7 +148,11 @@ const usePendingElementsMemo = (
|
|||
appState: UIAppState,
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
) => {
|
||||
const create = () => getSelectedElements(elements, appState, true);
|
||||
const create = () =>
|
||||
getSelectedElements(elements, appState, {
|
||||
includeBoundTextElement: true,
|
||||
includeElementsInFrames: true,
|
||||
});
|
||||
const val = useRef(create());
|
||||
const prevAppState = useRef<UIAppState>(appState);
|
||||
const prevElements = useRef(elements);
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
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>
|
||||
);
|
||||
};
|
|
@ -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", {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import "./ToolIcon.scss";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { useExcalidrawContainer } from "./App";
|
||||
import { AbortError } from "../errors";
|
||||
|
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
|||
visible?: boolean;
|
||||
selected?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
|
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||
"ToolIcon--plain": props.type === "icon",
|
||||
},
|
||||
)}
|
||||
style={props.style}
|
||||
data-testid={props["data-testid"]}
|
||||
hidden={props.hidden}
|
||||
title={props.title}
|
||||
|
|
|
@ -15,7 +15,24 @@
|
|||
height: 1.5rem;
|
||||
align-self: center;
|
||||
background-color: var(--default-border-color);
|
||||
margin: 0 0.5rem;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-trigger {
|
||||
box-shadow: none;
|
||||
border: 0;
|
||||
|
||||
&:active {
|
||||
background-color: var(--button-hover-bg);
|
||||
box-shadow: 0 0 0 1px
|
||||
var(--button-active-border, var(--color-primary-darkest)) inset;
|
||||
}
|
||||
}
|
||||
|
||||
.App-toolbar__extra-tools-dropdown {
|
||||
margin-top: 0.375rem;
|
||||
right: 0;
|
||||
min-width: 11.875rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,23 +1,23 @@
|
|||
import clsx from "clsx";
|
||||
import { useUIAppState } from "../../context/ui-appState";
|
||||
import { useDevice } from "../App";
|
||||
|
||||
const MenuTrigger = ({
|
||||
className = "",
|
||||
children,
|
||||
onToggle,
|
||||
title,
|
||||
...rest
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
onToggle: () => void;
|
||||
}) => {
|
||||
const appState = useUIAppState();
|
||||
title?: string;
|
||||
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||
const device = useDevice();
|
||||
const classNames = clsx(
|
||||
`dropdown-menu-button ${className}`,
|
||||
"zen-mode-transition",
|
||||
{
|
||||
"transition-left": appState.zenModeEnabled,
|
||||
"dropdown-menu-button--mobile": device.isMobile,
|
||||
},
|
||||
).trim();
|
||||
|
@ -28,6 +28,8 @@ const MenuTrigger = ({
|
|||
onClick={onToggle}
|
||||
type="button"
|
||||
data-testid="dropdown-menu-button"
|
||||
title={title}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
|
|
@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
|
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
|
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
|
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
|
|||
</div>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
||||
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||
|
||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||
".excalidraw-container",
|
||||
);
|
||||
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
expect(
|
||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
||||
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1608,6 +1608,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>
|
||||
|
@ -1616,3 +1626,24 @@ export const eyeDropperIcon = createIcon(
|
|||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const extraToolsIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M12 3l-4 7h8z"></path>
|
||||
<path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
|
||||
<path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"></path>
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const frameToolIcon = createIcon(
|
||||
<g strokeWidth={1.5}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||
<path d="M4 7l16 0"></path>
|
||||
<path d="M4 17l16 0"></path>
|
||||
<path d="M7 4l0 16"></path>
|
||||
<path d="M17 4l0 16"></path>
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
|
|
@ -1,6 +1,10 @@
|
|||
import { getShortcutFromShortcutName } from "../../actions/shortcuts";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useExcalidrawSetAppState, useExcalidrawActionManager } from "../App";
|
||||
import {
|
||||
useExcalidrawSetAppState,
|
||||
useExcalidrawActionManager,
|
||||
useExcalidrawElements,
|
||||
} from "../App";
|
||||
import {
|
||||
ExportIcon,
|
||||
ExportImageIcon,
|
||||
|
@ -29,19 +33,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")}
|
||||
|
|
|
@ -42,6 +42,7 @@ const MainMenu = Object.assign(
|
|||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||
});
|
||||
}}
|
||||
data-testid="main-menu-trigger"
|
||||
>
|
||||
{HamburgerMenuIcon}
|
||||
</DropdownMenu.Trigger>
|
||||
|
|
|
@ -94,6 +94,17 @@ export const THEME = {
|
|||
DARK: "dark",
|
||||
};
|
||||
|
||||
export const FRAME_STYLE = {
|
||||
strokeColor: "#bbb" as ExcalidrawElement["strokeColor"],
|
||||
strokeWidth: 1 as ExcalidrawElement["strokeWidth"],
|
||||
strokeStyle: "solid" as ExcalidrawElement["strokeStyle"],
|
||||
fillStyle: "solid" as ExcalidrawElement["fillStyle"],
|
||||
roughness: 0 as ExcalidrawElement["roughness"],
|
||||
roundness: null as ExcalidrawElement["roundness"],
|
||||
backgroundColor: "transparent" as ExcalidrawElement["backgroundColor"],
|
||||
radius: 8,
|
||||
};
|
||||
|
||||
export const WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||
|
||||
export const DEFAULT_FONT_SIZE = 20;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,6 +45,7 @@ import {
|
|||
ExcalidrawProgrammaticAPI,
|
||||
convertToExcalidrawElements,
|
||||
} from "../data/transform";
|
||||
import { normalizeLink } from "./url";
|
||||
|
||||
type RestoredAppState = Omit<
|
||||
AppState,
|
||||
|
@ -66,6 +67,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||
freedraw: true,
|
||||
eraser: false,
|
||||
custom: true,
|
||||
frame: true,
|
||||
hand: true,
|
||||
};
|
||||
|
||||
|
@ -131,6 +133,7 @@ export const restoreElementWithProperties = <
|
|||
height: element.height || 0,
|
||||
seed: element.seed ?? 1,
|
||||
groupIds: element.groupIds ?? [],
|
||||
frameId: element.frameId ?? null,
|
||||
roundness: element.roundness
|
||||
? element.roundness
|
||||
: element.strokeSharpness === "round"
|
||||
|
@ -146,7 +149,7 @@ export 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,
|
||||
};
|
||||
|
||||
|
@ -276,6 +279,10 @@ const restoreElement = (
|
|||
return restoreElementWithProperties(element, {});
|
||||
case "diamond":
|
||||
return restoreElementWithProperties(element, {});
|
||||
case "frame":
|
||||
return restoreElementWithProperties(element, {
|
||||
name: element.name ?? null,
|
||||
});
|
||||
|
||||
// Don't use default case so as to catch a missing an element type case.
|
||||
// We also don't want to throw, but instead return void so we filter
|
||||
|
@ -368,6 +375,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: ExcalidrawProgrammaticAPI["elements"],
|
||||
/** NOTE doesn't serve for reconciliation */
|
||||
|
@ -415,6 +440,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>");
|
||||
});
|
||||
});
|
9
src/data/url.ts
Normal file
9
src/data/url.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { sanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
export const normalizeLink = (link: string) => {
|
||||
return sanitizeUrl(link);
|
||||
};
|
||||
|
||||
export const isLocalLink = (link: string | null) => {
|
||||
return !!(link?.includes(location.origin) || link?.startsWith("/"));
|
||||
};
|
|
@ -29,6 +29,7 @@ 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";
|
||||
|
@ -166,7 +167,7 @@ export const Hyperlink = ({
|
|||
/>
|
||||
) : (
|
||||
<a
|
||||
href={element.link || ""}
|
||||
href={normalizeLink(element.link || "")}
|
||||
className={clsx("excalidraw-hyperlinkContainer-link", {
|
||||
"d-none": isEditing,
|
||||
})}
|
||||
|
@ -177,7 +178,13 @@ export const Hyperlink = ({
|
|||
EVENT.EXCALIDRAW_LINK,
|
||||
event.nativeEvent,
|
||||
);
|
||||
onLinkOpen(element, customEvent);
|
||||
onLinkOpen(
|
||||
{
|
||||
...element,
|
||||
link: normalizeLink(element.link),
|
||||
},
|
||||
customEvent,
|
||||
);
|
||||
if (customEvent.defaultPrevented) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
@ -231,21 +238,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) => {
|
||||
|
@ -344,7 +336,7 @@ export const isPointHittingLinkIcon = (
|
|||
if (
|
||||
!isMobile &&
|
||||
appState.viewModeEnabled &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
@ -440,7 +432,9 @@ export const shouldHideLinkPopup = (
|
|||
|
||||
const threshold = 15 / appState.zoom.value;
|
||||
// hitbox to prevent hiding when hovered in element bounding box
|
||||
if (isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold)) {
|
||||
if (
|
||||
isPointHittingElementBoundingBox(element, [sceneX, sceneY], threshold, null)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
||||
|
|
|
@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
|
|||
];
|
||||
|
||||
export const shouldEnableBindingForPointerEvent = (
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
return !event[KEYS.CTRL_OR_CMD];
|
||||
};
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
NonDeleted,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
} from "./types";
|
||||
import { distance2d, rotate } from "../math";
|
||||
import { distance2d, rotate, rotatePoint } from "../math";
|
||||
import rough from "roughjs/bin/rough";
|
||||
import { Drawable, Op } from "roughjs/bin/core";
|
||||
import { Point } from "../types";
|
||||
|
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||
import { LinearElementEditor } from "./linearElementEditor";
|
||||
import { Mutable } from "../utility-types";
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [number, number, number, number];
|
||||
export type RectangleBox = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
angle: number;
|
||||
};
|
||||
|
||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
||||
|
||||
// x and y position of top left corner, x and y position of bottom right corner
|
||||
export type Bounds = readonly [x1: number, y1: number, x2: number, y2: number];
|
||||
|
||||
export class ElementBounds {
|
||||
private static boundsCache = new WeakMap<
|
||||
ExcalidrawElement,
|
||||
{
|
||||
bounds: Bounds;
|
||||
version: ExcalidrawElement["version"];
|
||||
}
|
||||
>();
|
||||
|
||||
static getBounds(element: ExcalidrawElement) {
|
||||
const cachedBounds = ElementBounds.boundsCache.get(element);
|
||||
|
||||
if (cachedBounds?.version && cachedBounds.version === element.version) {
|
||||
return cachedBounds.bounds;
|
||||
}
|
||||
|
||||
const bounds = ElementBounds.calculateBounds(element);
|
||||
|
||||
ElementBounds.boundsCache.set(element, {
|
||||
version: element.version,
|
||||
bounds,
|
||||
});
|
||||
|
||||
return bounds;
|
||||
}
|
||||
|
||||
private static calculateBounds(element: ExcalidrawElement): Bounds {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
return bounds;
|
||||
}
|
||||
}
|
||||
|
||||
// Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
//
|
||||
// If the element is created from right to left, the width is going to be negative
|
||||
// This set of functions retrieves the absolute position of the 4 points.
|
||||
export const getElementAbsoluteCoords = (
|
||||
|
@ -69,6 +160,111 @@ export const getElementAbsoluteCoords = (
|
|||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* for a given element, `getElementLineSegments` returns line segments
|
||||
* that can be used for visual collision detection (useful for frames)
|
||||
* as opposed to bounding box collision detection
|
||||
*/
|
||||
export const getElementLineSegments = (
|
||||
element: ExcalidrawElement,
|
||||
): [Point, Point][] => {
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
|
||||
const center: Point = [cx, cy];
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
const segments: [Point, Point][] = [];
|
||||
|
||||
let i = 0;
|
||||
|
||||
while (i < element.points.length - 1) {
|
||||
segments.push([
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i][0] + element.x,
|
||||
element.points[i][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
rotatePoint(
|
||||
[
|
||||
element.points[i + 1][0] + element.x,
|
||||
element.points[i + 1][1] + element.y,
|
||||
] as Point,
|
||||
center,
|
||||
element.angle,
|
||||
),
|
||||
]);
|
||||
i++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
const [nw, ne, sw, se, n, s, w, e] = (
|
||||
[
|
||||
[x1, y1],
|
||||
[x2, y1],
|
||||
[x1, y2],
|
||||
[x2, y2],
|
||||
[cx, y1],
|
||||
[cx, y2],
|
||||
[x1, cy],
|
||||
[x2, cy],
|
||||
] as Point[]
|
||||
).map((point) => rotatePoint(point, center, element.angle));
|
||||
|
||||
if (element.type === "diamond") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
if (element.type === "ellipse") {
|
||||
return [
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
[n, w],
|
||||
[n, e],
|
||||
[s, w],
|
||||
[s, e],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
[nw, ne],
|
||||
[sw, se],
|
||||
[nw, sw],
|
||||
[ne, se],
|
||||
[nw, e],
|
||||
[sw, e],
|
||||
[ne, w],
|
||||
[se, w],
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* Scene -> Scene coords, but in x1,x2,y1,y2 format.
|
||||
*
|
||||
* Rectangle here means any rectangular frame, not an excalidraw element.
|
||||
*/
|
||||
export const getRectangleBoxAbsoluteCoords = (boxSceneCoords: RectangleBox) => {
|
||||
return [
|
||||
boxSceneCoords.x,
|
||||
boxSceneCoords.y,
|
||||
boxSceneCoords.x + boxSceneCoords.width,
|
||||
boxSceneCoords.y + boxSceneCoords.height,
|
||||
boxSceneCoords.x + boxSceneCoords.width / 2,
|
||||
boxSceneCoords.y + boxSceneCoords.height / 2,
|
||||
];
|
||||
};
|
||||
|
||||
export const pointRelativeTo = (
|
||||
element: ExcalidrawElement,
|
||||
absoluteCoords: Point,
|
||||
|
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
|
|||
return coords;
|
||||
};
|
||||
|
||||
// We could cache this stuff
|
||||
export const getElementBounds = (
|
||||
element: ExcalidrawElement,
|
||||
): [number, number, number, number] => {
|
||||
let bounds: [number, number, number, number];
|
||||
|
||||
const [x1, y1, x2, y2, cx, cy] = getElementAbsoluteCoords(element);
|
||||
if (isFreeDrawElement(element)) {
|
||||
const [minX, minY, maxX, maxY] = getBoundsFromPoints(
|
||||
element.points.map(([x, y]) =>
|
||||
rotate(x, y, cx - element.x, cy - element.y, element.angle),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
minX + element.x,
|
||||
minY + element.y,
|
||||
maxX + element.x,
|
||||
maxY + element.y,
|
||||
];
|
||||
} else if (isLinearElement(element)) {
|
||||
bounds = getLinearElementRotatedBounds(element, cx, cy);
|
||||
} else if (element.type === "diamond") {
|
||||
const [x11, y11] = rotate(cx, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(cx, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x1, cy, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, cy, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
} else if (element.type === "ellipse") {
|
||||
const w = (x2 - x1) / 2;
|
||||
const h = (y2 - y1) / 2;
|
||||
const cos = Math.cos(element.angle);
|
||||
const sin = Math.sin(element.angle);
|
||||
const ww = Math.hypot(w * cos, h * sin);
|
||||
const hh = Math.hypot(h * cos, w * sin);
|
||||
bounds = [cx - ww, cy - hh, cx + ww, cy + hh];
|
||||
} else {
|
||||
const [x11, y11] = rotate(x1, y1, cx, cy, element.angle);
|
||||
const [x12, y12] = rotate(x1, y2, cx, cy, element.angle);
|
||||
const [x22, y22] = rotate(x2, y2, cx, cy, element.angle);
|
||||
const [x21, y21] = rotate(x2, y1, cx, cy, element.angle);
|
||||
const minX = Math.min(x11, x12, x22, x21);
|
||||
const minY = Math.min(y11, y12, y22, y21);
|
||||
const maxX = Math.max(x11, x12, x22, x21);
|
||||
const maxY = Math.max(y11, y12, y22, y21);
|
||||
bounds = [minX, minY, maxX, maxY];
|
||||
}
|
||||
|
||||
return bounds;
|
||||
export const getElementBounds = (element: ExcalidrawElement): Bounds => {
|
||||
return ElementBounds.getBounds(element);
|
||||
};
|
||||
|
||||
export const getCommonBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
|
|||
export const getClosestElementBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
from: { x: number; y: number },
|
||||
): [number, number, number, number] => {
|
||||
): Bounds => {
|
||||
if (!elements.length) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
|
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
|
|||
return getElementBounds(closestElement);
|
||||
};
|
||||
|
||||
export interface Box {
|
||||
export interface BoundingBox {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
|
@ -642,7 +786,7 @@ export interface Box {
|
|||
|
||||
export const getCommonBoundingBox = (
|
||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||
): Box => {
|
||||
): BoundingBox => {
|
||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||
return {
|
||||
minX,
|
||||
|
|
|
@ -26,10 +26,16 @@ import {
|
|||
ExcalidrawImageElement,
|
||||
ExcalidrawLinearElement,
|
||||
StrokeRoundness,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
|
||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
||||
import { Point } from "../types";
|
||||
import {
|
||||
getElementAbsoluteCoords,
|
||||
getCurvePathOps,
|
||||
getRectangleBoxAbsoluteCoords,
|
||||
RectangleBox,
|
||||
} from "./bounds";
|
||||
import { FrameNameBoundsCache, Point } from "../types";
|
||||
import { Drawable } from "roughjs/bin/core";
|
||||
import { AppState } from "../types";
|
||||
import { getShapeForElement } from "../renderer/renderElement";
|
||||
|
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
|
|||
export const hitTest = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
|
@ -72,22 +79,39 @@ export const hitTest = (
|
|||
isElementSelected(appState, element) &&
|
||||
shouldShowBoundingBox([element], appState)
|
||||
) {
|
||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
||||
return isPointHittingElementBoundingBox(
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
);
|
||||
}
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
|
||||
const isHittingBoundTextElement = hitTest(
|
||||
boundTextElement,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
x,
|
||||
y,
|
||||
);
|
||||
if (isHittingBoundTextElement) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
||||
return isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
point,
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache,
|
||||
x: number,
|
||||
y: number,
|
||||
): boolean => {
|
||||
|
@ -96,19 +120,33 @@ export const isHittingElementBoundingBoxWithoutHittingElement = (
|
|||
// So that bound text element hit is considered within bounding box of container even if its outside actual bounding box of element
|
||||
// eg for linear elements text can be outside the element bounding box
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
|
||||
if (
|
||||
boundTextElement &&
|
||||
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
||||
!isHittingElementNotConsideringBoundingBox(
|
||||
element,
|
||||
appState,
|
||||
frameNameBoundsCache,
|
||||
[x, y],
|
||||
) &&
|
||||
isPointHittingElementBoundingBox(
|
||||
element,
|
||||
[x, y],
|
||||
threshold,
|
||||
frameNameBoundsCache,
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const isHittingElementNotConsideringBoundingBox = (
|
||||
element: NonDeletedExcalidrawElement,
|
||||
appState: AppState,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
point: Point,
|
||||
): boolean => {
|
||||
const threshold = 10 / appState.zoom.value;
|
||||
|
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
|
|||
: isElementDraggableFromInside(element)
|
||||
? isInsideCheck
|
||||
: isNearCheck;
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
};
|
||||
|
||||
const isElementSelected = (
|
||||
|
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
|
|||
element: NonDeleted<ExcalidrawElement>,
|
||||
[x, y]: Point,
|
||||
threshold: number,
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||
) => {
|
||||
// frames needs be checked differently so as to be able to drag it
|
||||
// by its frame, whether it has been selected or not
|
||||
// this logic here is not ideal
|
||||
// TODO: refactor it later...
|
||||
if (element.type === "frame") {
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point: [x, y],
|
||||
threshold,
|
||||
check: isInsideCheck,
|
||||
frameNameBoundsCache,
|
||||
});
|
||||
}
|
||||
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCenterX = (x1 + x2) / 2;
|
||||
const elementCenterY = (y1 + y2) / 2;
|
||||
|
@ -157,7 +216,13 @@ export const bindingBorderTest = (
|
|||
const threshold = maxBindingGap(element, element.width, element.height);
|
||||
const check = isOutsideCheck;
|
||||
const point: Point = [x, y];
|
||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
||||
return hitTestPointAgainstElement({
|
||||
element,
|
||||
point,
|
||||
threshold,
|
||||
check,
|
||||
frameNameBoundsCache: null,
|
||||
});
|
||||
};
|
||||
|
||||
export const maxBindingGap = (
|
||||
|
@ -177,6 +242,7 @@ type HitTestArgs = {
|
|||
point: Point;
|
||||
threshold: number;
|
||||
check: (distance: number, threshold: number) => boolean;
|
||||
frameNameBoundsCache: FrameNameBoundsCache | null;
|
||||
};
|
||||
|
||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
||||
|
@ -208,6 +274,27 @@ const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
|||
"This should not happen, we need to investigate why it does.",
|
||||
);
|
||||
return false;
|
||||
case "frame": {
|
||||
// check distance to frame element first
|
||||
if (
|
||||
args.check(
|
||||
distanceToBindableElement(args.element, args.point),
|
||||
args.threshold,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const frameNameBounds = args.frameNameBoundsCache?.get(args.element);
|
||||
|
||||
if (frameNameBounds) {
|
||||
return args.check(
|
||||
distanceToRectangleBox(frameNameBounds, args.point),
|
||||
args.threshold,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -219,6 +306,7 @@ export const distanceToBindableElement = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return distanceToRectangle(element, point);
|
||||
case "diamond":
|
||||
return distanceToDiamond(element, point);
|
||||
|
@ -248,7 +336,8 @@ const distanceToRectangle = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement,
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement,
|
||||
point: Point,
|
||||
): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
||||
|
@ -258,6 +347,14 @@ const distanceToRectangle = (
|
|||
);
|
||||
};
|
||||
|
||||
const distanceToRectangleBox = (box: RectangleBox, point: Point): number => {
|
||||
const [, pointRel, hwidth, hheight] = pointRelativeToDivElement(point, box);
|
||||
return Math.max(
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(0, 1, -hheight)),
|
||||
GAPoint.distanceToLine(pointRel, GALine.equation(1, 0, -hwidth)),
|
||||
);
|
||||
};
|
||||
|
||||
const distanceToDiamond = (
|
||||
element: ExcalidrawDiamondElement,
|
||||
point: Point,
|
||||
|
@ -457,8 +554,7 @@ const pointRelativeToElement = (
|
|||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const elementCoords = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
|
@ -466,9 +562,26 @@ const pointRelativeToElement = (
|
|||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(element.x, element.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const [ax, ay, bx, by] = elementCoords;
|
||||
const halfWidth = (bx - ax) / 2;
|
||||
const halfHeight = (by - ay) / 2;
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
const pointRelativeToDivElement = (
|
||||
pointTuple: Point,
|
||||
rectangle: RectangleBox,
|
||||
): [GA.Point, GA.Point, number, number] => {
|
||||
const point = GAPoint.from(pointTuple);
|
||||
const [x1, y1, x2, y2] = getRectangleBoxAbsoluteCoords(rectangle);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
const rotate = GATransform.rotation(center, rectangle.angle);
|
||||
const pointRotated = GATransform.apply(rotate, point);
|
||||
const pointRelToCenter = GA.sub(pointRotated, GADirection.from(center));
|
||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||
const elementPos = GA.offset(rectangle.x, rectangle.y);
|
||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||
const halfWidth = (x2 - x1) / 2;
|
||||
const halfHeight = (y2 - y1) / 2;
|
||||
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||
};
|
||||
|
||||
|
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
|
|||
element: ExcalidrawElement,
|
||||
): GA.Transform => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
// GA has angle orientation opposite to `rotate`
|
||||
const rotate = GATransform.rotation(center, element.angle);
|
||||
const translate = GA.reverse(
|
||||
|
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
|
|||
return GATransform.compose(rotate, translate);
|
||||
};
|
||||
|
||||
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
||||
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
||||
const coordsCenter = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
): GA.Point => {
|
||||
return GA.point((x1 + x2) / 2, (y1 + y2) / 2);
|
||||
};
|
||||
|
||||
// The focus distance is the oriented ratio between the size of
|
||||
|
@ -531,6 +649,7 @@ export const determineFocusDistance = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return c / (hwidth * (nabs + q * mabs));
|
||||
case "diamond":
|
||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||
|
@ -548,7 +667,7 @@ export const determineFocusPoint = (
|
|||
): Point => {
|
||||
if (focus === 0) {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const center = coordsCenter([x1, y1, x2, y2]);
|
||||
const center = coordsCenter(x1, y1, x2, y2);
|
||||
return GAPoint.toTuple(center);
|
||||
}
|
||||
const relateToCenter = relativizationToElementCenter(element);
|
||||
|
@ -563,6 +682,7 @@ export const determineFocusPoint = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||
break;
|
||||
case "ellipse":
|
||||
|
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
|
|||
case "image":
|
||||
case "text":
|
||||
case "diamond":
|
||||
case "frame":
|
||||
const corners = getCorners(element);
|
||||
intersections = corners
|
||||
.flatMap((point, i) => {
|
||||
|
@ -646,7 +767,8 @@ const getCorners = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
scale: number = 1,
|
||||
): GA.Point[] => {
|
||||
const hx = (scale * element.width) / 2;
|
||||
|
@ -655,6 +777,7 @@ const getCorners = (
|
|||
case "rectangle":
|
||||
case "image":
|
||||
case "text":
|
||||
case "frame":
|
||||
return [
|
||||
GA.point(hx, hy),
|
||||
GA.point(hx, -hy),
|
||||
|
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
|
|||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawTextElement,
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawFrameElement,
|
||||
// Between -1 and 1 for how far away should the focus point be relative
|
||||
// to the size of the element. Sign determines orientation.
|
||||
relativeDistance: number,
|
||||
|
|
|
@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
|
|||
import { AppState, PointerDownState } from "../types";
|
||||
import { getBoundTextElement } from "./textElement";
|
||||
import { isSelectedViaGroup } from "../groups";
|
||||
import Scene from "../scene/Scene";
|
||||
import { isFrameElement } from "./typeChecks";
|
||||
|
||||
export const dragSelectedElements = (
|
||||
pointerDownState: PointerDownState,
|
||||
|
@ -16,10 +18,31 @@ export const dragSelectedElements = (
|
|||
distanceX: number = 0,
|
||||
distanceY: number = 0,
|
||||
appState: AppState,
|
||||
scene: Scene,
|
||||
) => {
|
||||
const [x1, y1] = getCommonBounds(selectedElements);
|
||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
||||
selectedElements.forEach((element) => {
|
||||
|
||||
// we do not want a frame and its elements to be selected at the same time
|
||||
// but when it happens (due to some bug), we want to avoid updating element
|
||||
// in the frame twice, hence the use of set
|
||||
const elementsToUpdate = new Set<NonDeletedExcalidrawElement>(
|
||||
selectedElements,
|
||||
);
|
||||
const frames = selectedElements
|
||||
.filter((e) => isFrameElement(e))
|
||||
.map((f) => f.id);
|
||||
|
||||
if (frames.length > 0) {
|
||||
const elementsInFrames = scene
|
||||
.getNonDeletedElements()
|
||||
.filter((e) => e.frameId !== null)
|
||||
.filter((e) => frames.includes(e.frameId!));
|
||||
|
||||
elementsInFrames.forEach((element) => elementsToUpdate.add(element));
|
||||
}
|
||||
|
||||
elementsToUpdate.forEach((element) => {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
|
@ -38,7 +61,13 @@ export const dragSelectedElements = (
|
|||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||
) {
|
||||
const textElement = getBoundTextElement(element);
|
||||
if (textElement) {
|
||||
if (
|
||||
textElement &&
|
||||
// when container is added to a frame, so will its bound text
|
||||
// so the text is already in `elementsToUpdate` and we should avoid
|
||||
// updating its coords again
|
||||
(!textElement.frameId || !frames.includes(textElement.frameId))
|
||||
) {
|
||||
updateElementCoords(
|
||||
lockDirection,
|
||||
distanceX,
|
||||
|
@ -50,7 +79,7 @@ export const dragSelectedElements = (
|
|||
}
|
||||
}
|
||||
updateBoundElements(element, {
|
||||
simultaneouslyUpdated: selectedElements,
|
||||
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import {
|
|||
ExcalidrawElement,
|
||||
NonDeletedExcalidrawElement,
|
||||
NonDeleted,
|
||||
ExcalidrawFrameElement,
|
||||
} from "./types";
|
||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||
import { isLinearElementType } from "./typeChecks";
|
||||
|
@ -49,7 +50,11 @@ export {
|
|||
getDragOffsetXY,
|
||||
dragNewElement,
|
||||
} from "./dragElements";
|
||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
||||
export {
|
||||
isTextElement,
|
||||
isExcalidrawElement,
|
||||
isFrameElement,
|
||||
} from "./typeChecks";
|
||||
export { textWysiwyg } from "./textWysiwyg";
|
||||
export { redrawTextBoundingBox } from "./textElement";
|
||||
export {
|
||||
|
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
|||
(element) => !element.isDeleted,
|
||||
) as readonly NonDeletedExcalidrawElement[];
|
||||
|
||||
export const getNonDeletedFrames = (
|
||||
frames: readonly ExcalidrawFrameElement[],
|
||||
) =>
|
||||
frames.filter(
|
||||
(frame) => !frame.isDeleted,
|
||||
) as readonly NonDeleted<ExcalidrawFrameElement>[];
|
||||
|
||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||
element: T,
|
||||
): element is NonDeleted<T> => !element.isDeleted;
|
||||
|
|
|
@ -594,7 +594,7 @@ export class LinearElementEditor {
|
|||
}
|
||||
|
||||
static handlePointerDown(
|
||||
event: React.PointerEvent<HTMLCanvasElement>,
|
||||
event: React.PointerEvent<HTMLElement>,
|
||||
appState: AppState,
|
||||
history: History,
|
||||
scenePointer: { x: number; y: number },
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ExcalidrawFreeDrawElement,
|
||||
FontFamilyValues,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
} from "../element/types";
|
||||
import {
|
||||
arrayToMap,
|
||||
|
@ -20,15 +21,13 @@ import {
|
|||
isTestEnv,
|
||||
} from "../utils";
|
||||
import { randomInteger, randomId } from "../random";
|
||||
import { bumpVersion, mutateElement, newElementWith } from "./mutateElement";
|
||||
import { bumpVersion, newElementWith } from "./mutateElement";
|
||||
import { getNewGroupIdsForDuplication } from "../groups";
|
||||
import { AppState } from "../types";
|
||||
import { getElementAbsoluteCoords } from ".";
|
||||
import { adjustXYWithRotation } from "../math";
|
||||
import { getResizedElementAbsoluteCoords } from "./bounds";
|
||||
import {
|
||||
getBoundTextElementOffset,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
measureText,
|
||||
normalizeText,
|
||||
|
@ -44,7 +43,6 @@ import {
|
|||
DEFAULT_VERTICAL_ALIGN,
|
||||
VERTICAL_ALIGN,
|
||||
} from "../constants";
|
||||
import { isArrowElement } from "./typeChecks";
|
||||
import { MarkOptional, Merge, Mutable } from "../utility-types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
|
@ -53,6 +51,7 @@ export type ElementConstructorOpts = MarkOptional<
|
|||
| "height"
|
||||
| "angle"
|
||||
| "groupIds"
|
||||
| "frameId"
|
||||
| "boundElements"
|
||||
| "seed"
|
||||
| "version"
|
||||
|
@ -85,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
height = 0,
|
||||
angle = 0,
|
||||
groupIds = [],
|
||||
frameId = null,
|
||||
roundness = null,
|
||||
boundElements = null,
|
||||
link = null,
|
||||
|
@ -109,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||
roughness,
|
||||
opacity,
|
||||
groupIds,
|
||||
frameId,
|
||||
roundness,
|
||||
seed: rest.seed ?? randomInteger(),
|
||||
version: rest.version || 1,
|
||||
|
@ -129,6 +130,21 @@ export const newElement = (
|
|||
): NonDeleted<ExcalidrawGenericElement> =>
|
||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
||||
|
||||
export const newFrameElement = (
|
||||
opts: ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawFrameElement> => {
|
||||
const frameElement = newElementWith(
|
||||
{
|
||||
..._newElementBase<ExcalidrawFrameElement>("frame", opts),
|
||||
type: "frame",
|
||||
name: null,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return frameElement;
|
||||
};
|
||||
|
||||
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||
const getTextElementPositionOffsets = (
|
||||
opts: {
|
||||
|
@ -161,6 +177,7 @@ export const newTextElement = (
|
|||
containerId?: ExcalidrawTextContainer["id"] | null;
|
||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||
isFrameName?: boolean;
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawTextElement> => {
|
||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||
|
@ -195,6 +212,7 @@ export const newTextElement = (
|
|||
containerId: opts.containerId || null,
|
||||
originalText: text,
|
||||
lineHeight,
|
||||
isFrameName: opts.isFrameName || false,
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
@ -211,8 +229,6 @@ const getAdjustedDimensions = (
|
|||
height: number;
|
||||
baseline: number;
|
||||
} => {
|
||||
const container = getContainerElement(element);
|
||||
|
||||
const {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
@ -268,27 +284,6 @@ const getAdjustedDimensions = (
|
|||
);
|
||||
}
|
||||
|
||||
// make sure container dimensions are set properly when
|
||||
// text editor overflows beyond viewport dimensions
|
||||
if (container) {
|
||||
const boundTextElementPadding = getBoundTextElementOffset(element);
|
||||
|
||||
const containerDims = getContainerDims(container);
|
||||
let height = containerDims.height;
|
||||
let width = containerDims.width;
|
||||
if (nextHeight > height - boundTextElementPadding * 2) {
|
||||
height = nextHeight + boundTextElementPadding * 2;
|
||||
}
|
||||
if (nextWidth > width - boundTextElementPadding * 2) {
|
||||
width = nextWidth + boundTextElementPadding * 2;
|
||||
}
|
||||
if (
|
||||
!isArrowElement(container) &&
|
||||
(height !== containerDims.height || width !== containerDims.width)
|
||||
) {
|
||||
mutateElement(container, { height, width });
|
||||
}
|
||||
}
|
||||
return {
|
||||
width: nextWidth,
|
||||
height: nextHeight,
|
||||
|
@ -638,6 +633,10 @@ export const duplicateElements = (
|
|||
: null;
|
||||
}
|
||||
|
||||
if (clonedElement.frameId) {
|
||||
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||
}
|
||||
|
||||
clonedElements.push(clonedElement);
|
||||
}
|
||||
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
import {
|
||||
isArrowElement,
|
||||
isBoundToContainer,
|
||||
isFrameElement,
|
||||
isFreeDrawElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
|
@ -160,12 +161,17 @@ const rotateSingleElement = (
|
|||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
let angle: number;
|
||||
if (isFrameElement(element)) {
|
||||
angle = 0;
|
||||
} else {
|
||||
angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
||||
if (shouldRotateWithDiscreteAngle) {
|
||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
}
|
||||
angle = normalizeAngle(angle);
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
|
||||
mutateElement(element, { angle });
|
||||
|
@ -877,39 +883,49 @@ const rotateMultipleElements = (
|
|||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
||||
}
|
||||
elements.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
pointerDownState.originalElements.get(element.id)?.angle ?? element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(element, {
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
const boundTextElementId = getBoundTextElementId(element);
|
||||
if (boundTextElementId) {
|
||||
const textElement =
|
||||
Scene.getScene(element)?.getElement<ExcalidrawTextElementWithContainer>(
|
||||
boundTextElementId,
|
||||
);
|
||||
if (textElement && !isArrowElement(element)) {
|
||||
mutateElement(textElement, {
|
||||
x: textElement.x + (rotatedCX - cx),
|
||||
y: textElement.y + (rotatedCY - cy),
|
||||
|
||||
elements
|
||||
.filter((element) => element.type !== "frame")
|
||||
.forEach((element) => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
const cx = (x1 + x2) / 2;
|
||||
const cy = (y1 + y2) / 2;
|
||||
const origAngle =
|
||||
pointerDownState.originalElements.get(element.id)?.angle ??
|
||||
element.angle;
|
||||
const [rotatedCX, rotatedCY] = rotate(
|
||||
cx,
|
||||
cy,
|
||||
centerX,
|
||||
centerY,
|
||||
centerAngle + origAngle - element.angle,
|
||||
);
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
x: element.x + (rotatedCX - cx),
|
||||
y: element.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
});
|
||||
},
|
||||
false,
|
||||
);
|
||||
updateBoundElements(element, { simultaneouslyUpdated: elements });
|
||||
|
||||
const boundText = getBoundTextElement(element);
|
||||
if (boundText && !isArrowElement(element)) {
|
||||
mutateElement(
|
||||
boundText,
|
||||
{
|
||||
x: boundText.x + (rotatedCX - cx),
|
||||
y: boundText.y + (rotatedCY - cy),
|
||||
angle: normalizeAngle(centerAngle + origAngle),
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Scene.getScene(elements[0])?.informMutation();
|
||||
};
|
||||
|
||||
export const getResizeOffsetXY = (
|
||||
|
|
|
@ -845,10 +845,12 @@ export const getTextBindableContainerAtPosition = (
|
|||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
||||
if (
|
||||
isArrowElement(elements[index]) &&
|
||||
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
|
||||
x,
|
||||
y,
|
||||
])
|
||||
isHittingElementNotConsideringBoundingBox(
|
||||
elements[index],
|
||||
appState,
|
||||
null,
|
||||
[x, y],
|
||||
)
|
||||
) {
|
||||
hitElement = elements[index];
|
||||
break;
|
||||
|
@ -865,7 +867,6 @@ export const VALID_CONTAINER_TYPES = new Set([
|
|||
"rectangle",
|
||||
"ellipse",
|
||||
"diamond",
|
||||
"image",
|
||||
"arrow",
|
||||
]);
|
||||
|
||||
|
|
|
@ -26,6 +26,17 @@ ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
|
|||
const tab = " ";
|
||||
const mouse = new Pointer("mouse");
|
||||
|
||||
const getTextEditor = () => {
|
||||
return document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
};
|
||||
|
||||
const updateTextEditor = (editor: HTMLTextAreaElement, value: string) => {
|
||||
fireEvent.change(editor, { target: { value } });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
};
|
||||
|
||||
describe("textWysiwyg", () => {
|
||||
describe("start text editing", () => {
|
||||
const { h } = window;
|
||||
|
@ -190,9 +201,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.clickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -214,9 +223,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.doubleClickAt(text.x + 50, text.y + 50);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
expect(editor).not.toBe(null);
|
||||
expect(h.state.editingElement?.id).toBe(text.id);
|
||||
|
@ -243,9 +250,7 @@ describe("textWysiwyg", () => {
|
|||
textElement = UI.createElement("text");
|
||||
|
||||
mouse.clickOn(textElement);
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
textarea = getTextEditor();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -455,17 +460,11 @@ describe("textWysiwyg", () => {
|
|||
UI.clickTool("text");
|
||||
mouse.clickAt(750, 300);
|
||||
|
||||
textarea = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
)!;
|
||||
fireEvent.change(textarea, {
|
||||
target: {
|
||||
value:
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
},
|
||||
});
|
||||
|
||||
textarea.dispatchEvent(new Event("input"));
|
||||
textarea = getTextEditor();
|
||||
updateTextEditor(
|
||||
textarea,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard for sketching hand-drawn like diagrams!",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
textarea.blur();
|
||||
expect(textarea.style.width).toBe("792px");
|
||||
|
@ -513,11 +512,9 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -543,11 +540,9 @@ describe("textWysiwyg", () => {
|
|||
]);
|
||||
expect(text.angle).toBe(rectangle.angle);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -572,9 +567,7 @@ describe("textWysiwyg", () => {
|
|||
API.setSelectedElements([diamond]);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const value = new Array(1000).fill("1").join("\n");
|
||||
|
@ -587,9 +580,7 @@ describe("textWysiwyg", () => {
|
|||
expect(diamond.height).toBe(50020);
|
||||
|
||||
// Clearing text to simulate height decrease
|
||||
expect(() =>
|
||||
fireEvent.input(editor, { target: { value: "" } }),
|
||||
).not.toThrow();
|
||||
expect(() => updateTextEditor(editor, "")).not.toThrow();
|
||||
|
||||
expect(diamond.height).toBe(70);
|
||||
});
|
||||
|
@ -611,9 +602,7 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -628,11 +617,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
||||
|
@ -652,13 +639,11 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -689,11 +674,8 @@ describe("textWysiwyg", () => {
|
|||
{ id: text.id, type: "text" },
|
||||
]);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -717,17 +699,9 @@ describe("textWysiwyg", () => {
|
|||
freedraw.y + freedraw.height / 2,
|
||||
);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
fireEvent.keyDown(editor, { key: KEYS.ESCAPE });
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
|
||||
expect(freedraw.boundElements).toBe(null);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
@ -759,11 +733,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(null);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -776,17 +748,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
expect(h.elements.length).toBe(2);
|
||||
expect(h.elements[1].type).toBe("text");
|
||||
|
@ -826,12 +793,10 @@ describe("textWysiwyg", () => {
|
|||
mouse.down();
|
||||
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(text.fontFamily).toEqual(FONT_FAMILY.Virgil);
|
||||
UI.clickTool("text");
|
||||
|
@ -841,9 +806,7 @@ describe("textWysiwyg", () => {
|
|||
rectangle.y + rectangle.height / 2,
|
||||
);
|
||||
mouse.down();
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
fireEvent.click(screen.getByTitle(/code/i));
|
||||
|
@ -876,17 +839,9 @@ describe("textWysiwyg", () => {
|
|||
|
||||
Keyboard.keyDown(KEYS.ENTER);
|
||||
let text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello World!",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
editor.blur();
|
||||
|
@ -905,17 +860,8 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Hello",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
|
@ -943,13 +889,11 @@ describe("textWysiwyg", () => {
|
|||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -982,11 +926,9 @@ describe("textWysiwyg", () => {
|
|||
// Bind first text
|
||||
const text = h.elements[1] as ExcalidrawTextElementWithContainer;
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: text.id, type: "text" },
|
||||
|
@ -1005,11 +947,9 @@ describe("textWysiwyg", () => {
|
|||
it("should respect text alignment when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
// should center align horizontally and vertically by default
|
||||
|
@ -1024,9 +964,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1049,9 +987,7 @@ describe("textWysiwyg", () => {
|
|||
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
editor.select();
|
||||
|
||||
|
@ -1089,11 +1025,9 @@ describe("textWysiwyg", () => {
|
|||
expect(text.type).toBe("text");
|
||||
expect(text.containerId).toBe(rectangle.id);
|
||||
mouse.down();
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1106,11 +1040,9 @@ describe("textWysiwyg", () => {
|
|||
it("should scale font size correctly when resizing using shift", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
const textElement = h.elements[1] as ExcalidrawTextElement;
|
||||
expect(rectangle.width).toBe(90);
|
||||
|
@ -1128,11 +1060,9 @@ describe("textWysiwyg", () => {
|
|||
it("should bind text correctly when container duplicated with alt-drag", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(h.elements.length).toBe(2);
|
||||
|
||||
|
@ -1162,11 +1092,9 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("undo should work", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([
|
||||
{ id: h.elements[1].id, type: "text" },
|
||||
|
@ -1201,54 +1129,64 @@ describe("textWysiwyg", () => {
|
|||
|
||||
it("should not allow bound text with only whitespaces", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, { target: { value: " " } });
|
||||
updateTextEditor(editor, " ");
|
||||
editor.blur();
|
||||
expect(rectangle.boundElements).toStrictEqual([]);
|
||||
expect(h.elements[1].isDeleted).toBe(true);
|
||||
});
|
||||
|
||||
it("should restore original container height and clear cache once text is unbind", async () => {
|
||||
const originalRectHeight = rectangle.height;
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: { value: "Online whiteboard collaboration made easy" },
|
||||
const container = API.createElement({
|
||||
type: "rectangle",
|
||||
height: 75,
|
||||
width: 90,
|
||||
});
|
||||
editor.blur();
|
||||
expect(rectangle.height).toBe(185);
|
||||
mouse.select(rectangle);
|
||||
const originalRectHeight = container.height;
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
|
||||
const text = API.createElement({
|
||||
type: "text",
|
||||
text: "Online whiteboard collaboration made easy",
|
||||
});
|
||||
|
||||
h.elements = [container, text];
|
||||
API.setSelectedElements([container, text]);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
const contextMenu = document.querySelector(".context-menu");
|
||||
let contextMenu = document.querySelector(".context-menu");
|
||||
|
||||
fireEvent.click(
|
||||
queryByText(contextMenu as HTMLElement, "Bind text to the container")!,
|
||||
);
|
||||
|
||||
expect((h.elements[1] as ExcalidrawTextElementWithContainer).text).toBe(
|
||||
"Online \nwhitebo\nard \ncollabo\nration \nmade \neasy",
|
||||
);
|
||||
fireEvent.contextMenu(GlobalTestState.canvas, {
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 30,
|
||||
});
|
||||
contextMenu = document.querySelector(".context-menu");
|
||||
fireEvent.click(queryByText(contextMenu as HTMLElement, "Unbind text")!);
|
||||
expect(h.elements[0].boundElements).toEqual([]);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(null);
|
||||
expect(getOriginalContainerHeightFromCache(container.id)).toBe(null);
|
||||
|
||||
expect(rectangle.height).toBe(originalRectHeight);
|
||||
expect(container.height).toBe(originalRectHeight);
|
||||
});
|
||||
|
||||
it("should reset the container height cache when resizing", async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
let editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
let editor = getTextEditor();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
|
||||
resize(rectangle, "ne", [rectangle.x + 100, rectangle.y - 100]);
|
||||
|
@ -1258,9 +1196,7 @@ describe("textWysiwyg", () => {
|
|||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
editor.blur();
|
||||
|
@ -1273,12 +1209,8 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
|
||||
mouse.select(rectangle);
|
||||
|
@ -1302,12 +1234,8 @@ describe("textWysiwyg", () => {
|
|||
Keyboard.keyPress(KEYS.ENTER);
|
||||
expect(getOriginalContainerHeightFromCache(rectangle.id)).toBe(75);
|
||||
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello World!" } });
|
||||
const editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello World!");
|
||||
editor.blur();
|
||||
expect(
|
||||
(h.elements[1] as ExcalidrawTextElementWithContainer).lineHeight,
|
||||
|
@ -1338,17 +1266,12 @@ describe("textWysiwyg", () => {
|
|||
|
||||
beforeEach(async () => {
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
fireEvent.change(editor, { target: { value: "Hello" } });
|
||||
editor = getTextEditor();
|
||||
updateTextEditor(editor, "Hello");
|
||||
editor.blur();
|
||||
mouse.select(rectangle);
|
||||
Keyboard.keyPress(KEYS.ENTER);
|
||||
editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
editor = getTextEditor();
|
||||
editor.select();
|
||||
});
|
||||
|
||||
|
@ -1459,17 +1382,12 @@ describe("textWysiwyg", () => {
|
|||
it("should wrap text in a container when wrap text in container triggered from context menu", async () => {
|
||||
UI.clickTool("text");
|
||||
mouse.clickAt(20, 30);
|
||||
const editor = document.querySelector(
|
||||
".excalidraw-textEditorContainer > textarea",
|
||||
) as HTMLTextAreaElement;
|
||||
const editor = getTextEditor();
|
||||
|
||||
fireEvent.change(editor, {
|
||||
target: {
|
||||
value: "Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
},
|
||||
});
|
||||
|
||||
editor.dispatchEvent(new Event("input"));
|
||||
updateTextEditor(
|
||||
editor,
|
||||
"Excalidraw is an opensource virtual collaborative whiteboard",
|
||||
);
|
||||
await new Promise((cb) => setTimeout(cb, 0));
|
||||
|
||||
editor.select();
|
||||
|
@ -1541,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isBoundToContainer,
|
||||
isTextElement,
|
||||
} from "./typeChecks";
|
||||
import { CLASSES, isSafari, VERTICAL_ALIGN } from "../constants";
|
||||
import { CLASSES, isSafari } from "../constants";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
|
@ -23,12 +23,10 @@ import { AppState } from "../types";
|
|||
import { mutateElement } from "./mutateElement";
|
||||
import {
|
||||
getBoundTextElementId,
|
||||
getContainerCoords,
|
||||
getContainerDims,
|
||||
getContainerElement,
|
||||
getTextElementAngle,
|
||||
getTextWidth,
|
||||
measureText,
|
||||
normalizeText,
|
||||
redrawTextBoundingBox,
|
||||
wrapText,
|
||||
|
@ -36,6 +34,7 @@ import {
|
|||
getBoundTextMaxWidth,
|
||||
computeContainerDimensionForBoundText,
|
||||
detectLineHeight,
|
||||
computeBoundTextPosition,
|
||||
} from "./textElement";
|
||||
import {
|
||||
actionDecreaseFontSize,
|
||||
|
@ -162,7 +161,7 @@ export const textWysiwyg = ({
|
|||
let textElementWidth = updatedTextElement.width;
|
||||
// Set to element height by default since that's
|
||||
// what is going to be used for unbounded text
|
||||
let textElementHeight = updatedTextElement.height;
|
||||
const textElementHeight = updatedTextElement.height;
|
||||
|
||||
if (container && updatedTextElement.containerId) {
|
||||
if (isArrowElement(container)) {
|
||||
|
@ -179,15 +178,6 @@ export const textWysiwyg = ({
|
|||
editable,
|
||||
);
|
||||
const containerDims = getContainerDims(container);
|
||||
// using editor.style.height to get the accurate height of text editor
|
||||
const editorHeight = Number(editable.style.height.slice(0, -2));
|
||||
if (editorHeight > 0) {
|
||||
textElementHeight = editorHeight;
|
||||
}
|
||||
if (propertiesUpdated) {
|
||||
// update height of the editor after properties updated
|
||||
textElementHeight = updatedTextElement.height;
|
||||
}
|
||||
|
||||
let originalContainerData;
|
||||
if (propertiesUpdated) {
|
||||
|
@ -232,22 +222,12 @@ export const textWysiwyg = ({
|
|||
container.type,
|
||||
);
|
||||
mutateElement(container, { height: targetContainerHeight });
|
||||
}
|
||||
// Start pushing text upward until a diff of 30px (padding)
|
||||
// is reached
|
||||
else {
|
||||
const containerCoords = getContainerCoords(container);
|
||||
|
||||
// vertically center align the text
|
||||
if (verticalAlign === VERTICAL_ALIGN.MIDDLE) {
|
||||
if (!isArrowElement(container)) {
|
||||
coordY =
|
||||
containerCoords.y + maxHeight / 2 - textElementHeight / 2;
|
||||
}
|
||||
}
|
||||
if (verticalAlign === VERTICAL_ALIGN.BOTTOM) {
|
||||
coordY = containerCoords.y + (maxHeight - textElementHeight);
|
||||
}
|
||||
} else {
|
||||
const { y } = computeBoundTextPosition(
|
||||
container,
|
||||
updatedTextElement as ExcalidrawTextElementWithContainer,
|
||||
);
|
||||
coordY = y;
|
||||
}
|
||||
}
|
||||
const [viewportX, viewportY] = getViewportCoords(coordX, coordY);
|
||||
|
@ -388,25 +368,6 @@ export const textWysiwyg = ({
|
|||
};
|
||||
|
||||
editable.oninput = () => {
|
||||
const updatedTextElement = Scene.getScene(element)?.getElement(
|
||||
id,
|
||||
) as ExcalidrawTextElement;
|
||||
const font = getFontString(updatedTextElement);
|
||||
if (isBoundToContainer(element)) {
|
||||
const container = getContainerElement(element);
|
||||
const wrappedText = wrapText(
|
||||
normalizeText(editable.value),
|
||||
font,
|
||||
getBoundTextMaxWidth(container!),
|
||||
);
|
||||
const { width, height } = measureText(
|
||||
wrappedText,
|
||||
font,
|
||||
updatedTextElement.lineHeight,
|
||||
);
|
||||
editable.style.width = `${width}px`;
|
||||
editable.style.height = `${height}px`;
|
||||
}
|
||||
onChange(normalizeText(editable.value));
|
||||
};
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
|
|||
import { rotate } from "../math";
|
||||
import { AppState, Zoom } from "../types";
|
||||
import { isTextElement } from ".";
|
||||
import { isLinearElement } from "./typeChecks";
|
||||
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||
|
||||
export type TransformHandleDirection =
|
||||
|
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
|||
w: true,
|
||||
};
|
||||
|
||||
export const OMIT_SIDES_FOR_FRAME = {
|
||||
e: true,
|
||||
s: true,
|
||||
n: true,
|
||||
w: true,
|
||||
rotation: true,
|
||||
};
|
||||
|
||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||
e: true,
|
||||
s: true,
|
||||
|
@ -249,6 +257,10 @@ export const getTransformHandles = (
|
|||
}
|
||||
} else if (isTextElement(element)) {
|
||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||
} else if (isFrameElement(element)) {
|
||||
omitSides = {
|
||||
rotation: true,
|
||||
};
|
||||
}
|
||||
const dashedLineMargin = isLinearElement(element)
|
||||
? DEFAULT_SPACING + 8
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import {
|
|||
ExcalidrawImageElement,
|
||||
ExcalidrawTextElementWithContainer,
|
||||
ExcalidrawTextContainer,
|
||||
ExcalidrawFrameElement,
|
||||
RoundnessType,
|
||||
} from "./types";
|
||||
|
||||
|
@ -45,6 +46,12 @@ export const isTextElement = (
|
|||
return element != null && element.type === "text";
|
||||
};
|
||||
|
||||
export const isFrameElement = (
|
||||
element: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFrameElement => {
|
||||
return element != null && element.type === "frame";
|
||||
};
|
||||
|
||||
export const isFreeDrawElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawFreeDrawElement => {
|
||||
|
@ -119,7 +126,6 @@ export const isTextBindableContainer = (
|
|||
(element.type === "rectangle" ||
|
||||
element.type === "diamond" ||
|
||||
element.type === "ellipse" ||
|
||||
element.type === "image" ||
|
||||
isArrowElement(element))
|
||||
);
|
||||
};
|
||||
|
|
|
@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||
/** List of groups the element belongs to.
|
||||
Ordered from deepest to shallowest. */
|
||||
groupIds: readonly GroupId[];
|
||||
frameId: string | null;
|
||||
/** other elements that are bound to this element */
|
||||
boundElements:
|
||||
| readonly Readonly<{
|
||||
|
@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
|
|||
"fileId"
|
||||
>;
|
||||
|
||||
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||
type: "frame";
|
||||
name: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* These are elements that don't have any additional properties.
|
||||
*/
|
||||
|
@ -117,7 +123,8 @@ export type ExcalidrawElement =
|
|||
| ExcalidrawTextElement
|
||||
| ExcalidrawLinearElement
|
||||
| ExcalidrawFreeDrawElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||
isDeleted: boolean;
|
||||
|
@ -148,13 +155,13 @@ export type ExcalidrawBindableElement =
|
|||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawTextElement
|
||||
| ExcalidrawImageElement;
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawFrameElement;
|
||||
|
||||
export type ExcalidrawTextContainer =
|
||||
| ExcalidrawRectangleElement
|
||||
| ExcalidrawDiamondElement
|
||||
| ExcalidrawEllipseElement
|
||||
| ExcalidrawImageElement
|
||||
| ExcalidrawArrowElement;
|
||||
|
||||
export type ExcalidrawTextElementWithContainer = {
|
||||
|
|
|
@ -157,6 +157,8 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
window.addEventListener("offline", this.onOfflineStatusToggle);
|
||||
window.addEventListener(EVENT.UNLOAD, this.onUnload);
|
||||
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
const collabAPI: CollabAPI = {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
|
@ -168,7 +170,6 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
};
|
||||
|
||||
appJotaiStore.set(collabAPIAtom, collabAPI);
|
||||
this.onOfflineStatusToggle();
|
||||
|
||||
if (
|
||||
process.env.NODE_ENV === ENV.TEST ||
|
||||
|
@ -380,6 +381,13 @@ class Collab extends PureComponent<Props, CollabState> {
|
|||
startCollaboration = async (
|
||||
existingRoomLinkData: null | { roomId: string; roomKey: string },
|
||||
): Promise<ImportedDataState | null> => {
|
||||
if (!this.state.username) {
|
||||
import("@excalidraw/random-username").then(({ getRandomUsername }) => {
|
||||
const username = getRandomUsername();
|
||||
this.onUsernameChange(username);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.portal.socket) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import { LanguageList } from "./LanguageList";
|
|||
export const AppMainMenu: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
isCollaborating: boolean;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
return (
|
||||
<MainMenu>
|
||||
|
@ -13,10 +14,12 @@ export const AppMainMenu: React.FC<{
|
|||
<MainMenu.DefaultItems.SaveToActiveFile />
|
||||
<MainMenu.DefaultItems.Export />
|
||||
<MainMenu.DefaultItems.SaveAsImage />
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
{props.isCollabEnabled && (
|
||||
<MainMenu.DefaultItems.LiveCollaborationTrigger
|
||||
isCollaborating={props.isCollaborating}
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MainMenu.DefaultItems.Help />
|
||||
<MainMenu.DefaultItems.ClearCanvas />
|
||||
|
|
|
@ -6,6 +6,7 @@ import { isExcalidrawPlusSignedUser } from "../app_constants";
|
|||
|
||||
export const AppWelcomeScreen: React.FC<{
|
||||
setCollabDialogShown: (toggle: boolean) => any;
|
||||
isCollabEnabled: boolean;
|
||||
}> = React.memo((props) => {
|
||||
const { t } = useI18n();
|
||||
let headingContent;
|
||||
|
@ -46,9 +47,11 @@ export const AppWelcomeScreen: React.FC<{
|
|||
<WelcomeScreen.Center.Menu>
|
||||
<WelcomeScreen.Center.MenuItemLoadScene />
|
||||
<WelcomeScreen.Center.MenuItemHelp />
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
{props.isCollabEnabled && (
|
||||
<WelcomeScreen.Center.MenuItemLiveCollaborationTrigger
|
||||
onSelect={() => props.setCollabDialogShown(true)}
|
||||
/>
|
||||
)}
|
||||
{!isExcalidrawPlusSignedUser && (
|
||||
<WelcomeScreen.Center.MenuItemLink
|
||||
href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=app&utm_content=welcomeScreenGuest"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -263,7 +263,7 @@ export const loadScene = async (
|
|||
data = restore(
|
||||
await importFromBackend(id, privateKey),
|
||||
localDataState?.appState,
|
||||
convertToExcalidrawElements(localDataState?.elements),
|
||||
localDataState?.elements,
|
||||
{ repairBindings: true, refreshDimensions: false },
|
||||
);
|
||||
} else {
|
||||
|
@ -283,11 +283,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(
|
||||
|
@ -328,14 +332,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;
|
|
@ -42,6 +42,7 @@ import {
|
|||
preventUnload,
|
||||
ResolvablePromise,
|
||||
resolvablePromise,
|
||||
isRunningInIframe,
|
||||
} from "../utils";
|
||||
import {
|
||||
FIREBASE_STORAGE_PREFIXES,
|
||||
|
@ -68,7 +69,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";
|
||||
|
@ -88,6 +92,10 @@ import { appJotaiStore } from "./app-jotai";
|
|||
import "./index.scss";
|
||||
import { ResolutionType } from "../utility-types";
|
||||
import { convertToExcalidrawElements } from "../data/transform";
|
||||
import { ShareableLinkDialog } from "../components/ShareableLinkDialog";
|
||||
import { openConfirmModal } from "../components/OverwriteConfirm/OverwriteConfirmState";
|
||||
import { OverwriteConfirmDialog } from "../components/OverwriteConfirm/OverwriteConfirm";
|
||||
import Trans from "../components/Trans";
|
||||
|
||||
polyfill();
|
||||
|
||||
|
@ -98,8 +106,21 @@ 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;
|
||||
collabAPI: CollabAPI | null;
|
||||
excalidrawAPI: ExcalidrawImperativeAPI;
|
||||
}): Promise<
|
||||
{ scene: ExcalidrawInitialDataState | null } & (
|
||||
|
@ -129,7 +150,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(
|
||||
|
@ -168,7 +189,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 };
|
||||
}
|
||||
|
@ -184,7 +205,7 @@ const initializeScene = async (opts: {
|
|||
}
|
||||
}
|
||||
|
||||
if (roomLinkData) {
|
||||
if (roomLinkData && opts.collabAPI) {
|
||||
const { excalidrawAPI } = opts;
|
||||
|
||||
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
||||
|
@ -238,6 +259,7 @@ export const appLangCodeAtom = atom(
|
|||
const ExcalidrawWrapper = () => {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
||||
const isCollabDisabled = isRunningInIframe();
|
||||
|
||||
// initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
@ -273,7 +295,7 @@ const ExcalidrawWrapper = () => {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collabAPI || !excalidrawAPI) {
|
||||
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -284,7 +306,7 @@ const ExcalidrawWrapper = () => {
|
|||
if (!data.scene) {
|
||||
return;
|
||||
}
|
||||
if (collabAPI.isCollaborating()) {
|
||||
if (collabAPI?.isCollaborating()) {
|
||||
if (data.scene.elements) {
|
||||
collabAPI
|
||||
.fetchImageFilesFromFirebase({
|
||||
|
@ -355,7 +377,7 @@ const ExcalidrawWrapper = () => {
|
|||
const libraryUrlTokens = parseLibraryTokensFromUrl();
|
||||
if (!libraryUrlTokens) {
|
||||
if (
|
||||
collabAPI.isCollaborating() &&
|
||||
collabAPI?.isCollaborating() &&
|
||||
!isCollaborationLink(window.location.href)
|
||||
) {
|
||||
collabAPI.stopCollaboration(false);
|
||||
|
@ -384,7 +406,10 @@ const ExcalidrawWrapper = () => {
|
|||
if (isTestEnv()) {
|
||||
return;
|
||||
}
|
||||
if (!document.hidden && !collabAPI.isCollaborating()) {
|
||||
if (
|
||||
!document.hidden &&
|
||||
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
||||
) {
|
||||
// don't sync if local state is newer or identical to browser state
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
||||
const localDataState = importFromLocalStorage();
|
||||
|
@ -400,7 +425,7 @@ const ExcalidrawWrapper = () => {
|
|||
excalidrawAPI.updateLibrary({
|
||||
libraryItems: getLibraryItemsFromStorage(),
|
||||
});
|
||||
collabAPI.setUsername(username || "");
|
||||
collabAPI?.setUsername(username || "");
|
||||
}
|
||||
|
||||
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
||||
|
@ -468,7 +493,7 @@ const ExcalidrawWrapper = () => {
|
|||
);
|
||||
clearTimeout(titleTimeout);
|
||||
};
|
||||
}, [collabAPI, excalidrawAPI, setLangCode]);
|
||||
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
||||
|
||||
useEffect(() => {
|
||||
const unloadHandler = (event: BeforeUnloadEvent) => {
|
||||
|
@ -551,6 +576,10 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const onExportToBackend = async (
|
||||
exportedElements: readonly NonDeletedExcalidrawElement[],
|
||||
appState: Partial<AppState>,
|
||||
|
@ -562,7 +591,7 @@ const ExcalidrawWrapper = () => {
|
|||
}
|
||||
if (canvas) {
|
||||
try {
|
||||
await exportToBackend(
|
||||
const { url, errorMessage } = await exportToBackend(
|
||||
exportedElements,
|
||||
{
|
||||
...appState,
|
||||
|
@ -572,6 +601,14 @@ const ExcalidrawWrapper = () => {
|
|||
},
|
||||
files,
|
||||
);
|
||||
|
||||
if (errorMessage) {
|
||||
setErrorMessage(errorMessage);
|
||||
}
|
||||
|
||||
if (url) {
|
||||
setLatestShareableLink(url);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.name !== "AbortError") {
|
||||
const { width, height } = canvas;
|
||||
|
@ -651,7 +688,7 @@ const ExcalidrawWrapper = () => {
|
|||
autoFocus={true}
|
||||
theme={theme}
|
||||
renderTopRightUI={(isMobile) => {
|
||||
if (isMobile) {
|
||||
if (isMobile || !collabAPI || isCollabDisabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
@ -665,21 +702,53 @@ const ExcalidrawWrapper = () => {
|
|||
<AppMainMenu
|
||||
setCollabDialogShown={setCollabDialogShown}
|
||||
isCollaborating={isCollaborating}
|
||||
isCollabEnabled={!isCollabDisabled}
|
||||
/>
|
||||
<AppWelcomeScreen setCollabDialogShown={setCollabDialogShown} />
|
||||
<AppWelcomeScreen
|
||||
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>
|
||||
)}
|
||||
{excalidrawAPI && <Collab excalidrawAPI={excalidrawAPI} />}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
|
708
src/frame.ts
Normal file
708
src/frame.ts
Normal file
|
@ -0,0 +1,708 @@
|
|||
import {
|
||||
getCommonBounds,
|
||||
getElementAbsoluteCoords,
|
||||
isTextElement,
|
||||
} from "./element";
|
||||
import {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFrameElement,
|
||||
NonDeleted,
|
||||
NonDeletedExcalidrawElement,
|
||||
} from "./element/types";
|
||||
import { isPointWithinBounds } from "./math";
|
||||
import {
|
||||
getBoundTextElement,
|
||||
getContainerElement,
|
||||
} from "./element/textElement";
|
||||
import { arrayToMap, findIndex } from "./utils";
|
||||
import { mutateElement } from "./element/mutateElement";
|
||||
import { AppState } from "./types";
|
||||
import { getElementsWithinSelection, getSelectedElements } from "./scene";
|
||||
import { isFrameElement } from "./element";
|
||||
import { moveOneRight } from "./zindex";
|
||||
import { getElementsInGroup, selectGroupsFromGivenElements } from "./groups";
|
||||
import Scene, { ExcalidrawElementsIncludingDeleted } from "./scene/Scene";
|
||||
import { getElementLineSegments } from "./element/bounds";
|
||||
|
||||
// --------------------------- Frame State ------------------------------------
|
||||
export const bindElementsToFramesAfterDuplication = (
|
||||
nextElements: ExcalidrawElement[],
|
||||
oldElements: readonly ExcalidrawElement[],
|
||||
oldIdToDuplicatedId: Map<ExcalidrawElement["id"], ExcalidrawElement["id"]>,
|
||||
) => {
|
||||
const nextElementMap = arrayToMap(nextElements) as Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement
|
||||
>;
|
||||
|
||||
for (const element of oldElements) {
|
||||
if (element.frameId) {
|
||||
// use its frameId to get the new frameId
|
||||
const nextElementId = oldIdToDuplicatedId.get(element.id);
|
||||
const nextFrameId = oldIdToDuplicatedId.get(element.frameId);
|
||||
if (nextElementId) {
|
||||
const nextElement = nextElementMap.get(nextElementId);
|
||||
if (nextElement) {
|
||||
mutateElement(
|
||||
nextElement,
|
||||
{
|
||||
frameId: nextFrameId ?? element.frameId,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --------------------------- Frame Geometry ---------------------------------
|
||||
class Point {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
constructor(x: number, y: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
class LineSegment {
|
||||
first: Point;
|
||||
second: Point;
|
||||
|
||||
constructor(pointA: Point, pointB: Point) {
|
||||
this.first = pointA;
|
||||
this.second = pointB;
|
||||
}
|
||||
|
||||
public getBoundingBox(): [Point, Point] {
|
||||
return [
|
||||
new Point(
|
||||
Math.min(this.first.x, this.second.x),
|
||||
Math.min(this.first.y, this.second.y),
|
||||
),
|
||||
new Point(
|
||||
Math.max(this.first.x, this.second.x),
|
||||
Math.max(this.first.y, this.second.y),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// https://martin-thoma.com/how-to-check-if-two-line-segments-intersect/
|
||||
class FrameGeometry {
|
||||
private static EPSILON = 0.000001;
|
||||
|
||||
private static crossProduct(a: Point, b: Point) {
|
||||
return a.x * b.y - b.x * a.y;
|
||||
}
|
||||
|
||||
private static doBoundingBoxesIntersect(
|
||||
a: [Point, Point],
|
||||
b: [Point, Point],
|
||||
) {
|
||||
return (
|
||||
a[0].x <= b[1].x &&
|
||||
a[1].x >= b[0].x &&
|
||||
a[0].y <= b[1].y &&
|
||||
a[1].y >= b[0].y
|
||||
);
|
||||
}
|
||||
|
||||
private static isPointOnLine(a: LineSegment, b: Point) {
|
||||
const aTmp = new LineSegment(
|
||||
new Point(0, 0),
|
||||
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
|
||||
);
|
||||
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
|
||||
const r = this.crossProduct(aTmp.second, bTmp);
|
||||
return Math.abs(r) < this.EPSILON;
|
||||
}
|
||||
|
||||
private static isPointRightOfLine(a: LineSegment, b: Point) {
|
||||
const aTmp = new LineSegment(
|
||||
new Point(0, 0),
|
||||
new Point(a.second.x - a.first.x, a.second.y - a.first.y),
|
||||
);
|
||||
const bTmp = new Point(b.x - a.first.x, b.y - a.first.y);
|
||||
return this.crossProduct(aTmp.second, bTmp) < 0;
|
||||
}
|
||||
|
||||
private static lineSegmentTouchesOrCrossesLine(
|
||||
a: LineSegment,
|
||||
b: LineSegment,
|
||||
) {
|
||||
return (
|
||||
this.isPointOnLine(a, b.first) ||
|
||||
this.isPointOnLine(a, b.second) ||
|
||||
(this.isPointRightOfLine(a, b.first)
|
||||
? !this.isPointRightOfLine(a, b.second)
|
||||
: this.isPointRightOfLine(a, b.second))
|
||||
);
|
||||
}
|
||||
|
||||
private static doLineSegmentsIntersect(
|
||||
a: [readonly [number, number], readonly [number, number]],
|
||||
b: [readonly [number, number], readonly [number, number]],
|
||||
) {
|
||||
const aSegment = new LineSegment(
|
||||
new Point(a[0][0], a[0][1]),
|
||||
new Point(a[1][0], a[1][1]),
|
||||
);
|
||||
const bSegment = new LineSegment(
|
||||
new Point(b[0][0], b[0][1]),
|
||||
new Point(b[1][0], b[1][1]),
|
||||
);
|
||||
|
||||
const box1 = aSegment.getBoundingBox();
|
||||
const box2 = bSegment.getBoundingBox();
|
||||
return (
|
||||
this.doBoundingBoxesIntersect(box1, box2) &&
|
||||
this.lineSegmentTouchesOrCrossesLine(aSegment, bSegment) &&
|
||||
this.lineSegmentTouchesOrCrossesLine(bSegment, aSegment)
|
||||
);
|
||||
}
|
||||
|
||||
public static isElementIntersectingFrame(
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) {
|
||||
const frameLineSegments = getElementLineSegments(frame);
|
||||
|
||||
const elementLineSegments = getElementLineSegments(element);
|
||||
|
||||
const intersecting = frameLineSegments.some((frameLineSegment) =>
|
||||
elementLineSegments.some((elementLineSegment) =>
|
||||
this.doLineSegmentsIntersect(frameLineSegment, elementLineSegment),
|
||||
),
|
||||
);
|
||||
|
||||
return intersecting;
|
||||
}
|
||||
}
|
||||
|
||||
export const getElementsCompletelyInFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) =>
|
||||
omitGroupsContainingFrames(
|
||||
getElementsWithinSelection(elements, frame, false),
|
||||
).filter(
|
||||
(element) =>
|
||||
(element.type !== "frame" && !element.frameId) ||
|
||||
element.frameId === frame.id,
|
||||
);
|
||||
|
||||
export const isElementContainingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
return getElementsWithinSelection(elements, element).some(
|
||||
(e) => e.id === frame.id,
|
||||
);
|
||||
};
|
||||
|
||||
export const getElementsIntersectingFrame = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) =>
|
||||
elements.filter((element) =>
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame),
|
||||
);
|
||||
|
||||
export const elementsAreInFrameBounds = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||
getElementAbsoluteCoords(frame);
|
||||
|
||||
const [elementX1, elementY1, elementX2, elementY2] =
|
||||
getCommonBounds(elements);
|
||||
|
||||
return (
|
||||
selectionX1 <= elementX1 &&
|
||||
selectionY1 <= elementY1 &&
|
||||
selectionX2 >= elementX2 &&
|
||||
selectionY2 >= elementY2
|
||||
);
|
||||
};
|
||||
|
||||
export const elementOverlapsWithFrame = (
|
||||
element: ExcalidrawElement,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
return (
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame) ||
|
||||
isElementContainingFrame([frame], element, frame)
|
||||
);
|
||||
};
|
||||
|
||||
export const isCursorInFrame = (
|
||||
cursorCoords: {
|
||||
x: number;
|
||||
y: number;
|
||||
},
|
||||
frame: NonDeleted<ExcalidrawFrameElement>,
|
||||
) => {
|
||||
const [fx1, fy1, fx2, fy2] = getElementAbsoluteCoords(frame);
|
||||
|
||||
return isPointWithinBounds(
|
||||
[fx1, fy1],
|
||||
[cursorCoords.x, cursorCoords.y],
|
||||
[fx2, fy2],
|
||||
);
|
||||
};
|
||||
|
||||
export const groupsAreAtLeastIntersectingTheFrame = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
groupIds: readonly string[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const elementsInGroup = groupIds.flatMap((groupId) =>
|
||||
getElementsInGroup(elements, groupId),
|
||||
);
|
||||
|
||||
if (elementsInGroup.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !!elementsInGroup.find(
|
||||
(element) =>
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame),
|
||||
);
|
||||
};
|
||||
|
||||
export const groupsAreCompletelyOutOfFrame = (
|
||||
elements: readonly NonDeletedExcalidrawElement[],
|
||||
groupIds: readonly string[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const elementsInGroup = groupIds.flatMap((groupId) =>
|
||||
getElementsInGroup(elements, groupId),
|
||||
);
|
||||
|
||||
if (elementsInGroup.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
elementsInGroup.find(
|
||||
(element) =>
|
||||
elementsAreInFrameBounds([element], frame) ||
|
||||
FrameGeometry.isElementIntersectingFrame(element, frame),
|
||||
) === undefined
|
||||
);
|
||||
};
|
||||
|
||||
// --------------------------- Frame Utils ------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a map of frameId to frame elements. Includes empty frames.
|
||||
*/
|
||||
export const groupByFrames = (elements: readonly ExcalidrawElement[]) => {
|
||||
const frameElementsMap = new Map<
|
||||
ExcalidrawElement["id"],
|
||||
ExcalidrawElement[]
|
||||
>();
|
||||
|
||||
for (const element of elements) {
|
||||
const frameId = isFrameElement(element) ? element.id : element.frameId;
|
||||
if (frameId && !frameElementsMap.has(frameId)) {
|
||||
frameElementsMap.set(frameId, getFrameElements(elements, frameId));
|
||||
}
|
||||
}
|
||||
|
||||
return frameElementsMap;
|
||||
};
|
||||
|
||||
export const getFrameElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frameId: string,
|
||||
) => allElements.filter((element) => element.frameId === frameId);
|
||||
|
||||
export const getElementsInResizingFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
): ExcalidrawElement[] => {
|
||||
const prevElementsInFrame = getFrameElements(allElements, frame.id);
|
||||
const nextElementsInFrame = new Set<ExcalidrawElement>(prevElementsInFrame);
|
||||
|
||||
const elementsCompletelyInFrame = new Set([
|
||||
...getElementsCompletelyInFrame(allElements, frame),
|
||||
...prevElementsInFrame.filter((element) =>
|
||||
isElementContainingFrame(allElements, element, frame),
|
||||
),
|
||||
]);
|
||||
|
||||
const elementsNotCompletelyInFrame = prevElementsInFrame.filter(
|
||||
(element) => !elementsCompletelyInFrame.has(element),
|
||||
);
|
||||
|
||||
// for elements that are completely in the frame
|
||||
// if they are part of some groups, then those groups are still
|
||||
// considered to belong to the frame
|
||||
const groupsToKeep = new Set<string>(
|
||||
Array.from(elementsCompletelyInFrame).flatMap(
|
||||
(element) => element.groupIds,
|
||||
),
|
||||
);
|
||||
|
||||
for (const element of elementsNotCompletelyInFrame) {
|
||||
if (!FrameGeometry.isElementIntersectingFrame(element, frame)) {
|
||||
if (element.groupIds.length === 0) {
|
||||
nextElementsInFrame.delete(element);
|
||||
}
|
||||
} else if (element.groupIds.length > 0) {
|
||||
// group element intersects with the frame, we should keep the groups
|
||||
// that this element is part of
|
||||
for (const id of element.groupIds) {
|
||||
groupsToKeep.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of elementsNotCompletelyInFrame) {
|
||||
if (element.groupIds.length > 0) {
|
||||
let shouldRemoveElement = true;
|
||||
|
||||
for (const id of element.groupIds) {
|
||||
if (groupsToKeep.has(id)) {
|
||||
shouldRemoveElement = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRemoveElement) {
|
||||
nextElementsInFrame.delete(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const individualElementsCompletelyInFrame = Array.from(
|
||||
elementsCompletelyInFrame,
|
||||
).filter((element) => element.groupIds.length === 0);
|
||||
|
||||
for (const element of individualElementsCompletelyInFrame) {
|
||||
nextElementsInFrame.add(element);
|
||||
}
|
||||
|
||||
const newGroupElementsCompletelyInFrame = Array.from(
|
||||
elementsCompletelyInFrame,
|
||||
).filter((element) => element.groupIds.length > 0);
|
||||
|
||||
const groupIds = selectGroupsFromGivenElements(
|
||||
newGroupElementsCompletelyInFrame,
|
||||
appState,
|
||||
);
|
||||
|
||||
// new group elements
|
||||
for (const [id, isSelected] of Object.entries(groupIds)) {
|
||||
if (isSelected) {
|
||||
const elementsInGroup = getElementsInGroup(allElements, id);
|
||||
|
||||
if (elementsAreInFrameBounds(elementsInGroup, frame)) {
|
||||
for (const element of elementsInGroup) {
|
||||
nextElementsInFrame.add(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...nextElementsInFrame].filter((element) => {
|
||||
return !(isTextElement(element) && element.containerId);
|
||||
});
|
||||
};
|
||||
|
||||
export const getElementsInNewFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
return omitGroupsContainingFrames(
|
||||
allElements,
|
||||
getElementsCompletelyInFrame(allElements, frame),
|
||||
);
|
||||
};
|
||||
|
||||
export const getContainingFrame = (
|
||||
element: ExcalidrawElement,
|
||||
/**
|
||||
* Optionally an elements map, in case the elements aren't in the Scene yet.
|
||||
* Takes precedence over Scene elements, even if the element exists
|
||||
* in Scene elements and not the supplied elements map.
|
||||
*/
|
||||
elementsMap?: Map<string, ExcalidrawElement>,
|
||||
) => {
|
||||
if (element.frameId) {
|
||||
if (elementsMap) {
|
||||
return (elementsMap.get(element.frameId) ||
|
||||
null) as null | ExcalidrawFrameElement;
|
||||
}
|
||||
return (
|
||||
(Scene.getScene(element)?.getElement(
|
||||
element.frameId,
|
||||
) as ExcalidrawFrameElement) || null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// --------------------------- Frame Operations -------------------------------
|
||||
export const addElementsToFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
elementsToAdd: NonDeletedExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
) => {
|
||||
const _elementsToAdd: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elementsToAdd) {
|
||||
_elementsToAdd.push(element);
|
||||
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
_elementsToAdd.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
|
||||
let nextElements = allElements.slice();
|
||||
|
||||
const frameBoundary = findIndex(nextElements, (e) => e.frameId === frame.id);
|
||||
|
||||
for (const element of omitGroupsContainingFrames(
|
||||
allElements,
|
||||
_elementsToAdd,
|
||||
)) {
|
||||
if (element.frameId !== frame.id && !isFrameElement(element)) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: frame.id,
|
||||
},
|
||||
false,
|
||||
);
|
||||
|
||||
const frameIndex = findIndex(nextElements, (e) => e.id === frame.id);
|
||||
const elementIndex = findIndex(nextElements, (e) => e.id === element.id);
|
||||
|
||||
if (elementIndex < frameBoundary) {
|
||||
nextElements = [
|
||||
...nextElements.slice(0, elementIndex),
|
||||
...nextElements.slice(elementIndex + 1, frameBoundary),
|
||||
element,
|
||||
...nextElements.slice(frameBoundary),
|
||||
];
|
||||
} else if (elementIndex > frameIndex) {
|
||||
nextElements = [
|
||||
...nextElements.slice(0, frameIndex),
|
||||
element,
|
||||
...nextElements.slice(frameIndex, elementIndex),
|
||||
...nextElements.slice(elementIndex + 1),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
export const removeElementsFromFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
elementsToRemove: NonDeletedExcalidrawElement[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
const _elementsToRemove: ExcalidrawElement[] = [];
|
||||
|
||||
for (const element of elementsToRemove) {
|
||||
if (element.frameId) {
|
||||
_elementsToRemove.push(element);
|
||||
const boundTextElement = getBoundTextElement(element);
|
||||
if (boundTextElement) {
|
||||
_elementsToRemove.push(boundTextElement);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const element of _elementsToRemove) {
|
||||
mutateElement(
|
||||
element,
|
||||
{
|
||||
frameId: null,
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const nextElements = moveOneRight(
|
||||
allElements,
|
||||
appState,
|
||||
Array.from(_elementsToRemove),
|
||||
);
|
||||
|
||||
return nextElements;
|
||||
};
|
||||
|
||||
export const removeAllElementsFromFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const elementsInFrame = getFrameElements(allElements, frame.id);
|
||||
return removeElementsFromFrame(allElements, elementsInFrame, appState);
|
||||
};
|
||||
|
||||
export const replaceAllElementsInFrame = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
nextElementsInFrame: ExcalidrawElement[],
|
||||
frame: ExcalidrawFrameElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
return addElementsToFrame(
|
||||
removeAllElementsFromFrame(allElements, frame, appState),
|
||||
nextElementsInFrame,
|
||||
frame,
|
||||
);
|
||||
};
|
||||
|
||||
/** does not mutate elements, but return new ones */
|
||||
export const updateFrameMembershipOfSelectedElements = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const selectedElements = getSelectedElements(allElements, appState);
|
||||
const elementsToFilter = new Set<ExcalidrawElement>(selectedElements);
|
||||
|
||||
if (appState.editingGroupId) {
|
||||
for (const element of selectedElements) {
|
||||
if (element.groupIds.length === 0) {
|
||||
elementsToFilter.add(element);
|
||||
} else {
|
||||
element.groupIds
|
||||
.flatMap((gid) => getElementsInGroup(allElements, gid))
|
||||
.forEach((element) => elementsToFilter.add(element));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elementsToRemove = new Set<ExcalidrawElement>();
|
||||
|
||||
elementsToFilter.forEach((element) => {
|
||||
if (
|
||||
element.frameId &&
|
||||
!isFrameElement(element) &&
|
||||
!isElementInFrame(element, allElements, appState)
|
||||
) {
|
||||
elementsToRemove.add(element);
|
||||
}
|
||||
});
|
||||
|
||||
return elementsToRemove.size > 0
|
||||
? removeElementsFromFrame(allElements, [...elementsToRemove], appState)
|
||||
: allElements;
|
||||
};
|
||||
|
||||
/**
|
||||
* filters out elements that are inside groups that contain a frame element
|
||||
* anywhere in the group tree
|
||||
*/
|
||||
export const omitGroupsContainingFrames = (
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
/** subset of elements you want to filter. Optional perf optimization so we
|
||||
* don't have to filter all elements unnecessarily
|
||||
*/
|
||||
selectedElements?: readonly ExcalidrawElement[],
|
||||
) => {
|
||||
const uniqueGroupIds = new Set<string>();
|
||||
for (const el of selectedElements || allElements) {
|
||||
const topMostGroupId = el.groupIds[el.groupIds.length - 1];
|
||||
if (topMostGroupId) {
|
||||
uniqueGroupIds.add(topMostGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
const rejectedGroupIds = new Set<string>();
|
||||
for (const groupId of uniqueGroupIds) {
|
||||
if (
|
||||
getElementsInGroup(allElements, groupId).some((el) => isFrameElement(el))
|
||||
) {
|
||||
rejectedGroupIds.add(groupId);
|
||||
}
|
||||
}
|
||||
|
||||
return (selectedElements || allElements).filter(
|
||||
(el) => !rejectedGroupIds.has(el.groupIds[el.groupIds.length - 1]),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* depending on the appState, return target frame, which is the frame the given element
|
||||
* is going to be added to or remove from
|
||||
*/
|
||||
export const getTargetFrame = (
|
||||
element: ExcalidrawElement,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element) || element
|
||||
: element;
|
||||
|
||||
return appState.selectedElementIds[_element.id] &&
|
||||
appState.selectedElementsAreBeingDragged
|
||||
? appState.frameToHighlight
|
||||
: getContainingFrame(_element);
|
||||
};
|
||||
|
||||
// given an element, return if the element is in some frame
|
||||
export const isElementInFrame = (
|
||||
element: ExcalidrawElement,
|
||||
allElements: ExcalidrawElementsIncludingDeleted,
|
||||
appState: AppState,
|
||||
) => {
|
||||
const frame = getTargetFrame(element, appState);
|
||||
const _element = isTextElement(element)
|
||||
? getContainerElement(element) || element
|
||||
: element;
|
||||
|
||||
if (frame) {
|
||||
if (_element.groupIds.length === 0) {
|
||||
return elementOverlapsWithFrame(_element, frame);
|
||||
}
|
||||
|
||||
const allElementsInGroup = new Set(
|
||||
_element.groupIds.flatMap((gid) => getElementsInGroup(allElements, gid)),
|
||||
);
|
||||
|
||||
if (appState.editingGroupId && appState.selectedElementsAreBeingDragged) {
|
||||
const selectedElements = new Set(
|
||||
getSelectedElements(allElements, appState),
|
||||
);
|
||||
|
||||
const editingGroupOverlapsFrame = appState.frameToHighlight !== null;
|
||||
|
||||
if (editingGroupOverlapsFrame) {
|
||||
return true;
|
||||
}
|
||||
|
||||
selectedElements.forEach((selectedElement) => {
|
||||
allElementsInGroup.delete(selectedElement);
|
||||
});
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (isFrameElement(elementInGroup)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const elementInGroup of allElementsInGroup) {
|
||||
if (elementOverlapsWithFrame(elementInGroup, frame)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
|
@ -2,6 +2,7 @@ import { GroupId, ExcalidrawElement, NonDeleted } from "./element/types";
|
|||
import { AppState } from "./types";
|
||||
import { getSelectedElements } from "./scene";
|
||||
import { getBoundTextElement } from "./element/textElement";
|
||||
import { makeNextSelectedElementIds } from "./scene/selection";
|
||||
|
||||
export const selectGroup = (
|
||||
groupId: GroupId,
|
||||
|
@ -67,13 +68,21 @@ export const getSelectedGroupIds = (appState: AppState): GroupId[] =>
|
|||
export const selectGroupsForSelectedElements = (
|
||||
appState: AppState,
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
prevAppState: AppState,
|
||||
): AppState => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
const selectedElements = 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,9 +100,39 @@ export const selectGroupsForSelectedElements = (
|
|||
}
|
||||
}
|
||||
|
||||
nextAppState.selectedElementIds = makeNextSelectedElementIds(
|
||||
nextAppState.selectedElementIds,
|
||||
prevAppState,
|
||||
);
|
||||
|
||||
return nextAppState;
|
||||
};
|
||||
|
||||
// given a list of elements, return the the actual group ids that should be selected
|
||||
// or used to update the elements
|
||||
export const selectGroupsFromGivenElements = (
|
||||
elements: readonly NonDeleted<ExcalidrawElement>[],
|
||||
appState: AppState,
|
||||
) => {
|
||||
let nextAppState: AppState = { ...appState, selectedGroupIds: {} };
|
||||
|
||||
for (const element of elements) {
|
||||
let groupIds = element.groupIds;
|
||||
if (appState.editingGroupId) {
|
||||
const indexOfEditingGroup = groupIds.indexOf(appState.editingGroupId);
|
||||
if (indexOfEditingGroup > -1) {
|
||||
groupIds = groupIds.slice(0, indexOfEditingGroup);
|
||||
}
|
||||
}
|
||||
if (groupIds.length > 0) {
|
||||
const groupId = groupIds[groupIds.length - 1];
|
||||
nextAppState = selectGroup(groupId, nextAppState, elements);
|
||||
}
|
||||
}
|
||||
|
||||
return nextAppState.selectedGroupIds;
|
||||
};
|
||||
|
||||
export const editGroupForSelectedElement = (
|
||||
appState: AppState,
|
||||
element: NonDeleted<ExcalidrawElement>,
|
||||
|
@ -186,3 +225,18 @@ export const getMaximumGroups = (
|
|||
|
||||
return Array.from(groups.values());
|
||||
};
|
||||
|
||||
export const elementsAreInSameGroup = (elements: ExcalidrawElement[]) => {
|
||||
const allGroups = elements.flatMap((element) => element.groupIds);
|
||||
const groupCount = new Map<string, number>();
|
||||
let maxGroup = 0;
|
||||
|
||||
for (const group of allGroups) {
|
||||
groupCount.set(group, (groupCount.get(group) ?? 0) + 1);
|
||||
if (groupCount.get(group)! > maxGroup) {
|
||||
maxGroup = groupCount.get(group)!;
|
||||
}
|
||||
}
|
||||
|
||||
return maxGroup === elements.length;
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ export const CODES = {
|
|||
BRACKET_LEFT: "BracketLeft",
|
||||
ONE: "Digit1",
|
||||
TWO: "Digit2",
|
||||
THREE: "Digit3",
|
||||
NINE: "Digit9",
|
||||
QUOTE: "Quote",
|
||||
ZERO: "Digit0",
|
||||
|
@ -42,6 +43,7 @@ export const KEYS = {
|
|||
CHEVRON_RIGHT: ">",
|
||||
PERIOD: ".",
|
||||
COMMA: ",",
|
||||
SUBTRACT: "-",
|
||||
|
||||
A: "a",
|
||||
C: "c",
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "نُشر",
|
||||
"sidebarLock": "إبقاء الشريط الجانبي مفتوح",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "وضع القلم - امنع اللمس",
|
||||
"link": "إضافة/تحديث الرابط للشكل المحدد",
|
||||
"eraser": "ممحاة",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "إجراءات اللوحة",
|
||||
|
|
453
src/locales/az-AZ.json
Normal file
453
src/locales/az-AZ.json
Normal file
|
@ -0,0 +1,453 @@
|
|||
{
|
||||
"labels": {
|
||||
"paste": "Yapışdır",
|
||||
"pasteAsPlaintext": "Düz mətn kimi yapışdırın",
|
||||
"pasteCharts": "Diaqramları yapışdırın",
|
||||
"selectAll": "Hamısını seç",
|
||||
"multiSelect": "Seçimə element əlavə edin",
|
||||
"moveCanvas": "Kanvası köçürün",
|
||||
"cut": "Kəs",
|
||||
"copy": "Kopyala",
|
||||
"copyAsPng": "PNG olaraq panoya kopyala",
|
||||
"copyAsSvg": "SVG olaraq panoya kopyala",
|
||||
"copyText": "Mətn olaraq panoya kopyala",
|
||||
"bringForward": "Önə daşı",
|
||||
"sendToBack": "Geriyə göndərin",
|
||||
"bringToFront": "Önə gətirin",
|
||||
"sendBackward": "Geriyə göndərin",
|
||||
"delete": "Sil",
|
||||
"copyStyles": "Stilləri kopyalayın",
|
||||
"pasteStyles": "Stilləri yapışdırın",
|
||||
"stroke": "Strok rəngi",
|
||||
"background": "Arxa fon",
|
||||
"fill": "Doldur",
|
||||
"strokeWidth": "Strok eni",
|
||||
"strokeStyle": "Strok stili",
|
||||
"strokeStyle_solid": "Solid",
|
||||
"strokeStyle_dashed": "Kəsik",
|
||||
"strokeStyle_dotted": "Nöqtəli",
|
||||
"sloppiness": "Səliqəsizlik",
|
||||
"opacity": "Şəffaflıq",
|
||||
"textAlign": "Mətni uyğunlaşdır",
|
||||
"edges": "Kənarlar",
|
||||
"sharp": "Kəskin",
|
||||
"round": "Dəyirmi",
|
||||
"arrowheads": "Ox ucları",
|
||||
"arrowhead_none": "Heç biri",
|
||||
"arrowhead_arrow": "Ox",
|
||||
"arrowhead_bar": "Çubuq",
|
||||
"arrowhead_dot": "Nöqtə",
|
||||
"arrowhead_triangle": "Üçbucaq",
|
||||
"fontSize": "Şrift ölçüsü",
|
||||
"fontFamily": "Şrift qrupu",
|
||||
"addWatermark": "\"Made with Excalidraw\" əlavə et",
|
||||
"handDrawn": "Əllə çəkilmiş",
|
||||
"normal": "Normal",
|
||||
"code": "Kod",
|
||||
"small": "Kiçik",
|
||||
"medium": "Orta",
|
||||
"large": "Böyük",
|
||||
"veryLarge": "Çox böyük",
|
||||
"solid": "Solid",
|
||||
"hachure": "Ştrix",
|
||||
"zigzag": "Ziqzaq",
|
||||
"crossHatch": "Çarpaz dəlik",
|
||||
"thin": "İncə",
|
||||
"bold": "Qalın",
|
||||
"left": "Sol",
|
||||
"center": "Mərkəz",
|
||||
"right": "Sağ",
|
||||
"extraBold": "Ekstra qalın",
|
||||
"architect": "Memar",
|
||||
"artist": "Rəssam",
|
||||
"cartoonist": "Karikaturaçı",
|
||||
"fileTitle": "Fayl adı",
|
||||
"colorPicker": "Rəng seçən",
|
||||
"canvasColors": "Kanvas üzərində istifadə olunur",
|
||||
"canvasBackground": "Kanvas arxa fonu",
|
||||
"drawingCanvas": "Kanvas çəkmək",
|
||||
"layers": "Qatlar",
|
||||
"actions": "Hərəkətlər",
|
||||
"language": "Dil",
|
||||
"liveCollaboration": "Canlı əməkdaşlıq...",
|
||||
"duplicateSelection": "Dublikat",
|
||||
"untitled": "Başlıqsız",
|
||||
"name": "Ad",
|
||||
"yourName": "Adınız",
|
||||
"madeWithExcalidraw": "Excalidraw ilə hazırlanmışdır",
|
||||
"group": "Qrup şəklində seçim",
|
||||
"ungroup": "Qrupsuz seçim",
|
||||
"collaborators": "",
|
||||
"showGrid": "",
|
||||
"addToLibrary": "",
|
||||
"removeFromLibrary": "",
|
||||
"libraryLoadingMessage": "",
|
||||
"libraries": "",
|
||||
"loadingScene": "",
|
||||
"align": "",
|
||||
"alignTop": "",
|
||||
"alignBottom": "",
|
||||
"alignLeft": "",
|
||||
"alignRight": "",
|
||||
"centerVertically": "",
|
||||
"centerHorizontally": "",
|
||||
"distributeHorizontally": "",
|
||||
"distributeVertically": "",
|
||||
"flipHorizontal": "",
|
||||
"flipVertical": "",
|
||||
"viewMode": "",
|
||||
"share": "",
|
||||
"showStroke": "",
|
||||
"showBackground": "",
|
||||
"toggleTheme": "",
|
||||
"personalLib": "",
|
||||
"excalidrawLib": "",
|
||||
"decreaseFontSize": "",
|
||||
"increaseFontSize": "",
|
||||
"unbindText": "",
|
||||
"bindText": "",
|
||||
"createContainerFromText": "",
|
||||
"link": {
|
||||
"edit": "",
|
||||
"create": "",
|
||||
"label": ""
|
||||
},
|
||||
"lineEditor": {
|
||||
"edit": "",
|
||||
"exit": ""
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "",
|
||||
"unlock": "",
|
||||
"lockAll": "",
|
||||
"unlockAll": ""
|
||||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
"noItems": "",
|
||||
"hint_emptyLibrary": "",
|
||||
"hint_emptyPrivateLibrary": ""
|
||||
},
|
||||
"buttons": {
|
||||
"clearReset": "",
|
||||
"exportJSON": "",
|
||||
"exportImage": "",
|
||||
"export": "",
|
||||
"copyToClipboard": "",
|
||||
"save": "",
|
||||
"saveAs": "",
|
||||
"load": "",
|
||||
"getShareableLink": "",
|
||||
"close": "",
|
||||
"selectLanguage": "",
|
||||
"scrollBackToContent": "",
|
||||
"zoomIn": "",
|
||||
"zoomOut": "",
|
||||
"resetZoom": "",
|
||||
"menu": "",
|
||||
"done": "",
|
||||
"edit": "",
|
||||
"undo": "",
|
||||
"redo": "",
|
||||
"resetLibrary": "",
|
||||
"createNewRoom": "",
|
||||
"fullScreen": "",
|
||||
"darkMode": "",
|
||||
"lightMode": "",
|
||||
"zenMode": "",
|
||||
"exitZenMode": "",
|
||||
"cancel": "",
|
||||
"clear": "",
|
||||
"remove": "",
|
||||
"publishLibrary": "",
|
||||
"submit": "",
|
||||
"confirm": ""
|
||||
},
|
||||
"alerts": {
|
||||
"clearReset": "",
|
||||
"couldNotCreateShareableLink": "",
|
||||
"couldNotCreateShareableLinkTooBig": "",
|
||||
"couldNotLoadInvalidFile": "",
|
||||
"importBackendFailed": "",
|
||||
"cannotExportEmptyCanvas": "",
|
||||
"couldNotCopyToClipboard": "",
|
||||
"decryptFailed": "",
|
||||
"uploadedSecurly": "",
|
||||
"loadSceneOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "",
|
||||
"errorAddingToLibrary": "",
|
||||
"errorRemovingFromLibrary": "",
|
||||
"confirmAddLibrary": "",
|
||||
"imageDoesNotContainScene": "",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": "",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "",
|
||||
"image": "",
|
||||
"rectangle": "",
|
||||
"diamond": "",
|
||||
"ellipse": "",
|
||||
"arrow": "",
|
||||
"line": "",
|
||||
"freedraw": "",
|
||||
"text": "",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"shapes": ""
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"linearElement": "",
|
||||
"freeDraw": "",
|
||||
"text": "",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "",
|
||||
"firefox_clipboard_write": ""
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"trackedToSentry": "",
|
||||
"openIssueMessage": "",
|
||||
"sceneContent": ""
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": ""
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_button": "",
|
||||
"link_title": "",
|
||||
"link_details": "",
|
||||
"link_button": "",
|
||||
"excalidrawplus_description": "",
|
||||
"excalidrawplus_button": "",
|
||||
"excalidrawplus_exportError": ""
|
||||
},
|
||||
"helpDialog": {
|
||||
"blog": "",
|
||||
"click": "",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"curvedArrow": "",
|
||||
"curvedLine": "",
|
||||
"documentation": "",
|
||||
"doubleClick": "",
|
||||
"drag": "",
|
||||
"editor": "",
|
||||
"editLineArrowPoints": "",
|
||||
"editText": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"or": "",
|
||||
"preventBinding": "",
|
||||
"tools": "",
|
||||
"shortcuts": "",
|
||||
"textFinish": "",
|
||||
"textNewLine": "",
|
||||
"title": "",
|
||||
"view": "",
|
||||
"zoomToFit": "",
|
||||
"zoomToSelection": "",
|
||||
"toggleElementLock": "",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": ""
|
||||
},
|
||||
"publishDialog": {
|
||||
"title": "",
|
||||
"itemName": "",
|
||||
"authorName": "",
|
||||
"githubUsername": "",
|
||||
"twitterUsername": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"website": "",
|
||||
"placeholder": {
|
||||
"authorName": "",
|
||||
"libraryName": "",
|
||||
"libraryDesc": "",
|
||||
"githubHandle": "",
|
||||
"twitterHandle": "",
|
||||
"website": ""
|
||||
},
|
||||
"errors": {
|
||||
"required": "",
|
||||
"website": ""
|
||||
},
|
||||
"noteDescription": "",
|
||||
"noteGuidelines": "",
|
||||
"noteLicense": "",
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "",
|
||||
"content": ""
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromLib": ""
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "",
|
||||
"link": ""
|
||||
},
|
||||
"stats": {
|
||||
"angle": "",
|
||||
"element": "",
|
||||
"elements": "",
|
||||
"height": "",
|
||||
"scene": "",
|
||||
"selected": "",
|
||||
"storage": "",
|
||||
"title": "",
|
||||
"total": "",
|
||||
"version": "",
|
||||
"versionCopy": "",
|
||||
"versionNotAvailable": "",
|
||||
"width": ""
|
||||
},
|
||||
"toast": {
|
||||
"addedToLibrary": "",
|
||||
"copyStyles": "",
|
||||
"copyToClipboard": "",
|
||||
"copyToClipboardAsPng": "",
|
||||
"fileSaved": "",
|
||||
"fileSavedToFilename": "",
|
||||
"canvas": "",
|
||||
"selection": "",
|
||||
"pasteAsSingleElement": ""
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "",
|
||||
"black": "",
|
||||
"white": "",
|
||||
"red": "",
|
||||
"pink": "",
|
||||
"grape": "",
|
||||
"violet": "",
|
||||
"gray": "",
|
||||
"blue": "",
|
||||
"cyan": "",
|
||||
"teal": "",
|
||||
"green": "",
|
||||
"yellow": "",
|
||||
"orange": "",
|
||||
"bronze": ""
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"mostUsedCustomColors": "",
|
||||
"colors": "",
|
||||
"shades": "",
|
||||
"hexCode": "",
|
||||
"noShades": ""
|
||||
}
|
||||
}
|
|
@ -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ç",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"labels": {
|
||||
"paste": "Vložit",
|
||||
"pasteAsPlaintext": "",
|
||||
"pasteAsPlaintext": "Vložit jako prostý text",
|
||||
"pasteCharts": "Vložit grafy",
|
||||
"selectAll": "Vybrat vše",
|
||||
"multiSelect": "Přidat prvek do výběru",
|
||||
|
@ -49,9 +49,9 @@
|
|||
"large": "Velké",
|
||||
"veryLarge": "Velmi velké",
|
||||
"solid": "Plný",
|
||||
"hachure": "",
|
||||
"zigzag": "",
|
||||
"crossHatch": "",
|
||||
"hachure": "Hachure",
|
||||
"zigzag": "Klikatě",
|
||||
"crossHatch": "Křížový šrafování",
|
||||
"thin": "Tenký",
|
||||
"bold": "Tlustý",
|
||||
"left": "Vlevo",
|
||||
|
@ -60,7 +60,7 @@
|
|||
"extraBold": "Extra tlustý",
|
||||
"architect": "Architekt",
|
||||
"artist": "Umělec",
|
||||
"cartoonist": "",
|
||||
"cartoonist": "Kartoonista",
|
||||
"fileTitle": "Název souboru",
|
||||
"colorPicker": "Výběr barvy",
|
||||
"canvasColors": "Použito na plátně",
|
||||
|
@ -106,7 +106,7 @@
|
|||
"increaseFontSize": "Zvětšit písmo",
|
||||
"unbindText": "Zrušit vazbu textu",
|
||||
"bindText": "Vázat text s kontejnerem",
|
||||
"createContainerFromText": "",
|
||||
"createContainerFromText": "Zabalit text do kontejneru",
|
||||
"link": {
|
||||
"edit": "Upravit odkaz",
|
||||
"create": "Vytvořit odkaz",
|
||||
|
@ -124,7 +124,9 @@
|
|||
},
|
||||
"statusPublished": "Zveřejněno",
|
||||
"sidebarLock": "Ponechat postranní panel otevřený",
|
||||
"eyeDropper": ""
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": "Vyberte barvu z plátna"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Dosud neexistují žádné položky...",
|
||||
|
@ -177,38 +179,38 @@
|
|||
"decryptFailed": "Nelze dešifrovat data.",
|
||||
"uploadedSecurly": "Nahrávání je zabezpečeno koncovým šifrováním, což znamená, že server Excalidraw ani třetí strany nemohou obsah přečíst.",
|
||||
"loadSceneOverridePrompt": "Načítání externího výkresu nahradí váš existující obsah. Přejete si pokračovat?",
|
||||
"collabStopOverridePrompt": "",
|
||||
"collabStopOverridePrompt": "Zastavení relace přepíše vaše předchozí, lokálně uložené kresby. Jste si jisti?\n\n(Pokud chcete zachovat místní kresbu, jednoduše zavřete kartu prohlížeče)",
|
||||
"errorAddingToLibrary": "Položku nelze přidat do knihovny",
|
||||
"errorRemovingFromLibrary": "Položku nelze odstranit z knihovny",
|
||||
"confirmAddLibrary": "Tímto přidáte {{numShapes}} tvarů do tvé knihovny. Jste si jisti?",
|
||||
"imageDoesNotContainScene": "Zdá se, že tento obrázek neobsahuje žádná data o scéně. Zapnuli jste při exportu vkládání scény?",
|
||||
"cannotRestoreFromImage": "",
|
||||
"invalidSceneUrl": "",
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromsLibrary": "",
|
||||
"invalidEncryptionKey": "",
|
||||
"collabOfflineWarning": ""
|
||||
"cannotRestoreFromImage": "Scénu nelze obnovit z tohoto souboru obrázku",
|
||||
"invalidSceneUrl": "Nelze importovat scénu z zadané URL. Je buď poškozená, nebo neobsahuje platná JSON data Excalidraw.",
|
||||
"resetLibrary": "Tímto vymažete vaši knihovnu. Jste si jisti?",
|
||||
"removeItemsFromsLibrary": "Smazat {{count}} položek z knihovny?",
|
||||
"invalidEncryptionKey": "Šifrovací klíč musí mít 22 znaků. Live spolupráce je zakázána.",
|
||||
"collabOfflineWarning": "Není k dispozici žádné internetové připojení.\nVaše změny nebudou uloženy!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "",
|
||||
"imageInsertError": "",
|
||||
"fileTooBig": "",
|
||||
"svgImageInsertError": "",
|
||||
"invalidSVGString": "",
|
||||
"cannotResolveCollabServer": "",
|
||||
"importLibraryError": "",
|
||||
"collabSaveFailed": "",
|
||||
"collabSaveFailed_sizeExceeded": "",
|
||||
"unsupportedFileType": "Nepodporovaný typ souboru.",
|
||||
"imageInsertError": "Nelze vložit obrázek. Zkuste to později...",
|
||||
"fileTooBig": "Soubor je příliš velký. Maximální povolená velikost je {{maxSize}}.",
|
||||
"svgImageInsertError": "Nelze vložit SVG obrázek. Značení SVG je neplatné.",
|
||||
"invalidSVGString": "Neplatný SVG.",
|
||||
"cannotResolveCollabServer": "Nelze se připojit ke sdílenému serveru. Prosím obnovte stránku a zkuste to znovu.",
|
||||
"importLibraryError": "Nelze načíst knihovnu",
|
||||
"collabSaveFailed": "Nelze uložit do databáze na serveru. Pokud problémy přetrvávají, měli byste uložit soubor lokálně, abyste se ujistili, že neztratíte svou práci.",
|
||||
"collabSaveFailed_sizeExceeded": "Nelze uložit do databáze na serveru, plátno se zdá být příliš velké. Měli byste uložit soubor lokálně, abyste se ujistili, že neztratíte svou práci.",
|
||||
"brave_measure_text_error": {
|
||||
"line1": "",
|
||||
"line2": "",
|
||||
"line3": "",
|
||||
"line4": ""
|
||||
"line1": "Vypadá to, že používáte Brave prohlížeč s povoleným nastavením <bold>Aggressively Block Fingerprinting</bold>.",
|
||||
"line2": "To by mohlo vést k narušení <bold>Textových elementů</bold> ve vašich výkresech.",
|
||||
"line3": "Důrazně doporučujeme zakázat toto nastavení. Můžete sledovat <link>tyto kroky</link> jak to udělat.",
|
||||
"line4": "Pokud vypnutí tohoto nastavení neopravuje zobrazení textových prvků, prosím, otevřete <issueLink>problém</issueLink> na našem GitHubu, nebo nám napište na <discordLink>Discord</discordLink>"
|
||||
}
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Výběr",
|
||||
"image": "",
|
||||
"image": "Vložit obrázek",
|
||||
"rectangle": "Obdélník",
|
||||
"diamond": "Diamant",
|
||||
"ellipse": "Elipsa",
|
||||
|
@ -216,69 +218,71 @@
|
|||
"line": "Čára",
|
||||
"freedraw": "Kreslení",
|
||||
"text": "Text",
|
||||
"library": "",
|
||||
"lock": "",
|
||||
"penMode": "",
|
||||
"link": "",
|
||||
"library": "Knihovna",
|
||||
"lock": "Po kreslení ponechat vybraný nástroj aktivní",
|
||||
"penMode": "Režim Pera - zabránit dotyku",
|
||||
"link": "Přidat/aktualizovat odkaz pro vybraný tvar",
|
||||
"eraser": "Guma",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "Ruka (nástroj pro posouvání)",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
"selectedShapeActions": "",
|
||||
"canvasActions": "Akce plátna",
|
||||
"selectedShapeActions": "Akce vybraného tvaru",
|
||||
"shapes": "Tvary"
|
||||
},
|
||||
"hints": {
|
||||
"canvasPanning": "",
|
||||
"linearElement": "",
|
||||
"freeDraw": "",
|
||||
"text": "",
|
||||
"text_selected": "",
|
||||
"text_editing": "",
|
||||
"linearElementMulti": "",
|
||||
"lockAngle": "",
|
||||
"resize": "",
|
||||
"resizeImage": "",
|
||||
"rotate": "",
|
||||
"lineEditor_info": "",
|
||||
"lineEditor_pointSelected": "",
|
||||
"lineEditor_nothingSelected": "",
|
||||
"placeImage": "",
|
||||
"publishLibrary": "",
|
||||
"bindTextToElement": "",
|
||||
"deepBoxSelect": "",
|
||||
"eraserRevert": "",
|
||||
"firefox_clipboard_write": ""
|
||||
"canvasPanning": "Chcete-li přesunout plátno, podržte kolečko nebo mezerník při tažení nebo použijte nástroj Ruka",
|
||||
"linearElement": "Kliknutím pro více bodů, táhnutím pro jednu čáru",
|
||||
"freeDraw": "Klikněte a táhněte, pro ukončení pusťte",
|
||||
"text": "Tip: Text můžete také přidat dvojitým kliknutím kdekoli pomocí nástroje pro výběr",
|
||||
"text_selected": "Dvojklikem nebo stisknutím klávesy ENTER upravíte text",
|
||||
"text_editing": "Stiskněte Escape nebo Ctrl/Cmd+ENTER pro dokončení úprav",
|
||||
"linearElementMulti": "Klikněte na poslední bod nebo stiskněte Escape anebo Enter pro dokončení",
|
||||
"lockAngle": "Úhel můžete omezit podržením SHIFT",
|
||||
"resize": "Můžete omezit proporce podržením SHIFT při změně velikosti,\npodržte ALT pro změnu velikosti od středu",
|
||||
"resizeImage": "Můžete volně změnit velikost podržením SHIFT,\npodržením klávesy ALT změníte velikosti od středu",
|
||||
"rotate": "Úhly můžete omezit podržením SHIFT při otáčení",
|
||||
"lineEditor_info": "Podržte Ctrl/Cmd a dvakrát klikněte nebo stiskněte Ctrl/Cmd + Enter pro úpravu bodů",
|
||||
"lineEditor_pointSelected": "Stisknutím tlačítka Delete odstraňte bod(y),\nCtrl/Cmd+D pro duplicitu nebo táhnutím pro přesun",
|
||||
"lineEditor_nothingSelected": "Vyberte bod, který chcete upravit (podržením klávesy SHIFT vyberete více položek),\nnebo podržením klávesy Alt a kliknutím přidáte nové body",
|
||||
"placeImage": "Kliknutím umístěte obrázek, nebo klepnutím a přetažením ručně nastavíte jeho velikost",
|
||||
"publishLibrary": "Publikovat vlastní knihovnu",
|
||||
"bindTextToElement": "Stiskněte Enter pro přidání textu",
|
||||
"deepBoxSelect": "Podržte Ctrl/Cmd pro hluboký výběr a pro zabránění táhnutí",
|
||||
"eraserRevert": "Podržením klávesy Alt vrátíte prvky označené pro smazání",
|
||||
"firefox_clipboard_write": "Tato funkce může být povolena nastavením vlajky \"dom.events.asyncClipboard.clipboardItem\" na \"true\". Chcete-li změnit vlajky prohlížeče ve Firefoxu, navštivte stránku \"about:config\"."
|
||||
},
|
||||
"canvasError": {
|
||||
"cannotShowPreview": "",
|
||||
"canvasTooBig": "",
|
||||
"canvasTooBigTip": ""
|
||||
"cannotShowPreview": "Náhled nelze zobrazit",
|
||||
"canvasTooBig": "Plátno je možná příliš velké.",
|
||||
"canvasTooBigTip": "Tip: zkus posunout nejvzdálenější prvky trochu blíže k sobě."
|
||||
},
|
||||
"errorSplash": {
|
||||
"headingMain": "",
|
||||
"clearCanvasMessage": "",
|
||||
"clearCanvasCaveat": "",
|
||||
"headingMain": "Chyba. Zkuste <button>znovu načíst stránku</button>.",
|
||||
"clearCanvasMessage": "Pokud opětovné načtení nefunguje, zkuste <button>vymazat plátno</button>.",
|
||||
"clearCanvasCaveat": " To povede ke ztrátě dat ",
|
||||
"trackedToSentry": "Chyba identifikátoru {{eventId}} byl zaznamenán v našem systému.",
|
||||
"openIssueMessage": "",
|
||||
"sceneContent": ""
|
||||
"openIssueMessage": "Byli jsme velmi opatrní, abychom neuváděli informace o Vaší scéně. Pokud vaše scéna není soukromá, zvažte prosím sledování na našem <button>bug trackeru</button>. Uveďte prosím níže uvedené informace kopírováním a vložením do problému na GitHubu.",
|
||||
"sceneContent": "Obsah scény:"
|
||||
},
|
||||
"roomDialog": {
|
||||
"desc_intro": "",
|
||||
"desc_privacy": "",
|
||||
"button_startSession": "",
|
||||
"button_stopSession": "",
|
||||
"desc_inProgressIntro": "",
|
||||
"desc_shareLink": "",
|
||||
"desc_exitSession": "",
|
||||
"shareTitle": ""
|
||||
"desc_intro": "Můžete pozvat lidi na vaši aktuální scénu ke spolupráci s vámi.",
|
||||
"desc_privacy": "Nebojte se, relace používá end-to-end šifrování, takže cokoliv nakreslíte zůstane soukromé. Ani náš server nebude schopen vidět, s čím budete pracovat.",
|
||||
"button_startSession": "Zahájit relaci",
|
||||
"button_stopSession": "Ukončit relaci",
|
||||
"desc_inProgressIntro": "Živá spolupráce právě probíhá.",
|
||||
"desc_shareLink": "Sdílejte tento odkaz s každým, s kým chcete spolupracovat:",
|
||||
"desc_exitSession": "Zastavením relace se odpojíte od místnosti, ale budete moci pokračovat v práci s touto scénou lokálně. Všimněte si, že to nebude mít vliv na ostatní lidi a budou stále moci spolupracovat na jejich verzi.",
|
||||
"shareTitle": "Připojte se k aktivní spolupráci na Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": ""
|
||||
"title": "Chyba"
|
||||
},
|
||||
"exportDialog": {
|
||||
"disk_title": "",
|
||||
"disk_details": "",
|
||||
"disk_title": "Uložit na disk",
|
||||
"disk_details": "Exportovat data scény do souboru, ze kterého můžete importovat později.",
|
||||
"disk_button": "Uložit do souboru",
|
||||
"link_title": "Odkaz pro sdílení",
|
||||
"link_details": "Exportovat jako odkaz pouze pro čtení.",
|
||||
|
@ -290,18 +294,18 @@
|
|||
"helpDialog": {
|
||||
"blog": "Přečtěte si náš blog",
|
||||
"click": "kliknutí",
|
||||
"deepSelect": "",
|
||||
"deepBoxSelect": "",
|
||||
"deepSelect": "Hluboký výběr",
|
||||
"deepBoxSelect": "Hluboký výběr uvnitř boxu a zabránění táhnnutí",
|
||||
"curvedArrow": "Zakřivená šipka",
|
||||
"curvedLine": "Zakřivená čára",
|
||||
"documentation": "Dokumentace",
|
||||
"doubleClick": "dvojklik",
|
||||
"drag": "tažení",
|
||||
"editor": "Editor",
|
||||
"editLineArrowPoints": "",
|
||||
"editText": "",
|
||||
"github": "",
|
||||
"howto": "",
|
||||
"editLineArrowPoints": "Upravit body linií/šipek",
|
||||
"editText": "Upravit text / přidat popis",
|
||||
"github": "Našel jsi problém? Nahlaš ho",
|
||||
"howto": "Sledujte naše návody",
|
||||
"or": "nebo",
|
||||
"preventBinding": "Zabránit vázání šipky",
|
||||
"tools": "Nástroje",
|
||||
|
@ -313,8 +317,8 @@
|
|||
"zoomToFit": "Přiblížit na zobrazení všech prvků",
|
||||
"zoomToSelection": "Přiblížit na výběr",
|
||||
"toggleElementLock": "Zamknout/odemknout výběr",
|
||||
"movePageUpDown": "",
|
||||
"movePageLeftRight": ""
|
||||
"movePageUpDown": "Posunout stránku nahoru/dolů",
|
||||
"movePageLeftRight": "Přesunout stránku doleva/doprava"
|
||||
},
|
||||
"clearCanvasDialog": {
|
||||
"title": "Vymazat plátno"
|
||||
|
@ -342,46 +346,46 @@
|
|||
},
|
||||
"noteDescription": "Odešlete svou knihovnu, pro zařazení do <link>veřejného úložiště knihoven</link>, odkud ji budou moci při kreslení využít i ostatní uživatelé.",
|
||||
"noteGuidelines": "Knihovna musí být nejdříve ručně schválena. Přečtěte si prosím <link>pokyny</link>",
|
||||
"noteLicense": "",
|
||||
"noteItems": "",
|
||||
"atleastOneLibItem": "",
|
||||
"republishWarning": ""
|
||||
"noteLicense": "Odesláním souhlasíte s tím, že knihovna bude zveřejněna pod <link>MIT licencí</link>, stručně řečeno, kdokoli ji může používat bez omezení.",
|
||||
"noteItems": "Každá položka knihovny musí mít svůj vlastní název, aby byla filtrovatelná. Následující položky knihovny budou zahrnuty:",
|
||||
"atleastOneLibItem": "Vyberte alespoň jednu položku knihovny, kterou chcete začít",
|
||||
"republishWarning": "Poznámka: některé z vybraných položek jsou označeny jako již zveřejněné/odeslané. Položky byste měli znovu odeslat pouze při aktualizaci existující knihovny nebo podání."
|
||||
},
|
||||
"publishSuccessDialog": {
|
||||
"title": "Knihovna byla odeslána",
|
||||
"content": ""
|
||||
"content": "Děkujeme vám {{authorName}}. Vaše knihovna byla odeslána k posouzení. Stav můžete sledovat <link>zde</link>"
|
||||
},
|
||||
"confirmDialog": {
|
||||
"resetLibrary": "",
|
||||
"removeItemsFromLib": ""
|
||||
"resetLibrary": "Resetovat knihovnu",
|
||||
"removeItemsFromLib": "Odstranit vybrané položky z knihovny"
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"header": "Exportovat obrázek",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
"withBackground": "Pozadí",
|
||||
"onlySelected": "Pouze vybrané",
|
||||
"darkMode": "Tmavý režim",
|
||||
"embedScene": "Vložit scénu",
|
||||
"scale": "Měřítko",
|
||||
"padding": "Odsazení"
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
"embedScene": "Data scény budou uložena do exportovaného souboru PNG/SVG tak, aby z něj mohla být scéna obnovena.\nZvýší se velikost exportovaného souboru."
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "Exportovat do PNG",
|
||||
"exportToSvg": "Exportovat do SVG",
|
||||
"copyPngToClipboard": "Kopírovat PNG do schránky"
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "PNG",
|
||||
"exportToSvg": "SVG",
|
||||
"copyPngToClipboard": "Kopírovat do schránky"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
"tooltip": "",
|
||||
"link": ""
|
||||
"tooltip": "Vaše kresby jsou end-to-end šifrované, takže servery Excalidraw je nikdy neuvidí.",
|
||||
"link": "Blog příspěvek na end-to-end šifrování v Excalidraw"
|
||||
},
|
||||
"stats": {
|
||||
"angle": "Úhel",
|
||||
|
@ -402,48 +406,48 @@
|
|||
"addedToLibrary": "Přidáno do knihovny",
|
||||
"copyStyles": "Styly byly zkopírovány.",
|
||||
"copyToClipboard": "Zkopírováno do schránky.",
|
||||
"copyToClipboardAsPng": "",
|
||||
"copyToClipboardAsPng": "{{exportSelection}} zkopírován do schránky jako PNG\n({{exportColorScheme}})",
|
||||
"fileSaved": "Soubor byl uložen.",
|
||||
"fileSavedToFilename": "Uloženo do {filename}",
|
||||
"canvas": "plátno",
|
||||
"selection": "výběr",
|
||||
"pasteAsSingleElement": ""
|
||||
"pasteAsSingleElement": "Pomocí {{shortcut}} vložte jako jeden prvek,\nnebo vložte do existujícího textového editoru"
|
||||
},
|
||||
"colors": {
|
||||
"transparent": "Průhledná",
|
||||
"black": "",
|
||||
"white": "",
|
||||
"red": "",
|
||||
"pink": "",
|
||||
"grape": "",
|
||||
"violet": "",
|
||||
"gray": "",
|
||||
"blue": "",
|
||||
"cyan": "",
|
||||
"teal": "",
|
||||
"green": "",
|
||||
"yellow": "",
|
||||
"orange": "",
|
||||
"bronze": ""
|
||||
"black": "Černá",
|
||||
"white": "Bílá",
|
||||
"red": "Červená",
|
||||
"pink": "Růžová",
|
||||
"grape": "Vínová",
|
||||
"violet": "Fialová",
|
||||
"gray": "Šedá",
|
||||
"blue": "Modrá",
|
||||
"cyan": "Azurová",
|
||||
"teal": "Modrozelená",
|
||||
"green": "Zelená",
|
||||
"yellow": "Žlutá",
|
||||
"orange": "Oranžová",
|
||||
"bronze": "Bronzová"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
"center_heading": "",
|
||||
"center_heading_plus": "",
|
||||
"menuHint": ""
|
||||
"center_heading": "Všechna vaše data jsou uložena lokálně ve vašem prohlížeči.",
|
||||
"center_heading_plus": "Chcete místo toho přejít na Excalidraw+?",
|
||||
"menuHint": "Export, nastavení, jazyky, ..."
|
||||
},
|
||||
"defaults": {
|
||||
"menuHint": "",
|
||||
"center_heading": "",
|
||||
"toolbarHint": "",
|
||||
"helpHint": ""
|
||||
"menuHint": "Export, nastavení a další...",
|
||||
"center_heading": "Diagramy. Vytvořeny. Jednoduše.",
|
||||
"toolbarHint": "Vyberte nástroj a začněte kreslit!",
|
||||
"helpHint": "Zkratky a pomoc"
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"mostUsedCustomColors": "",
|
||||
"colors": "",
|
||||
"shades": "",
|
||||
"hexCode": "",
|
||||
"noShades": ""
|
||||
"mostUsedCustomColors": "Nejpoužívanější vlastní barvy",
|
||||
"colors": "Barvy",
|
||||
"shades": "Stíny",
|
||||
"hexCode": "Hex kód",
|
||||
"noShades": "Pro tuto barvu nejsou k dispozici žádné odstíny"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "",
|
||||
"sidebarLock": "",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "",
|
||||
"link": "",
|
||||
"eraser": "",
|
||||
"hand": ""
|
||||
"frame": "",
|
||||
"hand": "",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "",
|
||||
|
|
|
@ -124,7 +124,9 @@
|
|||
},
|
||||
"statusPublished": "Veröffentlicht",
|
||||
"sidebarLock": "Seitenleiste offen lassen",
|
||||
"eyeDropper": ""
|
||||
"selectAllElementsInFrame": "Alle Elemente im Rahmen auswählen",
|
||||
"removeAllElementsFromFrame": "Alle Elemente aus dem Rahmen entfernen",
|
||||
"eyeDropper": "Farbe von der Zeichenfläche auswählen"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Noch keine Elemente hinzugefügt...",
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "Stift-Modus - Berührung verhindern",
|
||||
"link": "Link für ausgewählte Form hinzufügen / aktualisieren",
|
||||
"eraser": "Radierer",
|
||||
"hand": "Hand (Schwenkwerkzeug)"
|
||||
"frame": "Rahmenwerkzeug",
|
||||
"hand": "Hand (Schwenkwerkzeug)",
|
||||
"extraTools": "Weitere Werkzeuge"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Aktionen für Zeichenfläche",
|
||||
|
|
|
@ -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": "Published",
|
||||
"sidebarLock": "Keep sidebar open",
|
||||
"selectAllElementsInFrame": "Select all elements in frame",
|
||||
"removeAllElementsFromFrame": "Remove all elements from frame",
|
||||
"eyeDropper": "Pick color from canvas"
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "Pen mode - prevent touch",
|
||||
"link": "Add/ Update link for a selected shape",
|
||||
"eraser": "Eraser",
|
||||
"hand": "Hand (panning tool)"
|
||||
"frame": "Frame tool",
|
||||
"hand": "Hand (panning tool)",
|
||||
"extraTools": "More tools"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas actions",
|
||||
|
@ -445,5 +449,36 @@
|
|||
"shades": "Shades",
|
||||
"hexCode": "Hex code",
|
||||
"noShades": "No shades available for this color"
|
||||
},
|
||||
"overwriteConfirm": {
|
||||
"action": {
|
||||
"exportToImage": {
|
||||
"title": "Export as image",
|
||||
"button": "Export as image",
|
||||
"description": "Export the scene data as an image from which you can import later."
|
||||
},
|
||||
"saveToDisk": {
|
||||
"title": "Save to disk",
|
||||
"button": "Save to disk",
|
||||
"description": "Export the scene data to a file from which you can import later."
|
||||
},
|
||||
"excalidrawPlus": {
|
||||
"title": "Excalidraw+",
|
||||
"button": "Export to Excalidraw+",
|
||||
"description": "Save the scene to your Excalidraw+ workspace."
|
||||
}
|
||||
},
|
||||
"modal": {
|
||||
"loadFromFile": {
|
||||
"title": "Load from file",
|
||||
"button": "Load from file",
|
||||
"description": "Loading from a file will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first using one of the options below."
|
||||
},
|
||||
"shareableLink": {
|
||||
"title": "Load from link",
|
||||
"button": "Replace my content",
|
||||
"description": "Loading external drawing will <bold>replace your existing content</bold>.<br></br>You can back up your drawing first by using one of the options below."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "Publicado",
|
||||
"sidebarLock": "Mantener barra lateral abierta",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "Modo Lápiz - previene toque",
|
||||
"link": "Añadir/Actualizar enlace para una forma seleccionada",
|
||||
"eraser": "Borrar",
|
||||
"hand": "Mano (herramienta de panoramización)"
|
||||
"frame": "",
|
||||
"hand": "Mano (herramienta de panoramización)",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Acciones del lienzo",
|
||||
|
|
|
@ -124,7 +124,9 @@
|
|||
},
|
||||
"statusPublished": "Argitaratua",
|
||||
"sidebarLock": "Mantendu alboko barra irekita",
|
||||
"eyeDropper": ""
|
||||
"selectAllElementsInFrame": "Hautatu markoko elementu guztiak",
|
||||
"removeAllElementsFromFrame": "Kendu markoko elementu guztiak",
|
||||
"eyeDropper": "Aukeratu kolorea oihaletik"
|
||||
},
|
||||
"library": {
|
||||
"noItems": "Oraindik ez da elementurik gehitu...",
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "Luma modua - ukipena saihestu",
|
||||
"link": "Gehitu / Eguneratu esteka hautatutako forma baterako",
|
||||
"eraser": "Borragoma",
|
||||
"hand": "Eskua (panoratze tresna)"
|
||||
"frame": "Marko tresna",
|
||||
"hand": "Eskua (panoratze tresna)",
|
||||
"extraTools": "Tresna gehiago"
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "Canvas ekintzak",
|
||||
|
@ -356,27 +360,27 @@
|
|||
"removeItemsFromLib": "Kendu hautatutako elementuak liburutegitik"
|
||||
},
|
||||
"imageExportDialog": {
|
||||
"header": "",
|
||||
"header": "Esportatu irudia",
|
||||
"label": {
|
||||
"withBackground": "",
|
||||
"onlySelected": "",
|
||||
"darkMode": "",
|
||||
"embedScene": "",
|
||||
"scale": "",
|
||||
"padding": ""
|
||||
"withBackground": "Atzeko planoa",
|
||||
"onlySelected": "Hautapena soilik",
|
||||
"darkMode": "Modu iluna",
|
||||
"embedScene": "Txertatu eszena",
|
||||
"scale": "Eskala",
|
||||
"padding": "Betegarria"
|
||||
},
|
||||
"tooltip": {
|
||||
"embedScene": ""
|
||||
"embedScene": "Eszenaren datuak esportatutako PNG/SVG fitxategian gordeko dira, eszena bertatik berrezartzeko.\nEsportatutako fitxategien tamaina handituko da."
|
||||
},
|
||||
"title": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "Esportatu PNG gisa",
|
||||
"exportToSvg": "Esportatu SVG gisa",
|
||||
"copyPngToClipboard": "Kopiatu PNG arbelera"
|
||||
},
|
||||
"button": {
|
||||
"exportToPng": "",
|
||||
"exportToSvg": "",
|
||||
"copyPngToClipboard": ""
|
||||
"exportToPng": "PNG",
|
||||
"exportToSvg": "SVG",
|
||||
"copyPngToClipboard": "Kopiatu arbelean"
|
||||
}
|
||||
},
|
||||
"encrypted": {
|
||||
|
@ -411,20 +415,20 @@
|
|||
},
|
||||
"colors": {
|
||||
"transparent": "Gardena",
|
||||
"black": "",
|
||||
"white": "",
|
||||
"red": "",
|
||||
"pink": "",
|
||||
"grape": "",
|
||||
"violet": "",
|
||||
"gray": "",
|
||||
"blue": "",
|
||||
"cyan": "",
|
||||
"teal": "",
|
||||
"green": "",
|
||||
"yellow": "",
|
||||
"orange": "",
|
||||
"bronze": ""
|
||||
"black": "Beltza",
|
||||
"white": "Zuria",
|
||||
"red": "Gorria",
|
||||
"pink": "Arrosa",
|
||||
"grape": "Mahats kolorea",
|
||||
"violet": "Bioleta",
|
||||
"gray": "Grisa",
|
||||
"blue": "Urdina",
|
||||
"cyan": "Ziana",
|
||||
"teal": "Berde urdinxka",
|
||||
"green": "Berdea",
|
||||
"yellow": "Horia",
|
||||
"orange": "Laranja",
|
||||
"bronze": "Brontzea"
|
||||
},
|
||||
"welcomeScreen": {
|
||||
"app": {
|
||||
|
@ -440,10 +444,10 @@
|
|||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
"mostUsedCustomColors": "",
|
||||
"colors": "",
|
||||
"shades": "",
|
||||
"hexCode": "",
|
||||
"noShades": ""
|
||||
"mostUsedCustomColors": "Gehien erabilitako kolore pertsonalizatuak",
|
||||
"colors": "Koloreak",
|
||||
"shades": "Ñabardurak",
|
||||
"hexCode": "Hez kodea",
|
||||
"noShades": "Kolore honetarako ez dago ñabardurarik eskuragarri"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -124,6 +124,8 @@
|
|||
},
|
||||
"statusPublished": "منتشر شده",
|
||||
"sidebarLock": "باز نگه داشتن سایدبار",
|
||||
"selectAllElementsInFrame": "",
|
||||
"removeAllElementsFromFrame": "",
|
||||
"eyeDropper": ""
|
||||
},
|
||||
"library": {
|
||||
|
@ -221,7 +223,9 @@
|
|||
"penMode": "حالت قلم - جلوگیری از تماس",
|
||||
"link": "افزودن/بهروزرسانی پیوند برای شکل انتخابی",
|
||||
"eraser": "پاک کن",
|
||||
"hand": "دست (ابزار پانینگ)"
|
||||
"frame": "",
|
||||
"hand": "دست (ابزار پانینگ)",
|
||||
"extraTools": ""
|
||||
},
|
||||
"headings": {
|
||||
"canvasActions": "عملیات روی بوم",
|
||||
|
|
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