feat: introduce frames (#6123)
Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
parent
4d7d96eb7b
commit
81ebf82979
@ -12,7 +12,10 @@ export const actionAddToLibrary = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (selectedElements.some((element) => element.type === "image")) {
|
if (selectedElements.some((element) => element.type === "image")) {
|
||||||
return {
|
return {
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
@ -17,10 +18,20 @@ import { AppState } from "../types";
|
|||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
|
|
||||||
const enableActionGroup = (
|
const alignActionsPredicate = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
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 = (
|
const alignSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -36,14 +47,16 @@ const alignSelectedElements = (
|
|||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map(
|
return updateFrameMembershipOfSelectedElements(
|
||||||
(element) => updatedElementsMap.get(element.id) || element,
|
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actionAlignTop = register({
|
export const actionAlignTop = register({
|
||||||
name: "alignTop",
|
name: "alignTop",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@ -58,7 +71,7 @@ export const actionAlignTop = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_UP,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!alignActionsPredicate(elements, appState)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignTopIcon}
|
icon={AlignTopIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@ -74,6 +87,7 @@ export const actionAlignTop = register({
|
|||||||
export const actionAlignBottom = register({
|
export const actionAlignBottom = register({
|
||||||
name: "alignBottom",
|
name: "alignBottom",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@ -88,7 +102,7 @@ export const actionAlignBottom = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_DOWN,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!alignActionsPredicate(elements, appState)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignBottomIcon}
|
icon={AlignBottomIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@ -104,6 +118,7 @@ export const actionAlignBottom = register({
|
|||||||
export const actionAlignLeft = register({
|
export const actionAlignLeft = register({
|
||||||
name: "alignLeft",
|
name: "alignLeft",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@ -118,7 +133,7 @@ export const actionAlignLeft = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_LEFT,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!alignActionsPredicate(elements, appState)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignLeftIcon}
|
icon={AlignLeftIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@ -134,7 +149,7 @@ export const actionAlignLeft = register({
|
|||||||
export const actionAlignRight = register({
|
export const actionAlignRight = register({
|
||||||
name: "alignRight",
|
name: "alignRight",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@ -149,7 +164,7 @@ export const actionAlignRight = register({
|
|||||||
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
event[KEYS.CTRL_OR_CMD] && event.shiftKey && event.key === KEYS.ARROW_RIGHT,
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!alignActionsPredicate(elements, appState)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={AlignRightIcon}
|
icon={AlignRightIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@ -165,7 +180,7 @@ export const actionAlignRight = register({
|
|||||||
export const actionAlignVerticallyCentered = register({
|
export const actionAlignVerticallyCentered = register({
|
||||||
name: "alignVerticallyCentered",
|
name: "alignVerticallyCentered",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@ -178,7 +193,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!alignActionsPredicate(elements, appState)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={CenterVerticallyIcon}
|
icon={CenterVerticallyIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
@ -192,6 +207,7 @@ export const actionAlignVerticallyCentered = register({
|
|||||||
export const actionAlignHorizontallyCentered = register({
|
export const actionAlignHorizontallyCentered = register({
|
||||||
name: "alignHorizontallyCentered",
|
name: "alignHorizontallyCentered",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: alignActionsPredicate,
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
appState,
|
appState,
|
||||||
@ -204,7 +220,7 @@ export const actionAlignHorizontallyCentered = register({
|
|||||||
},
|
},
|
||||||
PanelComponent: ({ elements, appState, updateData }) => (
|
PanelComponent: ({ elements, appState, updateData }) => (
|
||||||
<ToolButton
|
<ToolButton
|
||||||
hidden={!enableActionGroup(elements, appState)}
|
hidden={!alignActionsPredicate(elements, appState)}
|
||||||
type="button"
|
type="button"
|
||||||
icon={CenterHorizontallyIcon}
|
icon={CenterHorizontallyIcon}
|
||||||
onClick={() => updateData(null)}
|
onClick={() => updateData(null)}
|
||||||
|
@ -249,6 +249,7 @@ export const actionWrapTextInContainer = register({
|
|||||||
"rectangle",
|
"rectangle",
|
||||||
),
|
),
|
||||||
groupIds: textElement.groupIds,
|
groupIds: textElement.groupIds,
|
||||||
|
frameId: textElement.frameId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// update bindings
|
// update bindings
|
||||||
|
@ -20,6 +20,8 @@ import {
|
|||||||
isHandToolActive,
|
isHandToolActive,
|
||||||
} from "../appState";
|
} from "../appState";
|
||||||
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
import { DEFAULT_CANVAS_BACKGROUND_PICKS } from "../colors";
|
||||||
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
|
import { Bounds } from "../element/bounds";
|
||||||
|
|
||||||
export const actionChangeViewBackgroundColor = register({
|
export const actionChangeViewBackgroundColor = register({
|
||||||
name: "changeViewBackgroundColor",
|
name: "changeViewBackgroundColor",
|
||||||
@ -206,7 +208,7 @@ export const actionResetZoom = register({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const zoomValueToFitBoundsOnViewport = (
|
const zoomValueToFitBoundsOnViewport = (
|
||||||
bounds: [number, number, number, number],
|
bounds: Bounds,
|
||||||
viewportDimensions: { width: number; height: number },
|
viewportDimensions: { width: number; height: number },
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = bounds;
|
const [x1, y1, x2, y2] = bounds;
|
||||||
@ -234,8 +236,10 @@ export const zoomToFitElements = (
|
|||||||
|
|
||||||
const commonBounds =
|
const commonBounds =
|
||||||
zoomToSelection && selectedElements.length > 0
|
zoomToSelection && selectedElements.length > 0
|
||||||
? getCommonBounds(selectedElements)
|
? getCommonBounds(excludeElementsInFramesFromSelection(selectedElements))
|
||||||
: getCommonBounds(nonDeletedElements);
|
: getCommonBounds(
|
||||||
|
excludeElementsInFramesFromSelection(nonDeletedElements),
|
||||||
|
);
|
||||||
|
|
||||||
const newZoom = {
|
const newZoom = {
|
||||||
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
value: zoomValueToFitBoundsOnViewport(commonBounds, {
|
||||||
|
@ -16,9 +16,12 @@ export const actionCopy = register({
|
|||||||
name: "copy",
|
name: "copy",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState, _, app) => {
|
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 {
|
return {
|
||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
@ -75,7 +78,10 @@ export const actionCopyAsSvg = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@ -119,7 +125,10 @@ export const actionCopyAsPng = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await exportCanvas(
|
await exportCanvas(
|
||||||
@ -172,7 +181,9 @@ export const copyText = register({
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const text = selectedElements
|
const text = selectedElements
|
||||||
@ -191,7 +202,9 @@ export const copyText = register({
|
|||||||
predicate: (elements, appState) => {
|
predicate: (elements, appState) => {
|
||||||
return (
|
return (
|
||||||
probablySupportsClipboardWriteText &&
|
probablySupportsClipboardWriteText &&
|
||||||
getSelectedElements(elements, appState, true).some(isTextElement)
|
getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
}).some(isTextElement)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
contextItemLabel: "labels.copyText",
|
contextItemLabel: "labels.copyText",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
import { KEYS } from "../keys";
|
import { KEYS } from "../keys";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
@ -18,11 +18,23 @@ const deleteSelectedElements = (
|
|||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
) => {
|
) => {
|
||||||
|
const framesToBeDeleted = new Set(
|
||||||
|
getSelectedElements(
|
||||||
|
elements.filter((el) => el.type === "frame"),
|
||||||
|
appState,
|
||||||
|
).map((el) => el.id),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: elements.map((el) => {
|
elements: elements.map((el) => {
|
||||||
if (appState.selectedElementIds[el.id]) {
|
if (appState.selectedElementIds[el.id]) {
|
||||||
return newElementWith(el, { isDeleted: true });
|
return newElementWith(el, { isDeleted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (el.frameId && framesToBeDeleted.has(el.frameId)) {
|
||||||
|
return newElementWith(el, { isDeleted: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isBoundToContainer(el) &&
|
isBoundToContainer(el) &&
|
||||||
appState.selectedElementIds[el.containerId]
|
appState.selectedElementIds[el.containerId]
|
||||||
|
@ -6,6 +6,7 @@ import { ToolButton } from "../components/ToolButton";
|
|||||||
import { distributeElements, Distribution } from "../distribute";
|
import { distributeElements, Distribution } from "../distribute";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { CODES, KEYS } from "../keys";
|
import { CODES, KEYS } from "../keys";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
||||||
@ -16,7 +17,17 @@ import { register } from "./register";
|
|||||||
const enableActionGroup = (
|
const enableActionGroup = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
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 = (
|
const distributeSelectedElements = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
@ -32,8 +43,9 @@ const distributeSelectedElements = (
|
|||||||
|
|
||||||
const updatedElementsMap = arrayToMap(updatedElements);
|
const updatedElementsMap = arrayToMap(updatedElements);
|
||||||
|
|
||||||
return elements.map(
|
return updateFrameMembershipOfSelectedElements(
|
||||||
(element) => updatedElementsMap.get(element.id) || element,
|
elements.map((element) => updatedElementsMap.get(element.id) || element),
|
||||||
|
appState,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import { KEYS } from "../keys";
|
|||||||
import { register } from "./register";
|
import { register } from "./register";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { duplicateElement, getNonDeletedElements } from "../element";
|
import { duplicateElement, getNonDeletedElements } from "../element";
|
||||||
import { getSelectedElements, isSomeElementSelected } from "../scene";
|
import { isSomeElementSelected } from "../scene";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { t } from "../i18n";
|
import { t } from "../i18n";
|
||||||
import { arrayToMap, getShortcutKey } from "../utils";
|
import { arrayToMap, getShortcutKey } from "../utils";
|
||||||
@ -20,9 +20,17 @@ import {
|
|||||||
bindTextToShapeAfterDuplication,
|
bindTextToShapeAfterDuplication,
|
||||||
getBoundTextElement,
|
getBoundTextElement,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer, isFrameElement } from "../element/typeChecks";
|
||||||
import { normalizeElementOrder } from "../element/sortElements";
|
import { normalizeElementOrder } from "../element/sortElements";
|
||||||
import { DuplicateIcon } from "../components/icons";
|
import { DuplicateIcon } from "../components/icons";
|
||||||
|
import {
|
||||||
|
bindElementsToFramesAfterDuplication,
|
||||||
|
getFrameElements,
|
||||||
|
} from "../frame";
|
||||||
|
import {
|
||||||
|
excludeElementsInFramesFromSelection,
|
||||||
|
getSelectedElements,
|
||||||
|
} from "../scene/selection";
|
||||||
|
|
||||||
export const actionDuplicateSelection = register({
|
export const actionDuplicateSelection = register({
|
||||||
name: "duplicateSelection",
|
name: "duplicateSelection",
|
||||||
@ -94,8 +102,11 @@ const duplicateElements = (
|
|||||||
return newElement;
|
return newElement;
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedElementIds = arrayToMap(
|
const idsOfElementsToDuplicate = arrayToMap(
|
||||||
getSelectedElements(sortedElements, appState, true),
|
getSelectedElements(sortedElements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Ids of elements that have already been processed so we don't push them
|
// Ids of elements that have already been processed so we don't push them
|
||||||
@ -129,12 +140,25 @@ const duplicateElements = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (selectedElementIds.get(element.id)) {
|
const isElementAFrame = isFrameElement(element);
|
||||||
// if a group or a container/bound-text, duplicate atomically
|
|
||||||
if (element.groupIds.length || boundTextElement) {
|
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);
|
const groupId = getSelectedGroupForElement(appState, element);
|
||||||
if (groupId) {
|
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(
|
elementsWithClones.push(
|
||||||
...markAsProcessed([
|
...markAsProcessed([
|
||||||
...groupElements,
|
...groupElements,
|
||||||
@ -156,10 +180,34 @@ const duplicateElements = (
|
|||||||
);
|
);
|
||||||
continue;
|
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 {
|
} else {
|
||||||
elementsWithClones.push(...markAsProcessed([element]));
|
elementsWithClones.push(...markAsProcessed([element]));
|
||||||
}
|
}
|
||||||
@ -200,6 +248,14 @@ const duplicateElements = (
|
|||||||
oldElements,
|
oldElements,
|
||||||
oldIdToDuplicatedId,
|
oldIdToDuplicatedId,
|
||||||
);
|
);
|
||||||
|
bindElementsToFramesAfterDuplication(
|
||||||
|
finalElements,
|
||||||
|
oldElements,
|
||||||
|
oldIdToDuplicatedId,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextElementsToSelect =
|
||||||
|
excludeElementsInFramesFromSelection(newElements);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
elements: finalElements,
|
elements: finalElements,
|
||||||
@ -207,7 +263,7 @@ const duplicateElements = (
|
|||||||
{
|
{
|
||||||
...appState,
|
...appState,
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
selectedElementIds: newElements.reduce(
|
selectedElementIds: nextElementsToSelect.reduce(
|
||||||
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
(acc: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
if (!isBoundToContainer(element)) {
|
if (!isBoundToContainer(element)) {
|
||||||
acc[element.id] = true;
|
acc[element.id] = true;
|
||||||
|
@ -11,8 +11,17 @@ const shouldLock = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
export const actionToggleElementLock = register({
|
export const actionToggleElementLock = register({
|
||||||
name: "toggleElementLock",
|
name: "toggleElementLock",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
|
predicate: (elements, appState) => {
|
||||||
|
const selectedElements = getSelectedElements(elements, appState);
|
||||||
|
return !selectedElements.some(
|
||||||
|
(element) => element.locked && element.frameId,
|
||||||
|
);
|
||||||
|
},
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
const selectedElements = getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (!selectedElements.length) {
|
if (!selectedElements.length) {
|
||||||
return false;
|
return false;
|
||||||
@ -38,8 +47,10 @@ export const actionToggleElementLock = register({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
contextItemLabel: (elements, appState) => {
|
contextItemLabel: (elements, appState) => {
|
||||||
const selected = getSelectedElements(elements, appState, false);
|
const selected = getSelectedElements(elements, appState, {
|
||||||
if (selected.length === 1) {
|
includeBoundTextElement: false,
|
||||||
|
});
|
||||||
|
if (selected.length === 1 && selected[0].type !== "frame") {
|
||||||
return selected[0].locked
|
return selected[0].locked
|
||||||
? "labels.elementLock.unlock"
|
? "labels.elementLock.unlock"
|
||||||
: "labels.elementLock.lock";
|
: "labels.elementLock.lock";
|
||||||
@ -54,7 +65,9 @@ export const actionToggleElementLock = register({
|
|||||||
event.key.toLocaleLowerCase() === KEYS.L &&
|
event.key.toLocaleLowerCase() === KEYS.L &&
|
||||||
event[KEYS.CTRL_OR_CMD] &&
|
event[KEYS.CTRL_OR_CMD] &&
|
||||||
event.shiftKey &&
|
event.shiftKey &&
|
||||||
getSelectedElements(elements, appState, false).length > 0
|
getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: false,
|
||||||
|
}).length > 0
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -12,13 +12,17 @@ import {
|
|||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
unbindLinearElements,
|
unbindLinearElements,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
|
import { updateFrameMembershipOfSelectedElements } from "../frame";
|
||||||
|
|
||||||
export const actionFlipHorizontal = register({
|
export const actionFlipHorizontal = register({
|
||||||
name: "flipHorizontal",
|
name: "flipHorizontal",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: flipSelectedElements(elements, appState, "horizontal"),
|
elements: updateFrameMembershipOfSelectedElements(
|
||||||
|
flipSelectedElements(elements, appState, "horizontal"),
|
||||||
|
appState,
|
||||||
|
),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@ -32,7 +36,10 @@ export const actionFlipVertical = register({
|
|||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState) => {
|
||||||
return {
|
return {
|
||||||
elements: flipSelectedElements(elements, appState, "vertical"),
|
elements: updateFrameMembershipOfSelectedElements(
|
||||||
|
flipSelectedElements(elements, appState, "vertical"),
|
||||||
|
appState,
|
||||||
|
),
|
||||||
appState,
|
appState,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
@ -50,6 +57,9 @@ const flipSelectedElements = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
|
{
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedElements = flipElements(
|
const updatedElements = flipElements(
|
||||||
|
140
src/actions/actionFrame.ts
Normal file
140
src/actions/actionFrame.ts
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
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 actionToggleFrameRendering = register({
|
||||||
|
name: "toggleFrameRendering",
|
||||||
|
viewMode: true,
|
||||||
|
trackEvent: { category: "canvas" },
|
||||||
|
perform: (elements, appState) => {
|
||||||
|
return {
|
||||||
|
elements,
|
||||||
|
appState: {
|
||||||
|
...appState,
|
||||||
|
shouldRenderFrames: !appState.shouldRenderFrames,
|
||||||
|
},
|
||||||
|
commitToHistory: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
contextItemLabel: "labels.toggleFrameRendering",
|
||||||
|
checked: (appState: AppState) => appState.shouldRenderFrames,
|
||||||
|
});
|
||||||
|
|
||||||
|
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 { getNonDeletedElements } from "../element";
|
||||||
import { randomId } from "../random";
|
import { randomId } from "../random";
|
||||||
import { ToolButton } from "../components/ToolButton";
|
import { ToolButton } from "../components/ToolButton";
|
||||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
import {
|
||||||
|
ExcalidrawElement,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
|
ExcalidrawTextElement,
|
||||||
|
} from "../element/types";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
getElementsInResizingFrame,
|
||||||
|
groupByFrames,
|
||||||
|
removeElementsFromFrame,
|
||||||
|
replaceAllElementsInFrame,
|
||||||
|
} from "../frame";
|
||||||
|
|
||||||
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
const allElementsInSameGroup = (elements: readonly ExcalidrawElement[]) => {
|
||||||
if (elements.length >= 2) {
|
if (elements.length >= 2) {
|
||||||
@ -45,7 +55,9 @@ const enableActionGroup = (
|
|||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
selectedElements.length >= 2 && !allElementsInSameGroup(selectedElements)
|
||||||
@ -55,11 +67,13 @@ const enableActionGroup = (
|
|||||||
export const actionGroup = register({
|
export const actionGroup = register({
|
||||||
name: "group",
|
name: "group",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const selectedElements = getSelectedElements(
|
const selectedElements = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (selectedElements.length < 2) {
|
if (selectedElements.length < 2) {
|
||||||
// nothing to group
|
// nothing to group
|
||||||
@ -86,9 +100,31 @@ export const actionGroup = register({
|
|||||||
return { appState, elements, commitToHistory: false };
|
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 newGroupId = randomId();
|
||||||
const selectElementIds = arrayToMap(selectedElements);
|
const selectElementIds = arrayToMap(selectedElements);
|
||||||
const updatedElements = elements.map((element) => {
|
|
||||||
|
nextElements = nextElements.map((element) => {
|
||||||
if (!selectElementIds.get(element.id)) {
|
if (!selectElementIds.get(element.id)) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
@ -102,17 +138,16 @@ export const actionGroup = register({
|
|||||||
});
|
});
|
||||||
// keep the z order within the group the same, but move them
|
// keep the z order within the group the same, but move them
|
||||||
// to the z order of the highest element in the layer stack
|
// 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 lastElementInGroup = elementsInGroup[elementsInGroup.length - 1];
|
||||||
const lastGroupElementIndex =
|
const lastGroupElementIndex = nextElements.lastIndexOf(lastElementInGroup);
|
||||||
updatedElements.lastIndexOf(lastElementInGroup);
|
const elementsAfterGroup = nextElements.slice(lastGroupElementIndex + 1);
|
||||||
const elementsAfterGroup = updatedElements.slice(lastGroupElementIndex + 1);
|
const elementsBeforeGroup = nextElements
|
||||||
const elementsBeforeGroup = updatedElements
|
|
||||||
.slice(0, lastGroupElementIndex)
|
.slice(0, lastGroupElementIndex)
|
||||||
.filter(
|
.filter(
|
||||||
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
(updatedElement) => !isElementInGroup(updatedElement, newGroupId),
|
||||||
);
|
);
|
||||||
const updatedElementsInOrder = [
|
nextElements = [
|
||||||
...elementsBeforeGroup,
|
...elementsBeforeGroup,
|
||||||
...elementsInGroup,
|
...elementsInGroup,
|
||||||
...elementsAfterGroup,
|
...elementsAfterGroup,
|
||||||
@ -122,9 +157,9 @@ export const actionGroup = register({
|
|||||||
appState: selectGroup(
|
appState: selectGroup(
|
||||||
newGroupId,
|
newGroupId,
|
||||||
{ ...appState, selectedGroupIds: {} },
|
{ ...appState, selectedGroupIds: {} },
|
||||||
getNonDeletedElements(updatedElementsInOrder),
|
getNonDeletedElements(nextElements),
|
||||||
),
|
),
|
||||||
elements: updatedElementsInOrder,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -148,14 +183,23 @@ export const actionGroup = register({
|
|||||||
export const actionUngroup = register({
|
export const actionUngroup = register({
|
||||||
name: "ungroup",
|
name: "ungroup",
|
||||||
trackEvent: { category: "element" },
|
trackEvent: { category: "element" },
|
||||||
perform: (elements, appState) => {
|
perform: (elements, appState, _, app) => {
|
||||||
const groupIds = getSelectedGroupIds(appState);
|
const groupIds = getSelectedGroupIds(appState);
|
||||||
if (groupIds.length === 0) {
|
if (groupIds.length === 0) {
|
||||||
return { appState, elements, commitToHistory: false };
|
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 boundTextElementIds: ExcalidrawTextElement["id"][] = [];
|
||||||
const nextElements = elements.map((element) => {
|
nextElements = nextElements.map((element) => {
|
||||||
if (isBoundToContainer(element)) {
|
if (isBoundToContainer(element)) {
|
||||||
boundTextElementIds.push(element.id);
|
boundTextElementIds.push(element.id);
|
||||||
}
|
}
|
||||||
@ -176,13 +220,23 @@ export const actionUngroup = register({
|
|||||||
getNonDeletedElements(nextElements),
|
getNonDeletedElements(nextElements),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
frames.forEach((frame) => {
|
||||||
|
if (frame) {
|
||||||
|
nextElements = replaceAllElementsInFrame(
|
||||||
|
nextElements,
|
||||||
|
getElementsInResizingFrame(nextElements, frame, appState),
|
||||||
|
frame,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// remove binded text elements from selection
|
// remove binded text elements from selection
|
||||||
boundTextElementIds.forEach(
|
boundTextElementIds.forEach(
|
||||||
(id) => (updateAppState.selectedElementIds[id] = false),
|
(id) => (updateAppState.selectedElementIds[id] = false),
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
appState: updateAppState,
|
appState: updateAppState,
|
||||||
|
|
||||||
elements: nextElements,
|
elements: nextElements,
|
||||||
commitToHistory: true,
|
commitToHistory: true,
|
||||||
};
|
};
|
||||||
|
@ -21,7 +21,9 @@ export const actionToggleLinearEditor = register({
|
|||||||
const selectedElement = getSelectedElements(
|
const selectedElement = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
)[0] as ExcalidrawLinearElement;
|
)[0] as ExcalidrawLinearElement;
|
||||||
|
|
||||||
const editingLinearElement =
|
const editingLinearElement =
|
||||||
@ -40,7 +42,9 @@ export const actionToggleLinearEditor = register({
|
|||||||
const selectedElement = getSelectedElements(
|
const selectedElement = getSelectedElements(
|
||||||
getNonDeletedElements(elements),
|
getNonDeletedElements(elements),
|
||||||
appState,
|
appState,
|
||||||
true,
|
{
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
},
|
||||||
)[0] as ExcalidrawLinearElement;
|
)[0] as ExcalidrawLinearElement;
|
||||||
return appState.editingLinearElement?.elementId === selectedElement.id
|
return appState.editingLinearElement?.elementId === selectedElement.id
|
||||||
? "labels.lineEditor.exit"
|
? "labels.lineEditor.exit"
|
||||||
|
@ -67,7 +67,6 @@ export const actionFullScreen = register({
|
|||||||
commitToHistory: false,
|
commitToHistory: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
keyTest: (event) => event.key === KEYS.F && !event[KEYS.CTRL_OR_CMD],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actionShortcuts = register({
|
export const actionShortcuts = register({
|
||||||
|
@ -102,8 +102,11 @@ const changeProperty = (
|
|||||||
includeBoundText = false,
|
includeBoundText = false,
|
||||||
) => {
|
) => {
|
||||||
const selectedElementIds = arrayToMap(
|
const selectedElementIds = arrayToMap(
|
||||||
getSelectedElements(elements, appState, includeBoundText),
|
getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: includeBoundText,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return elements.map((element) => {
|
return elements.map((element) => {
|
||||||
if (
|
if (
|
||||||
selectedElementIds.get(element.id) ||
|
selectedElementIds.get(element.id) ||
|
||||||
|
@ -5,6 +5,7 @@ import { getNonDeletedElements, isTextElement } from "../element";
|
|||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isLinearElement } from "../element/typeChecks";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { excludeElementsInFramesFromSelection } from "../scene/selection";
|
||||||
|
|
||||||
export const actionSelectAll = register({
|
export const actionSelectAll = register({
|
||||||
name: "selectAll",
|
name: "selectAll",
|
||||||
@ -13,19 +14,18 @@ export const actionSelectAll = register({
|
|||||||
if (appState.editingLinearElement) {
|
if (appState.editingLinearElement) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const selectedElementIds = elements.reduce(
|
|
||||||
(map: Record<ExcalidrawElement["id"], true>, element) => {
|
const selectedElementIds = excludeElementsInFramesFromSelection(
|
||||||
if (
|
elements.filter(
|
||||||
|
(element) =>
|
||||||
!element.isDeleted &&
|
!element.isDeleted &&
|
||||||
!(isTextElement(element) && element.containerId) &&
|
!(isTextElement(element) && element.containerId) &&
|
||||||
!element.locked
|
!element.locked,
|
||||||
) {
|
),
|
||||||
map[element.id] = true;
|
).reduce((map: Record<ExcalidrawElement["id"], true>, element) => {
|
||||||
}
|
map[element.id] = true;
|
||||||
return map;
|
return map;
|
||||||
},
|
}, {});
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
appState: selectGroupsForSelectedElements(
|
appState: selectGroupsForSelectedElements(
|
||||||
|
@ -20,6 +20,7 @@ import {
|
|||||||
hasBoundTextElement,
|
hasBoundTextElement,
|
||||||
canApplyRoundnessTypeToElement,
|
canApplyRoundnessTypeToElement,
|
||||||
getDefaultRoundnessTypeForElement,
|
getDefaultRoundnessTypeForElement,
|
||||||
|
isFrameElement,
|
||||||
} from "../element/typeChecks";
|
} from "../element/typeChecks";
|
||||||
import { getSelectedElements } from "../scene";
|
import { getSelectedElements } from "../scene";
|
||||||
|
|
||||||
@ -64,7 +65,9 @@ export const actionPasteStyles = register({
|
|||||||
return { elements, commitToHistory: false };
|
return { elements, commitToHistory: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedElements = getSelectedElements(elements, appState, true);
|
const selectedElements = getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
});
|
||||||
const selectedElementIds = selectedElements.map((element) => element.id);
|
const selectedElementIds = selectedElements.map((element) => element.id);
|
||||||
return {
|
return {
|
||||||
elements: elements.map((element) => {
|
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 newElement;
|
||||||
}
|
}
|
||||||
return element;
|
return element;
|
||||||
|
@ -116,6 +116,11 @@ export type ActionName =
|
|||||||
| "toggleLinearEditor"
|
| "toggleLinearEditor"
|
||||||
| "toggleEraserTool"
|
| "toggleEraserTool"
|
||||||
| "toggleHandTool"
|
| "toggleHandTool"
|
||||||
|
| "selectAllElementsInFrame"
|
||||||
|
| "removeAllElementsFromFrame"
|
||||||
|
| "toggleFrameRendering"
|
||||||
|
| "setFrameAsActiveTool"
|
||||||
|
| "createContainerFromText"
|
||||||
| "wrapTextInContainer";
|
| "wrapTextInContainer";
|
||||||
|
|
||||||
export type PanelComponentProps = {
|
export type PanelComponentProps = {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
import { newElementWith } from "./element/mutateElement";
|
import { newElementWith } from "./element/mutateElement";
|
||||||
import { Box, getCommonBoundingBox } from "./element/bounds";
|
import { BoundingBox, getCommonBoundingBox } from "./element/bounds";
|
||||||
import { getMaximumGroups } from "./groups";
|
import { getMaximumGroups } from "./groups";
|
||||||
|
|
||||||
export interface Alignment {
|
export interface Alignment {
|
||||||
@ -33,7 +33,7 @@ export const alignElements = (
|
|||||||
|
|
||||||
const calculateTranslation = (
|
const calculateTranslation = (
|
||||||
group: ExcalidrawElement[],
|
group: ExcalidrawElement[],
|
||||||
selectionBoundingBox: Box,
|
selectionBoundingBox: BoundingBox,
|
||||||
{ axis, position }: Alignment,
|
{ axis, position }: Alignment,
|
||||||
): { x: number; y: number } => {
|
): { x: number; y: number } => {
|
||||||
const groupBoundingBox = getCommonBoundingBox(group);
|
const groupBoundingBox = getCommonBoundingBox(group);
|
||||||
|
@ -78,11 +78,16 @@ export const getDefaultAppState = (): Omit<
|
|||||||
scrollY: 0,
|
scrollY: 0,
|
||||||
selectedElementIds: {},
|
selectedElementIds: {},
|
||||||
selectedGroupIds: {},
|
selectedGroupIds: {},
|
||||||
|
selectedElementsAreBeingDragged: false,
|
||||||
selectionElement: null,
|
selectionElement: null,
|
||||||
shouldCacheIgnoreZoom: false,
|
shouldCacheIgnoreZoom: false,
|
||||||
showStats: false,
|
showStats: false,
|
||||||
startBoundElement: null,
|
startBoundElement: null,
|
||||||
suggestedBindings: [],
|
suggestedBindings: [],
|
||||||
|
shouldRenderFrames: true,
|
||||||
|
frameToHighlight: null,
|
||||||
|
editingFrame: null,
|
||||||
|
elementsToHighlight: null,
|
||||||
toast: null,
|
toast: null,
|
||||||
viewBackgroundColor: COLOR_PALETTE.white,
|
viewBackgroundColor: COLOR_PALETTE.white,
|
||||||
zenModeEnabled: false,
|
zenModeEnabled: false,
|
||||||
@ -176,11 +181,20 @@ const APP_STATE_STORAGE_CONF = (<
|
|||||||
scrollY: { browser: true, export: false, server: false },
|
scrollY: { browser: true, export: false, server: false },
|
||||||
selectedElementIds: { browser: true, export: false, server: false },
|
selectedElementIds: { browser: true, export: false, server: false },
|
||||||
selectedGroupIds: { 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 },
|
selectionElement: { browser: false, export: false, server: false },
|
||||||
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
shouldCacheIgnoreZoom: { browser: true, export: false, server: false },
|
||||||
showStats: { browser: true, export: false, server: false },
|
showStats: { browser: true, export: false, server: false },
|
||||||
startBoundElement: { browser: false, export: false, server: false },
|
startBoundElement: { browser: false, export: false, server: false },
|
||||||
suggestedBindings: { browser: false, export: false, server: false },
|
suggestedBindings: { browser: false, export: false, server: false },
|
||||||
|
shouldRenderFrames: { 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 },
|
toast: { browser: false, export: false, server: false },
|
||||||
viewBackgroundColor: { browser: true, export: true, server: true },
|
viewBackgroundColor: { browser: true, export: true, server: true },
|
||||||
width: { browser: false, export: false, server: false },
|
width: { browser: false, export: false, server: false },
|
||||||
|
@ -7,6 +7,9 @@ import { SVG_EXPORT_TAG } from "./scene/export";
|
|||||||
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
||||||
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
|
||||||
import { isInitializedImageElement } from "./element/typeChecks";
|
import { isInitializedImageElement } from "./element/typeChecks";
|
||||||
|
import { deepCopyElement } from "./element/newElement";
|
||||||
|
import { mutateElement } from "./element/mutateElement";
|
||||||
|
import { getContainingFrame } from "./frame";
|
||||||
import { isPromiseLike, isTestEnv } from "./utils";
|
import { isPromiseLike, isTestEnv } from "./utils";
|
||||||
|
|
||||||
type ElementsClipboard = {
|
type ElementsClipboard = {
|
||||||
@ -57,6 +60,9 @@ export const copyToClipboard = async (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles | null,
|
||||||
) => {
|
) => {
|
||||||
|
const framesToCopy = new Set(
|
||||||
|
elements.filter((element) => element.type === "frame"),
|
||||||
|
);
|
||||||
let foundFile = false;
|
let foundFile = false;
|
||||||
|
|
||||||
const _files = elements.reduce((acc, element) => {
|
const _files = elements.reduce((acc, element) => {
|
||||||
@ -78,7 +84,20 @@ export const copyToClipboard = async (
|
|||||||
// select binded text elements when copying
|
// select binded text elements when copying
|
||||||
const contents: ElementsClipboard = {
|
const contents: ElementsClipboard = {
|
||||||
type: EXPORT_DATA_TYPES.excalidrawClipboard,
|
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,
|
files: files ? _files : undefined,
|
||||||
};
|
};
|
||||||
const json = JSON.stringify(contents);
|
const json = JSON.stringify(contents);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { ActionManager } from "../actions/manager";
|
import { ActionManager } from "../actions/manager";
|
||||||
import { getNonDeletedElements } from "../element";
|
import { getNonDeletedElements } from "../element";
|
||||||
import { ExcalidrawElement, PointerType } from "../element/types";
|
import { ExcalidrawElement, PointerType } from "../element/types";
|
||||||
@ -35,6 +35,9 @@ import {
|
|||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
|
|
||||||
import "./Actions.scss";
|
import "./Actions.scss";
|
||||||
|
import DropdownMenu from "./dropdownMenu/DropdownMenu";
|
||||||
|
import { extraToolsIcon, frameToolIcon } from "./icons";
|
||||||
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
export const SelectedShapeActions = ({
|
export const SelectedShapeActions = ({
|
||||||
appState,
|
appState,
|
||||||
@ -89,7 +92,8 @@ export const SelectedShapeActions = ({
|
|||||||
<div>
|
<div>
|
||||||
{((hasStrokeColor(appState.activeTool.type) &&
|
{((hasStrokeColor(appState.activeTool.type) &&
|
||||||
appState.activeTool.type !== "image" &&
|
appState.activeTool.type !== "image" &&
|
||||||
commonSelectedType !== "image") ||
|
commonSelectedType !== "image" &&
|
||||||
|
commonSelectedType !== "frame") ||
|
||||||
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
targetElements.some((element) => hasStrokeColor(element.type))) &&
|
||||||
renderAction("changeStrokeColor")}
|
renderAction("changeStrokeColor")}
|
||||||
</div>
|
</div>
|
||||||
@ -220,28 +224,78 @@ export const ShapesSwitcher = ({
|
|||||||
setAppState: React.Component<any, UIAppState>["setState"];
|
setAppState: React.Component<any, UIAppState>["setState"];
|
||||||
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
onImageAction: (data: { pointerType: PointerType | null }) => void;
|
||||||
appState: UIAppState;
|
appState: UIAppState;
|
||||||
}) => (
|
}) => {
|
||||||
<>
|
const [isExtraToolsMenuOpen, setIsExtraToolsMenuOpen] = useState(false);
|
||||||
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
const device = useDevice();
|
||||||
const label = t(`toolBar.${value}`);
|
return (
|
||||||
const letter =
|
<>
|
||||||
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
{SHAPES.map(({ value, icon, key, numericKey, fillable }, index) => {
|
||||||
const shortcut = letter
|
const label = t(`toolBar.${value}`);
|
||||||
? `${letter} ${t("helpDialog.or")} ${numericKey}`
|
const letter =
|
||||||
: `${numericKey}`;
|
key && capitalizeString(typeof key === "string" ? key : key[0]);
|
||||||
return (
|
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
|
<ToolButton
|
||||||
className={clsx("Shape", { fillable })}
|
className={clsx("Shape", { fillable: false })}
|
||||||
key={value}
|
|
||||||
type="radio"
|
type="radio"
|
||||||
icon={icon}
|
icon={frameToolIcon}
|
||||||
checked={activeTool.type === value}
|
checked={activeTool.type === "frame"}
|
||||||
name="editor-current-shape"
|
name="editor-current-shape"
|
||||||
title={`${capitalizeString(label)} — ${shortcut}`}
|
title={`${capitalizeString(
|
||||||
keyBindingLabel={numericKey || letter}
|
t("toolBar.frame"),
|
||||||
aria-label={capitalizeString(label)}
|
)} — ${KEYS.F.toLocaleUpperCase()}`}
|
||||||
aria-keyshortcuts={shortcut}
|
keyBindingLabel={KEYS.F.toLocaleUpperCase()}
|
||||||
data-testid={`toolbar-${value}`}
|
aria-label={capitalizeString(t("toolBar.frame"))}
|
||||||
|
aria-keyshortcuts={KEYS.F.toLocaleUpperCase()}
|
||||||
|
data-testid={`toolbar-frame`}
|
||||||
onPointerDown={({ pointerType }) => {
|
onPointerDown={({ pointerType }) => {
|
||||||
if (!appState.penDetected && pointerType === "pen") {
|
if (!appState.penDetected && pointerType === "pen") {
|
||||||
setAppState({
|
setAppState({
|
||||||
@ -251,30 +305,54 @@ export const ShapesSwitcher = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={({ pointerType }) => {
|
onChange={({ pointerType }) => {
|
||||||
if (appState.activeTool.type !== value) {
|
trackEvent("toolbar", "frame", "ui");
|
||||||
trackEvent("toolbar", value, "ui");
|
|
||||||
}
|
|
||||||
const nextActiveTool = updateActiveTool(appState, {
|
const nextActiveTool = updateActiveTool(appState, {
|
||||||
type: value,
|
type: "frame",
|
||||||
});
|
});
|
||||||
setAppState({
|
setAppState({
|
||||||
activeTool: nextActiveTool,
|
activeTool: nextActiveTool,
|
||||||
multiElement: null,
|
multiElement: null,
|
||||||
selectedElementIds: {},
|
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 = ({
|
export const ZoomActions = ({
|
||||||
renderAction,
|
renderAction,
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -164,6 +164,7 @@ export const HelpDialog = ({ onClose }: { onClose?: () => void }) => {
|
|||||||
label={t("toolBar.eraser")}
|
label={t("toolBar.eraser")}
|
||||||
shortcuts={[KEYS.E, KEYS["0"]]}
|
shortcuts={[KEYS.E, KEYS["0"]]}
|
||||||
/>
|
/>
|
||||||
|
<Shortcut label={t("toolBar.frame")} shortcuts={[KEYS.F]} />
|
||||||
<Shortcut
|
<Shortcut
|
||||||
label={t("labels.eyeDropper")}
|
label={t("labels.eyeDropper")}
|
||||||
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
shortcuts={[KEYS.I, "Shift+S", "Shift+G"]}
|
||||||
|
@ -84,7 +84,10 @@ const ImageExportModal = ({
|
|||||||
const [renderError, setRenderError] = useState<Error | null>(null);
|
const [renderError, setRenderError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const exportedElements = exportSelected
|
const exportedElements = exportSelected
|
||||||
? getSelectedElements(elements, appState, true)
|
? getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
})
|
||||||
: elements;
|
: elements;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -204,12 +204,7 @@ const LayerUI = ({
|
|||||||
return (
|
return (
|
||||||
<FixedSideContainer side="top">
|
<FixedSideContainer side="top">
|
||||||
<div className="App-menu App-menu_top">
|
<div className="App-menu App-menu_top">
|
||||||
<Stack.Col
|
<Stack.Col gap={6} className={clsx("App-menu_top__left")}>
|
||||||
gap={6}
|
|
||||||
className={clsx("App-menu_top__left", {
|
|
||||||
"disable-pointerEvents": appState.zenModeEnabled,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{renderCanvasActions()}
|
{renderCanvasActions()}
|
||||||
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
|
||||||
</Stack.Col>
|
</Stack.Col>
|
||||||
@ -254,7 +249,7 @@ const LayerUI = ({
|
|||||||
title={t("toolBar.lock")}
|
title={t("toolBar.lock")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="App-toolbar__divider"></div>
|
<div className="App-toolbar__divider" />
|
||||||
|
|
||||||
<HandButton
|
<HandButton
|
||||||
checked={isHandToolActive(appState)}
|
checked={isHandToolActive(appState)}
|
||||||
|
@ -148,7 +148,11 @@ const usePendingElementsMemo = (
|
|||||||
appState: UIAppState,
|
appState: UIAppState,
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
const create = () => getSelectedElements(elements, appState, true);
|
const create = () =>
|
||||||
|
getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
includeElementsInFrames: true,
|
||||||
|
});
|
||||||
const val = useRef(create());
|
const val = useRef(create());
|
||||||
const prevAppState = useRef<UIAppState>(appState);
|
const prevAppState = useRef<UIAppState>(appState);
|
||||||
const prevElements = useRef(elements);
|
const prevElements = useRef(elements);
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import "./ToolIcon.scss";
|
import "./ToolIcon.scss";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useExcalidrawContainer } from "./App";
|
import { useExcalidrawContainer } from "./App";
|
||||||
import { AbortError } from "../errors";
|
import { AbortError } from "../errors";
|
||||||
@ -25,6 +25,7 @@ type ToolButtonBaseProps = {
|
|||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -114,6 +115,7 @@ export const ToolButton = React.forwardRef((props: ToolButtonProps, ref) => {
|
|||||||
"ToolIcon--plain": props.type === "icon",
|
"ToolIcon--plain": props.type === "icon",
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
|
style={props.style}
|
||||||
data-testid={props["data-testid"]}
|
data-testid={props["data-testid"]}
|
||||||
hidden={props.hidden}
|
hidden={props.hidden}
|
||||||
title={props.title}
|
title={props.title}
|
||||||
|
@ -15,7 +15,24 @@
|
|||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
background-color: var(--default-border-color);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,23 +1,23 @@
|
|||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { useUIAppState } from "../../context/ui-appState";
|
|
||||||
import { useDevice } from "../App";
|
import { useDevice } from "../App";
|
||||||
|
|
||||||
const MenuTrigger = ({
|
const MenuTrigger = ({
|
||||||
className = "",
|
className = "",
|
||||||
children,
|
children,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
title,
|
||||||
|
...rest
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
}) => {
|
title?: string;
|
||||||
const appState = useUIAppState();
|
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect">) => {
|
||||||
const device = useDevice();
|
const device = useDevice();
|
||||||
const classNames = clsx(
|
const classNames = clsx(
|
||||||
`dropdown-menu-button ${className}`,
|
`dropdown-menu-button ${className}`,
|
||||||
"zen-mode-transition",
|
"zen-mode-transition",
|
||||||
{
|
{
|
||||||
"transition-left": appState.zenModeEnabled,
|
|
||||||
"dropdown-menu-button--mobile": device.isMobile,
|
"dropdown-menu-button--mobile": device.isMobile,
|
||||||
},
|
},
|
||||||
).trim();
|
).trim();
|
||||||
@ -28,6 +28,8 @@ const MenuTrigger = ({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="dropdown-menu-button"
|
data-testid="dropdown-menu-button"
|
||||||
|
title={title}
|
||||||
|
{...rest}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
|
@ -12,17 +12,17 @@ describe("Test internal component fallback rendering", () => {
|
|||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||||
|
|
||||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||||
".excalidraw-container",
|
".excalidraw-container",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -36,17 +36,17 @@ describe("Test internal component fallback rendering", () => {
|
|||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||||
|
|
||||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||||
".excalidraw-container",
|
".excalidraw-container",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -62,17 +62,17 @@ describe("Test internal component fallback rendering", () => {
|
|||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||||
|
|
||||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||||
".excalidraw-container",
|
".excalidraw-container",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -84,17 +84,17 @@ describe("Test internal component fallback rendering", () => {
|
|||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(queryAllByTestId(container, "dropdown-menu-button")?.length).toBe(2);
|
expect(queryAllByTestId(container, "main-menu-trigger")?.length).toBe(2);
|
||||||
|
|
||||||
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
const excalContainers = container.querySelectorAll<HTMLDivElement>(
|
||||||
".excalidraw-container",
|
".excalidraw-container",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[0], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[0], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
expect(
|
expect(
|
||||||
queryAllByTestId(excalContainers[1], "dropdown-menu-button")?.length,
|
queryAllByTestId(excalContainers[1], "main-menu-trigger")?.length,
|
||||||
).toBe(1);
|
).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1616,3 +1616,24 @@ export const eyeDropperIcon = createIcon(
|
|||||||
</g>,
|
</g>,
|
||||||
tablerIconProps,
|
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,
|
||||||
|
);
|
||||||
|
@ -42,6 +42,7 @@ const MainMenu = Object.assign(
|
|||||||
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
openMenu: appState.openMenu === "canvas" ? null : "canvas",
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
data-testid="main-menu-trigger"
|
||||||
>
|
>
|
||||||
{HamburgerMenuIcon}
|
{HamburgerMenuIcon}
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
|
@ -94,6 +94,17 @@ export const THEME = {
|
|||||||
DARK: "dark",
|
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 WINDOWS_EMOJI_FALLBACK_FONT = "Segoe UI Emoji";
|
||||||
|
|
||||||
export const DEFAULT_FONT_SIZE = 20;
|
export const DEFAULT_FONT_SIZE = 20;
|
||||||
|
@ -62,6 +62,7 @@ export const AllowedExcalidrawActiveTools: Record<
|
|||||||
freedraw: true,
|
freedraw: true,
|
||||||
eraser: false,
|
eraser: false,
|
||||||
custom: true,
|
custom: true,
|
||||||
|
frame: true,
|
||||||
hand: true,
|
hand: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -125,6 +126,7 @@ const restoreElementWithProperties = <
|
|||||||
height: element.height || 0,
|
height: element.height || 0,
|
||||||
seed: element.seed ?? 1,
|
seed: element.seed ?? 1,
|
||||||
groupIds: element.groupIds ?? [],
|
groupIds: element.groupIds ?? [],
|
||||||
|
frameId: element.frameId ?? null,
|
||||||
roundness: element.roundness
|
roundness: element.roundness
|
||||||
? element.roundness
|
? element.roundness
|
||||||
: element.strokeSharpness === "round"
|
: element.strokeSharpness === "round"
|
||||||
@ -272,6 +274,10 @@ const restoreElement = (
|
|||||||
return restoreElementWithProperties(element, {});
|
return restoreElementWithProperties(element, {});
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return restoreElementWithProperties(element, {});
|
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.
|
// 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
|
// We also don't want to throw, but instead return void so we filter
|
||||||
|
@ -344,7 +344,7 @@ export const isPointHittingLinkIcon = (
|
|||||||
if (
|
if (
|
||||||
!isMobile &&
|
!isMobile &&
|
||||||
appState.viewModeEnabled &&
|
appState.viewModeEnabled &&
|
||||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
isPointHittingElementBoundingBox(element, [x, y], threshold, null)
|
||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -440,7 +440,9 @@ export const shouldHideLinkPopup = (
|
|||||||
|
|
||||||
const threshold = 15 / appState.zoom.value;
|
const threshold = 15 / appState.zoom.value;
|
||||||
// hitbox to prevent hiding when hovered in element bounding box
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2] = getElementAbsoluteCoords(element);
|
||||||
|
@ -39,7 +39,7 @@ export type SuggestedPointBinding = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const shouldEnableBindingForPointerEvent = (
|
export const shouldEnableBindingForPointerEvent = (
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
) => {
|
) => {
|
||||||
return !event[KEYS.CTRL_OR_CMD];
|
return !event[KEYS.CTRL_OR_CMD];
|
||||||
};
|
};
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
NonDeleted,
|
NonDeleted,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { distance2d, rotate } from "../math";
|
import { distance2d, rotate, rotatePoint } from "../math";
|
||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { Drawable, Op } from "roughjs/bin/core";
|
import { Drawable, Op } from "roughjs/bin/core";
|
||||||
import { Point } from "../types";
|
import { Point } from "../types";
|
||||||
@ -25,10 +25,101 @@ import { getBoundTextElement, getContainerElement } from "./textElement";
|
|||||||
import { LinearElementEditor } from "./linearElementEditor";
|
import { LinearElementEditor } from "./linearElementEditor";
|
||||||
import { Mutable } from "../utility-types";
|
import { Mutable } from "../utility-types";
|
||||||
|
|
||||||
// x and y position of top left corner, x and y position of bottom right corner
|
export type RectangleBox = {
|
||||||
export type Bounds = readonly [number, number, number, number];
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
angle: number;
|
||||||
|
};
|
||||||
|
|
||||||
type MaybeQuadraticSolution = [number | null, number | null] | false;
|
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
|
// 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.
|
// This set of functions retrieves the absolute position of the 4 points.
|
||||||
export const getElementAbsoluteCoords = (
|
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 = (
|
export const pointRelativeTo = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
absoluteCoords: Point,
|
absoluteCoords: Point,
|
||||||
@ -454,64 +650,12 @@ const getLinearElementRotatedBounds = (
|
|||||||
return coords;
|
return coords;
|
||||||
};
|
};
|
||||||
|
|
||||||
// We could cache this stuff
|
export const getElementBounds = (element: ExcalidrawElement): Bounds => {
|
||||||
export const getElementBounds = (
|
return ElementBounds.getBounds(element);
|
||||||
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 getCommonBounds = (
|
export const getCommonBounds = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
): [number, number, number, number] => {
|
): Bounds => {
|
||||||
if (!elements.length) {
|
if (!elements.length) {
|
||||||
return [0, 0, 0, 0];
|
return [0, 0, 0, 0];
|
||||||
}
|
}
|
||||||
@ -608,7 +752,7 @@ export const getElementPointsCoords = (
|
|||||||
export const getClosestElementBounds = (
|
export const getClosestElementBounds = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
from: { x: number; y: number },
|
from: { x: number; y: number },
|
||||||
): [number, number, number, number] => {
|
): Bounds => {
|
||||||
if (!elements.length) {
|
if (!elements.length) {
|
||||||
return [0, 0, 0, 0];
|
return [0, 0, 0, 0];
|
||||||
}
|
}
|
||||||
@ -629,7 +773,7 @@ export const getClosestElementBounds = (
|
|||||||
return getElementBounds(closestElement);
|
return getElementBounds(closestElement);
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Box {
|
export interface BoundingBox {
|
||||||
minX: number;
|
minX: number;
|
||||||
minY: number;
|
minY: number;
|
||||||
maxX: number;
|
maxX: number;
|
||||||
@ -642,7 +786,7 @@ export interface Box {
|
|||||||
|
|
||||||
export const getCommonBoundingBox = (
|
export const getCommonBoundingBox = (
|
||||||
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
elements: ExcalidrawElement[] | readonly NonDeleted<ExcalidrawElement>[],
|
||||||
): Box => {
|
): BoundingBox => {
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
return {
|
return {
|
||||||
minX,
|
minX,
|
||||||
|
@ -26,10 +26,16 @@ import {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawLinearElement,
|
ExcalidrawLinearElement,
|
||||||
StrokeRoundness,
|
StrokeRoundness,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import { getElementAbsoluteCoords, getCurvePathOps, Bounds } from "./bounds";
|
import {
|
||||||
import { Point } from "../types";
|
getElementAbsoluteCoords,
|
||||||
|
getCurvePathOps,
|
||||||
|
getRectangleBoxAbsoluteCoords,
|
||||||
|
RectangleBox,
|
||||||
|
} from "./bounds";
|
||||||
|
import { FrameNameBoundsCache, Point } from "../types";
|
||||||
import { Drawable } from "roughjs/bin/core";
|
import { Drawable } from "roughjs/bin/core";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { getShapeForElement } from "../renderer/renderElement";
|
import { getShapeForElement } from "../renderer/renderElement";
|
||||||
@ -61,6 +67,7 @@ const isElementDraggableFromInside = (
|
|||||||
export const hitTest = (
|
export const hitTest = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
frameNameBoundsCache: FrameNameBoundsCache,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@ -72,22 +79,39 @@ export const hitTest = (
|
|||||||
isElementSelected(appState, element) &&
|
isElementSelected(appState, element) &&
|
||||||
shouldShowBoundingBox([element], appState)
|
shouldShowBoundingBox([element], appState)
|
||||||
) {
|
) {
|
||||||
return isPointHittingElementBoundingBox(element, point, threshold);
|
return isPointHittingElementBoundingBox(
|
||||||
|
element,
|
||||||
|
point,
|
||||||
|
threshold,
|
||||||
|
frameNameBoundsCache,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (boundTextElement) {
|
if (boundTextElement) {
|
||||||
const isHittingBoundTextElement = hitTest(boundTextElement, appState, x, y);
|
const isHittingBoundTextElement = hitTest(
|
||||||
|
boundTextElement,
|
||||||
|
appState,
|
||||||
|
frameNameBoundsCache,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
);
|
||||||
if (isHittingBoundTextElement) {
|
if (isHittingBoundTextElement) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return isHittingElementNotConsideringBoundingBox(element, appState, point);
|
return isHittingElementNotConsideringBoundingBox(
|
||||||
|
element,
|
||||||
|
appState,
|
||||||
|
frameNameBoundsCache,
|
||||||
|
point,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isHittingElementBoundingBoxWithoutHittingElement = (
|
export const isHittingElementBoundingBoxWithoutHittingElement = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
frameNameBoundsCache: FrameNameBoundsCache,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
): boolean => {
|
): 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
|
// 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
|
// eg for linear elements text can be outside the element bounding box
|
||||||
const boundTextElement = getBoundTextElement(element);
|
const boundTextElement = getBoundTextElement(element);
|
||||||
if (boundTextElement && hitTest(boundTextElement, appState, x, y)) {
|
if (
|
||||||
|
boundTextElement &&
|
||||||
|
hitTest(boundTextElement, appState, frameNameBoundsCache, x, y)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
!isHittingElementNotConsideringBoundingBox(element, appState, [x, y]) &&
|
!isHittingElementNotConsideringBoundingBox(
|
||||||
isPointHittingElementBoundingBox(element, [x, y], threshold)
|
element,
|
||||||
|
appState,
|
||||||
|
frameNameBoundsCache,
|
||||||
|
[x, y],
|
||||||
|
) &&
|
||||||
|
isPointHittingElementBoundingBox(
|
||||||
|
element,
|
||||||
|
[x, y],
|
||||||
|
threshold,
|
||||||
|
frameNameBoundsCache,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isHittingElementNotConsideringBoundingBox = (
|
export const isHittingElementNotConsideringBoundingBox = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
frameNameBoundsCache: FrameNameBoundsCache | null,
|
||||||
point: Point,
|
point: Point,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const threshold = 10 / appState.zoom.value;
|
const threshold = 10 / appState.zoom.value;
|
||||||
@ -117,7 +155,13 @@ export const isHittingElementNotConsideringBoundingBox = (
|
|||||||
: isElementDraggableFromInside(element)
|
: isElementDraggableFromInside(element)
|
||||||
? isInsideCheck
|
? isInsideCheck
|
||||||
: isNearCheck;
|
: isNearCheck;
|
||||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
return hitTestPointAgainstElement({
|
||||||
|
element,
|
||||||
|
point,
|
||||||
|
threshold,
|
||||||
|
check,
|
||||||
|
frameNameBoundsCache,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isElementSelected = (
|
const isElementSelected = (
|
||||||
@ -129,7 +173,22 @@ export const isPointHittingElementBoundingBox = (
|
|||||||
element: NonDeleted<ExcalidrawElement>,
|
element: NonDeleted<ExcalidrawElement>,
|
||||||
[x, y]: Point,
|
[x, y]: Point,
|
||||||
threshold: number,
|
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 [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const elementCenterX = (x1 + x2) / 2;
|
const elementCenterX = (x1 + x2) / 2;
|
||||||
const elementCenterY = (y1 + y2) / 2;
|
const elementCenterY = (y1 + y2) / 2;
|
||||||
@ -157,7 +216,13 @@ export const bindingBorderTest = (
|
|||||||
const threshold = maxBindingGap(element, element.width, element.height);
|
const threshold = maxBindingGap(element, element.width, element.height);
|
||||||
const check = isOutsideCheck;
|
const check = isOutsideCheck;
|
||||||
const point: Point = [x, y];
|
const point: Point = [x, y];
|
||||||
return hitTestPointAgainstElement({ element, point, threshold, check });
|
return hitTestPointAgainstElement({
|
||||||
|
element,
|
||||||
|
point,
|
||||||
|
threshold,
|
||||||
|
check,
|
||||||
|
frameNameBoundsCache: null,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maxBindingGap = (
|
export const maxBindingGap = (
|
||||||
@ -177,6 +242,7 @@ type HitTestArgs = {
|
|||||||
point: Point;
|
point: Point;
|
||||||
threshold: number;
|
threshold: number;
|
||||||
check: (distance: number, threshold: number) => boolean;
|
check: (distance: number, threshold: number) => boolean;
|
||||||
|
frameNameBoundsCache: FrameNameBoundsCache | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hitTestPointAgainstElement = (args: HitTestArgs): boolean => {
|
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.",
|
"This should not happen, we need to investigate why it does.",
|
||||||
);
|
);
|
||||||
return false;
|
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 "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "frame":
|
||||||
return distanceToRectangle(element, point);
|
return distanceToRectangle(element, point);
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return distanceToDiamond(element, point);
|
return distanceToDiamond(element, point);
|
||||||
@ -248,7 +336,8 @@ const distanceToRectangle = (
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawImageElement,
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawFrameElement,
|
||||||
point: Point,
|
point: Point,
|
||||||
): number => {
|
): number => {
|
||||||
const [, pointRel, hwidth, hheight] = pointRelativeToElement(element, point);
|
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 = (
|
const distanceToDiamond = (
|
||||||
element: ExcalidrawDiamondElement,
|
element: ExcalidrawDiamondElement,
|
||||||
point: Point,
|
point: Point,
|
||||||
@ -457,8 +554,7 @@ const pointRelativeToElement = (
|
|||||||
): [GA.Point, GA.Point, number, number] => {
|
): [GA.Point, GA.Point, number, number] => {
|
||||||
const point = GAPoint.from(pointTuple);
|
const point = GAPoint.from(pointTuple);
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
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`
|
// GA has angle orientation opposite to `rotate`
|
||||||
const rotate = GATransform.rotation(center, element.angle);
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
const pointRotated = GATransform.apply(rotate, point);
|
const pointRotated = GATransform.apply(rotate, point);
|
||||||
@ -466,9 +562,26 @@ const pointRelativeToElement = (
|
|||||||
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
const pointRelToCenterAbs = GAPoint.abs(pointRelToCenter);
|
||||||
const elementPos = GA.offset(element.x, element.y);
|
const elementPos = GA.offset(element.x, element.y);
|
||||||
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
const pointRelToPos = GA.sub(pointRotated, elementPos);
|
||||||
const [ax, ay, bx, by] = elementCoords;
|
const halfWidth = (x2 - x1) / 2;
|
||||||
const halfWidth = (bx - ax) / 2;
|
const halfHeight = (y2 - y1) / 2;
|
||||||
const halfHeight = (by - ay) / 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];
|
return [pointRelToPos, pointRelToCenterAbs, halfWidth, halfHeight];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -490,7 +603,7 @@ const relativizationToElementCenter = (
|
|||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
): GA.Transform => {
|
): GA.Transform => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
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`
|
// GA has angle orientation opposite to `rotate`
|
||||||
const rotate = GATransform.rotation(center, element.angle);
|
const rotate = GATransform.rotation(center, element.angle);
|
||||||
const translate = GA.reverse(
|
const translate = GA.reverse(
|
||||||
@ -499,8 +612,13 @@ const relativizationToElementCenter = (
|
|||||||
return GATransform.compose(rotate, translate);
|
return GATransform.compose(rotate, translate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const coordsCenter = ([ax, ay, bx, by]: Bounds): GA.Point => {
|
const coordsCenter = (
|
||||||
return GA.point((ax + bx) / 2, (ay + by) / 2);
|
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
|
// The focus distance is the oriented ratio between the size of
|
||||||
@ -531,6 +649,7 @@ export const determineFocusDistance = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "frame":
|
||||||
return c / (hwidth * (nabs + q * mabs));
|
return c / (hwidth * (nabs + q * mabs));
|
||||||
case "diamond":
|
case "diamond":
|
||||||
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
return mabs < nabs ? c / (nabs * hwidth) : c / (mabs * hheight);
|
||||||
@ -548,7 +667,7 @@ export const determineFocusPoint = (
|
|||||||
): Point => {
|
): Point => {
|
||||||
if (focus === 0) {
|
if (focus === 0) {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
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);
|
return GAPoint.toTuple(center);
|
||||||
}
|
}
|
||||||
const relateToCenter = relativizationToElementCenter(element);
|
const relateToCenter = relativizationToElementCenter(element);
|
||||||
@ -563,6 +682,7 @@ export const determineFocusPoint = (
|
|||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
point = findFocusPointForRectangulars(element, focus, adjecentPointRel);
|
||||||
break;
|
break;
|
||||||
case "ellipse":
|
case "ellipse":
|
||||||
@ -613,6 +733,7 @@ const getSortedElementLineIntersections = (
|
|||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
|
case "frame":
|
||||||
const corners = getCorners(element);
|
const corners = getCorners(element);
|
||||||
intersections = corners
|
intersections = corners
|
||||||
.flatMap((point, i) => {
|
.flatMap((point, i) => {
|
||||||
@ -646,7 +767,8 @@ const getCorners = (
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawTextElement,
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawFrameElement,
|
||||||
scale: number = 1,
|
scale: number = 1,
|
||||||
): GA.Point[] => {
|
): GA.Point[] => {
|
||||||
const hx = (scale * element.width) / 2;
|
const hx = (scale * element.width) / 2;
|
||||||
@ -655,6 +777,7 @@ const getCorners = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "image":
|
case "image":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "frame":
|
||||||
return [
|
return [
|
||||||
GA.point(hx, hy),
|
GA.point(hx, hy),
|
||||||
GA.point(hx, -hy),
|
GA.point(hx, -hy),
|
||||||
@ -802,7 +925,8 @@ export const findFocusPointForRectangulars = (
|
|||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
| ExcalidrawImageElement
|
| ExcalidrawImageElement
|
||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawTextElement,
|
| ExcalidrawTextElement
|
||||||
|
| ExcalidrawFrameElement,
|
||||||
// Between -1 and 1 for how far away should the focus point be relative
|
// Between -1 and 1 for how far away should the focus point be relative
|
||||||
// to the size of the element. Sign determines orientation.
|
// to the size of the element. Sign determines orientation.
|
||||||
relativeDistance: number,
|
relativeDistance: number,
|
||||||
|
@ -6,6 +6,8 @@ import { NonDeletedExcalidrawElement } from "./types";
|
|||||||
import { AppState, PointerDownState } from "../types";
|
import { AppState, PointerDownState } from "../types";
|
||||||
import { getBoundTextElement } from "./textElement";
|
import { getBoundTextElement } from "./textElement";
|
||||||
import { isSelectedViaGroup } from "../groups";
|
import { isSelectedViaGroup } from "../groups";
|
||||||
|
import Scene from "../scene/Scene";
|
||||||
|
import { isFrameElement } from "./typeChecks";
|
||||||
|
|
||||||
export const dragSelectedElements = (
|
export const dragSelectedElements = (
|
||||||
pointerDownState: PointerDownState,
|
pointerDownState: PointerDownState,
|
||||||
@ -16,10 +18,31 @@ export const dragSelectedElements = (
|
|||||||
distanceX: number = 0,
|
distanceX: number = 0,
|
||||||
distanceY: number = 0,
|
distanceY: number = 0,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
scene: Scene,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1] = getCommonBounds(selectedElements);
|
const [x1, y1] = getCommonBounds(selectedElements);
|
||||||
const offset = { x: pointerX - x1, y: pointerY - y1 };
|
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(
|
updateElementCoords(
|
||||||
lockDirection,
|
lockDirection,
|
||||||
distanceX,
|
distanceX,
|
||||||
@ -38,7 +61,13 @@ export const dragSelectedElements = (
|
|||||||
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
(appState.editingGroupId && !isSelectedViaGroup(appState, element))
|
||||||
) {
|
) {
|
||||||
const textElement = getBoundTextElement(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(
|
updateElementCoords(
|
||||||
lockDirection,
|
lockDirection,
|
||||||
distanceX,
|
distanceX,
|
||||||
@ -50,7 +79,7 @@ export const dragSelectedElements = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateBoundElements(element, {
|
updateBoundElements(element, {
|
||||||
simultaneouslyUpdated: selectedElements,
|
simultaneouslyUpdated: Array.from(elementsToUpdate),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -2,6 +2,7 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { isInvisiblySmallElement } from "./sizeHelpers";
|
import { isInvisiblySmallElement } from "./sizeHelpers";
|
||||||
import { isLinearElementType } from "./typeChecks";
|
import { isLinearElementType } from "./typeChecks";
|
||||||
@ -49,7 +50,11 @@ export {
|
|||||||
getDragOffsetXY,
|
getDragOffsetXY,
|
||||||
dragNewElement,
|
dragNewElement,
|
||||||
} from "./dragElements";
|
} from "./dragElements";
|
||||||
export { isTextElement, isExcalidrawElement } from "./typeChecks";
|
export {
|
||||||
|
isTextElement,
|
||||||
|
isExcalidrawElement,
|
||||||
|
isFrameElement,
|
||||||
|
} from "./typeChecks";
|
||||||
export { textWysiwyg } from "./textWysiwyg";
|
export { textWysiwyg } from "./textWysiwyg";
|
||||||
export { redrawTextBoundingBox } from "./textElement";
|
export { redrawTextBoundingBox } from "./textElement";
|
||||||
export {
|
export {
|
||||||
@ -74,6 +79,13 @@ export const getNonDeletedElements = (elements: readonly ExcalidrawElement[]) =>
|
|||||||
(element) => !element.isDeleted,
|
(element) => !element.isDeleted,
|
||||||
) as readonly NonDeletedExcalidrawElement[];
|
) as readonly NonDeletedExcalidrawElement[];
|
||||||
|
|
||||||
|
export const getNonDeletedFrames = (
|
||||||
|
frames: readonly ExcalidrawFrameElement[],
|
||||||
|
) =>
|
||||||
|
frames.filter(
|
||||||
|
(frame) => !frame.isDeleted,
|
||||||
|
) as readonly NonDeleted<ExcalidrawFrameElement>[];
|
||||||
|
|
||||||
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
export const isNonDeletedElement = <T extends ExcalidrawElement>(
|
||||||
element: T,
|
element: T,
|
||||||
): element is NonDeleted<T> => !element.isDeleted;
|
): element is NonDeleted<T> => !element.isDeleted;
|
||||||
|
@ -594,7 +594,7 @@ export class LinearElementEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static handlePointerDown(
|
static handlePointerDown(
|
||||||
event: React.PointerEvent<HTMLCanvasElement>,
|
event: React.PointerEvent<HTMLElement>,
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
history: History,
|
history: History,
|
||||||
scenePointer: { x: number; y: number },
|
scenePointer: { x: number; y: number },
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
ExcalidrawFreeDrawElement,
|
ExcalidrawFreeDrawElement,
|
||||||
FontFamilyValues,
|
FontFamilyValues,
|
||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
arrayToMap,
|
arrayToMap,
|
||||||
@ -50,6 +51,7 @@ type ElementConstructorOpts = MarkOptional<
|
|||||||
| "height"
|
| "height"
|
||||||
| "angle"
|
| "angle"
|
||||||
| "groupIds"
|
| "groupIds"
|
||||||
|
| "frameId"
|
||||||
| "boundElements"
|
| "boundElements"
|
||||||
| "seed"
|
| "seed"
|
||||||
| "version"
|
| "version"
|
||||||
@ -82,6 +84,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
height = 0,
|
height = 0,
|
||||||
angle = 0,
|
angle = 0,
|
||||||
groupIds = [],
|
groupIds = [],
|
||||||
|
frameId = null,
|
||||||
roundness = null,
|
roundness = null,
|
||||||
boundElements = null,
|
boundElements = null,
|
||||||
link = null,
|
link = null,
|
||||||
@ -106,6 +109,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
|
|||||||
roughness,
|
roughness,
|
||||||
opacity,
|
opacity,
|
||||||
groupIds,
|
groupIds,
|
||||||
|
frameId,
|
||||||
roundness,
|
roundness,
|
||||||
seed: rest.seed ?? randomInteger(),
|
seed: rest.seed ?? randomInteger(),
|
||||||
version: rest.version || 1,
|
version: rest.version || 1,
|
||||||
@ -126,6 +130,21 @@ export const newElement = (
|
|||||||
): NonDeleted<ExcalidrawGenericElement> =>
|
): NonDeleted<ExcalidrawGenericElement> =>
|
||||||
_newElementBase<ExcalidrawGenericElement>(opts.type, opts);
|
_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 */
|
/** computes element x/y offset based on textAlign/verticalAlign */
|
||||||
const getTextElementPositionOffsets = (
|
const getTextElementPositionOffsets = (
|
||||||
opts: {
|
opts: {
|
||||||
@ -158,6 +177,7 @@ export const newTextElement = (
|
|||||||
containerId?: ExcalidrawTextContainer["id"];
|
containerId?: ExcalidrawTextContainer["id"];
|
||||||
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
lineHeight?: ExcalidrawTextElement["lineHeight"];
|
||||||
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
strokeWidth?: ExcalidrawTextElement["strokeWidth"];
|
||||||
|
isFrameName?: boolean;
|
||||||
} & ElementConstructorOpts,
|
} & ElementConstructorOpts,
|
||||||
): NonDeleted<ExcalidrawTextElement> => {
|
): NonDeleted<ExcalidrawTextElement> => {
|
||||||
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
const fontFamily = opts.fontFamily || DEFAULT_FONT_FAMILY;
|
||||||
@ -192,6 +212,7 @@ export const newTextElement = (
|
|||||||
containerId: opts.containerId || null,
|
containerId: opts.containerId || null,
|
||||||
originalText: text,
|
originalText: text,
|
||||||
lineHeight,
|
lineHeight,
|
||||||
|
isFrameName: opts.isFrameName || false,
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
@ -612,6 +633,10 @@ export const duplicateElements = (
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clonedElement.frameId) {
|
||||||
|
clonedElement.frameId = maybeGetNewId(clonedElement.frameId);
|
||||||
|
}
|
||||||
|
|
||||||
clonedElements.push(clonedElement);
|
clonedElements.push(clonedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
isArrowElement,
|
isArrowElement,
|
||||||
isBoundToContainer,
|
isBoundToContainer,
|
||||||
|
isFrameElement,
|
||||||
isFreeDrawElement,
|
isFreeDrawElement,
|
||||||
isImageElement,
|
isImageElement,
|
||||||
isLinearElement,
|
isLinearElement,
|
||||||
@ -160,12 +161,17 @@ const rotateSingleElement = (
|
|||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
const cx = (x1 + x2) / 2;
|
const cx = (x1 + x2) / 2;
|
||||||
const cy = (y1 + y2) / 2;
|
const cy = (y1 + y2) / 2;
|
||||||
let angle = (5 * Math.PI) / 2 + Math.atan2(pointerY - cy, pointerX - cx);
|
let angle: number;
|
||||||
if (shouldRotateWithDiscreteAngle) {
|
if (isFrameElement(element)) {
|
||||||
angle += SHIFT_LOCKING_ANGLE / 2;
|
angle = 0;
|
||||||
angle -= angle % SHIFT_LOCKING_ANGLE;
|
} 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);
|
const boundTextElementId = getBoundTextElementId(element);
|
||||||
|
|
||||||
mutateElement(element, { angle });
|
mutateElement(element, { angle });
|
||||||
@ -877,45 +883,47 @@ const rotateMultipleElements = (
|
|||||||
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
centerAngle += SHIFT_LOCKING_ANGLE / 2;
|
||||||
centerAngle -= centerAngle % SHIFT_LOCKING_ANGLE;
|
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(
|
elements
|
||||||
element,
|
.filter((element) => element.type !== "frame")
|
||||||
{
|
.forEach((element) => {
|
||||||
x: element.x + (rotatedCX - cx),
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
y: element.y + (rotatedCY - cy),
|
const cx = (x1 + x2) / 2;
|
||||||
angle: normalizeAngle(centerAngle + origAngle),
|
const cy = (y1 + y2) / 2;
|
||||||
},
|
const origAngle =
|
||||||
false,
|
pointerDownState.originalElements.get(element.id)?.angle ??
|
||||||
);
|
element.angle;
|
||||||
|
const [rotatedCX, rotatedCY] = rotate(
|
||||||
updateBoundElements(element, { simultaneouslyUpdated: elements });
|
cx,
|
||||||
|
cy,
|
||||||
const boundText = getBoundTextElement(element);
|
centerX,
|
||||||
if (boundText && !isArrowElement(element)) {
|
centerY,
|
||||||
|
centerAngle + origAngle - element.angle,
|
||||||
|
);
|
||||||
mutateElement(
|
mutateElement(
|
||||||
boundText,
|
element,
|
||||||
{
|
{
|
||||||
x: boundText.x + (rotatedCX - cx),
|
x: element.x + (rotatedCX - cx),
|
||||||
y: boundText.y + (rotatedCY - cy),
|
y: element.y + (rotatedCY - cy),
|
||||||
angle: normalizeAngle(centerAngle + origAngle),
|
angle: normalizeAngle(centerAngle + origAngle),
|
||||||
},
|
},
|
||||||
false,
|
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();
|
Scene.getScene(elements[0])?.informMutation();
|
||||||
};
|
};
|
||||||
|
@ -840,10 +840,12 @@ export const getTextBindableContainerAtPosition = (
|
|||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[index]);
|
||||||
if (
|
if (
|
||||||
isArrowElement(elements[index]) &&
|
isArrowElement(elements[index]) &&
|
||||||
isHittingElementNotConsideringBoundingBox(elements[index], appState, [
|
isHittingElementNotConsideringBoundingBox(
|
||||||
x,
|
elements[index],
|
||||||
y,
|
appState,
|
||||||
])
|
null,
|
||||||
|
[x, y],
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
hitElement = elements[index];
|
hitElement = elements[index];
|
||||||
break;
|
break;
|
||||||
|
@ -8,7 +8,7 @@ import { getElementAbsoluteCoords } from "./bounds";
|
|||||||
import { rotate } from "../math";
|
import { rotate } from "../math";
|
||||||
import { AppState, Zoom } from "../types";
|
import { AppState, Zoom } from "../types";
|
||||||
import { isTextElement } from ".";
|
import { isTextElement } from ".";
|
||||||
import { isLinearElement } from "./typeChecks";
|
import { isFrameElement, isLinearElement } from "./typeChecks";
|
||||||
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
import { DEFAULT_SPACING } from "../renderer/renderScene";
|
||||||
|
|
||||||
export type TransformHandleDirection =
|
export type TransformHandleDirection =
|
||||||
@ -44,6 +44,14 @@ export const OMIT_SIDES_FOR_MULTIPLE_ELEMENTS = {
|
|||||||
w: true,
|
w: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const OMIT_SIDES_FOR_FRAME = {
|
||||||
|
e: true,
|
||||||
|
s: true,
|
||||||
|
n: true,
|
||||||
|
w: true,
|
||||||
|
rotation: true,
|
||||||
|
};
|
||||||
|
|
||||||
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
const OMIT_SIDES_FOR_TEXT_ELEMENT = {
|
||||||
e: true,
|
e: true,
|
||||||
s: true,
|
s: true,
|
||||||
@ -249,6 +257,10 @@ export const getTransformHandles = (
|
|||||||
}
|
}
|
||||||
} else if (isTextElement(element)) {
|
} else if (isTextElement(element)) {
|
||||||
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
omitSides = OMIT_SIDES_FOR_TEXT_ELEMENT;
|
||||||
|
} else if (isFrameElement(element)) {
|
||||||
|
omitSides = {
|
||||||
|
rotation: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const dashedLineMargin = isLinearElement(element)
|
const dashedLineMargin = isLinearElement(element)
|
||||||
? DEFAULT_SPACING + 8
|
? DEFAULT_SPACING + 8
|
||||||
|
@ -12,6 +12,7 @@ import {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
ExcalidrawTextElementWithContainer,
|
ExcalidrawTextElementWithContainer,
|
||||||
ExcalidrawTextContainer,
|
ExcalidrawTextContainer,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
RoundnessType,
|
RoundnessType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
@ -45,6 +46,12 @@ export const isTextElement = (
|
|||||||
return element != null && element.type === "text";
|
return element != null && element.type === "text";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isFrameElement = (
|
||||||
|
element: ExcalidrawElement | null,
|
||||||
|
): element is ExcalidrawFrameElement => {
|
||||||
|
return element != null && element.type === "frame";
|
||||||
|
};
|
||||||
|
|
||||||
export const isFreeDrawElement = (
|
export const isFreeDrawElement = (
|
||||||
element?: ExcalidrawElement | null,
|
element?: ExcalidrawElement | null,
|
||||||
): element is ExcalidrawFreeDrawElement => {
|
): element is ExcalidrawFreeDrawElement => {
|
||||||
|
@ -53,6 +53,7 @@ type _ExcalidrawElementBase = Readonly<{
|
|||||||
/** List of groups the element belongs to.
|
/** List of groups the element belongs to.
|
||||||
Ordered from deepest to shallowest. */
|
Ordered from deepest to shallowest. */
|
||||||
groupIds: readonly GroupId[];
|
groupIds: readonly GroupId[];
|
||||||
|
frameId: string | null;
|
||||||
/** other elements that are bound to this element */
|
/** other elements that are bound to this element */
|
||||||
boundElements:
|
boundElements:
|
||||||
| readonly Readonly<{
|
| readonly Readonly<{
|
||||||
@ -98,6 +99,11 @@ export type InitializedExcalidrawImageElement = MarkNonNullable<
|
|||||||
"fileId"
|
"fileId"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ExcalidrawFrameElement = _ExcalidrawElementBase & {
|
||||||
|
type: "frame";
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* These are elements that don't have any additional properties.
|
* These are elements that don't have any additional properties.
|
||||||
*/
|
*/
|
||||||
@ -117,7 +123,8 @@ export type ExcalidrawElement =
|
|||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawLinearElement
|
| ExcalidrawLinearElement
|
||||||
| ExcalidrawFreeDrawElement
|
| ExcalidrawFreeDrawElement
|
||||||
| ExcalidrawImageElement;
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawFrameElement;
|
||||||
|
|
||||||
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
export type NonDeleted<TElement extends ExcalidrawElement> = TElement & {
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
@ -148,7 +155,8 @@ export type ExcalidrawBindableElement =
|
|||||||
| ExcalidrawDiamondElement
|
| ExcalidrawDiamondElement
|
||||||
| ExcalidrawEllipseElement
|
| ExcalidrawEllipseElement
|
||||||
| ExcalidrawTextElement
|
| ExcalidrawTextElement
|
||||||
| ExcalidrawImageElement;
|
| ExcalidrawImageElement
|
||||||
|
| ExcalidrawFrameElement;
|
||||||
|
|
||||||
export type ExcalidrawTextContainer =
|
export type ExcalidrawTextContainer =
|
||||||
| ExcalidrawRectangleElement
|
| ExcalidrawRectangleElement
|
||||||
|
705
src/frame.ts
Normal file
705
src/frame.ts
Normal file
@ -0,0 +1,705 @@
|
|||||||
|
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: ExcalidrawElementsIncludingDeleted) => {
|
||||||
|
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 (
|
||||||
|
!isFrameElement(element) &&
|
||||||
|
!isElementInFrame(element, allElements, appState)
|
||||||
|
) {
|
||||||
|
elementsToRemove.add(element);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return removeElementsFromFrame(allElements, [...elementsToRemove], appState);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
};
|
@ -94,6 +94,31 @@ export const selectGroupsForSelectedElements = (
|
|||||||
return nextAppState;
|
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 = (
|
export const editGroupForSelectedElement = (
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
element: NonDeleted<ExcalidrawElement>,
|
element: NonDeleted<ExcalidrawElement>,
|
||||||
@ -186,3 +211,18 @@ export const getMaximumGroups = (
|
|||||||
|
|
||||||
return Array.from(groups.values());
|
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;
|
||||||
|
};
|
||||||
|
@ -42,6 +42,7 @@ export const KEYS = {
|
|||||||
CHEVRON_RIGHT: ">",
|
CHEVRON_RIGHT: ">",
|
||||||
PERIOD: ".",
|
PERIOD: ".",
|
||||||
COMMA: ",",
|
COMMA: ",",
|
||||||
|
SUBTRACT: "-",
|
||||||
|
|
||||||
A: "a",
|
A: "a",
|
||||||
C: "c",
|
C: "c",
|
||||||
|
@ -124,6 +124,8 @@
|
|||||||
},
|
},
|
||||||
"statusPublished": "Published",
|
"statusPublished": "Published",
|
||||||
"sidebarLock": "Keep sidebar open",
|
"sidebarLock": "Keep sidebar open",
|
||||||
|
"selectAllElementsInFrame": "Select all elements in frame",
|
||||||
|
"removeAllElementsFromFrame": "Remove all elements from frame",
|
||||||
"eyeDropper": "Pick color from canvas"
|
"eyeDropper": "Pick color from canvas"
|
||||||
},
|
},
|
||||||
"library": {
|
"library": {
|
||||||
@ -221,7 +223,9 @@
|
|||||||
"penMode": "Pen mode - prevent touch",
|
"penMode": "Pen mode - prevent touch",
|
||||||
"link": "Add/ Update link for a selected shape",
|
"link": "Add/ Update link for a selected shape",
|
||||||
"eraser": "Eraser",
|
"eraser": "Eraser",
|
||||||
"hand": "Hand (panning tool)"
|
"frame": "Frame tool",
|
||||||
|
"hand": "Hand (panning tool)",
|
||||||
|
"extraTools": "More tools"
|
||||||
},
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"canvasActions": "Canvas actions",
|
"canvasActions": "Canvas actions",
|
||||||
|
@ -206,7 +206,7 @@ export const isPointInPolygon = (
|
|||||||
|
|
||||||
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
|
// Returns whether `q` lies inside the segment/rectangle defined by `p` and `r`.
|
||||||
// This is an approximation to "does `q` lie on a segment `pr`" check.
|
// This is an approximation to "does `q` lie on a segment `pr`" check.
|
||||||
const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
|
export const isPointWithinBounds = (p: Point, q: Point, r: Point) => {
|
||||||
return (
|
return (
|
||||||
q[0] <= Math.max(p[0], r[0]) &&
|
q[0] <= Math.max(p[0], r[0]) &&
|
||||||
q[0] >= Math.min(p[0], r[0]) &&
|
q[0] >= Math.min(p[0], r[0]) &&
|
||||||
|
@ -205,6 +205,7 @@ export default function App({ appTitle, useCustom, customArgs }: AppProps) {
|
|||||||
height: 141.9765625,
|
height: 141.9765625,
|
||||||
seed: 1968410350,
|
seed: 1968410350,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
boundElements: null,
|
boundElements: null,
|
||||||
locked: false,
|
locked: false,
|
||||||
link: null,
|
link: null,
|
||||||
|
@ -20,6 +20,7 @@ export default {
|
|||||||
height: 141.9765625,
|
height: 141.9765625,
|
||||||
seed: 1968410350,
|
seed: 1968410350,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "-xMIs_0jIFqvpx-R9UnaG",
|
id: "-xMIs_0jIFqvpx-R9UnaG",
|
||||||
@ -37,6 +38,7 @@ export default {
|
|||||||
roughness: 1,
|
roughness: 1,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
seed: 957947807,
|
seed: 957947807,
|
||||||
version: 47,
|
version: 47,
|
||||||
versionNonce: 1128618623,
|
versionNonce: 1128618623,
|
||||||
@ -58,6 +60,7 @@ export default {
|
|||||||
roughness: 1,
|
roughness: 1,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
seed: 707269846,
|
seed: 707269846,
|
||||||
version: 143,
|
version: 143,
|
||||||
@ -94,6 +97,7 @@ export default {
|
|||||||
height: 103.65107323746608,
|
height: 103.65107323746608,
|
||||||
seed: 1445523839,
|
seed: 1445523839,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -133,6 +137,7 @@ export default {
|
|||||||
height: 113.8575037534261,
|
height: 113.8575037534261,
|
||||||
seed: 1513238033,
|
seed: 1513238033,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -182,6 +187,7 @@ export default {
|
|||||||
height: 9.797916664247975,
|
height: 9.797916664247975,
|
||||||
seed: 683951089,
|
seed: 683951089,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -220,6 +226,7 @@ export default {
|
|||||||
height: 9.797916664247975,
|
height: 9.797916664247975,
|
||||||
seed: 1817746897,
|
seed: 1817746897,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -258,6 +265,7 @@ export default {
|
|||||||
height: 17.72670397681366,
|
height: 17.72670397681366,
|
||||||
seed: 1409727409,
|
seed: 1409727409,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
|
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
|
||||||
},
|
},
|
||||||
@ -281,6 +289,7 @@ export default {
|
|||||||
height: 13.941904362416096,
|
height: 13.941904362416096,
|
||||||
seed: 1073094033,
|
seed: 1073094033,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -304,6 +313,7 @@ export default {
|
|||||||
height: 13.941904362416096,
|
height: 13.941904362416096,
|
||||||
seed: 526271345,
|
seed: 526271345,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -327,6 +337,7 @@ export default {
|
|||||||
height: 13.941904362416096,
|
height: 13.941904362416096,
|
||||||
seed: 243707217,
|
seed: 243707217,
|
||||||
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
groupIds: ["N2YAi9nU-wlRb0rDaDZoe"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -352,6 +363,7 @@ export default {
|
|||||||
height: 36.77344700318558,
|
height: 36.77344700318558,
|
||||||
seed: 511870335,
|
seed: 511870335,
|
||||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -375,6 +387,7 @@ export default {
|
|||||||
height: 36.77344700318558,
|
height: 36.77344700318558,
|
||||||
seed: 1283079231,
|
seed: 1283079231,
|
||||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -398,6 +411,7 @@ export default {
|
|||||||
height: 36.77344700318558,
|
height: 36.77344700318558,
|
||||||
seed: 996251633,
|
seed: 996251633,
|
||||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -421,6 +435,7 @@ export default {
|
|||||||
height: 36.77344700318558,
|
height: 36.77344700318558,
|
||||||
seed: 1764842481,
|
seed: 1764842481,
|
||||||
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
groupIds: ["M6ByXuSmtHCr3RtPPKJQh"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -446,6 +461,7 @@ export default {
|
|||||||
height: 154.56722543646003,
|
height: 154.56722543646003,
|
||||||
seed: 1424381745,
|
seed: 1424381745,
|
||||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -495,6 +511,7 @@ export default {
|
|||||||
height: 12.698053371678215,
|
height: 12.698053371678215,
|
||||||
seed: 726657713,
|
seed: 726657713,
|
||||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -533,6 +550,7 @@ export default {
|
|||||||
height: 10.178760037658167,
|
height: 10.178760037658167,
|
||||||
seed: 1977326481,
|
seed: 1977326481,
|
||||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -571,6 +589,7 @@ export default {
|
|||||||
height: 22.797152568995934,
|
height: 22.797152568995934,
|
||||||
seed: 1774660383,
|
seed: 1774660383,
|
||||||
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
groupIds: ["HSrtfEf-CssQTf160Fb6R"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
|
boundElementIds: ["bxuMGTzXLn7H-uBCptINx"],
|
||||||
},
|
},
|
||||||
@ -596,6 +615,7 @@ export default {
|
|||||||
height: 107.25081879410921,
|
height: 107.25081879410921,
|
||||||
seed: 371096063,
|
seed: 371096063,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [
|
boundElementIds: [
|
||||||
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
||||||
@ -623,6 +643,7 @@ export default {
|
|||||||
height: 107.25081879410921,
|
height: 107.25081879410921,
|
||||||
seed: 685932433,
|
seed: 685932433,
|
||||||
groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["0RJwA-yKP5dqk5oMiSeot", "9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [
|
boundElementIds: [
|
||||||
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
||||||
@ -650,6 +671,7 @@ export default {
|
|||||||
height: 107.25081879410921,
|
height: 107.25081879410921,
|
||||||
seed: 58634943,
|
seed: 58634943,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [
|
boundElementIds: [
|
||||||
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
"CFu0B4Mw_1wC1Hbgx8Fs0",
|
||||||
@ -677,6 +699,7 @@ export default {
|
|||||||
height: 3.249953844290203,
|
height: 3.249953844290203,
|
||||||
seed: 1673003743,
|
seed: 1673003743,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
points: [
|
points: [
|
||||||
@ -708,6 +731,7 @@ export default {
|
|||||||
height: 2.8032978840147194,
|
height: 2.8032978840147194,
|
||||||
seed: 1821527807,
|
seed: 1821527807,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
points: [
|
points: [
|
||||||
@ -739,6 +763,7 @@ export default {
|
|||||||
height: 4.280657518731036,
|
height: 4.280657518731036,
|
||||||
seed: 1485707039,
|
seed: 1485707039,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
points: [
|
points: [
|
||||||
@ -771,6 +796,7 @@ export default {
|
|||||||
height: 2.9096445412231735,
|
height: 2.9096445412231735,
|
||||||
seed: 1042012991,
|
seed: 1042012991,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
points: [
|
points: [
|
||||||
@ -804,6 +830,7 @@ export default {
|
|||||||
height: 2.4757501798128,
|
height: 2.4757501798128,
|
||||||
seed: 295443295,
|
seed: 295443295,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
points: [
|
points: [
|
||||||
@ -835,6 +862,7 @@ export default {
|
|||||||
height: 2.4757501798128,
|
height: 2.4757501798128,
|
||||||
seed: 1734301567,
|
seed: 1734301567,
|
||||||
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
groupIds: ["9ppmKFUbA4iKjt8FaDFox"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
points: [
|
points: [
|
||||||
@ -869,6 +897,7 @@ export default {
|
|||||||
height: 76.53703389977764,
|
height: 76.53703389977764,
|
||||||
seed: 106569279,
|
seed: 106569279,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -892,6 +921,7 @@ export default {
|
|||||||
height: 0,
|
height: 0,
|
||||||
seed: 73916127,
|
seed: 73916127,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -924,6 +954,7 @@ export default {
|
|||||||
height: 5.001953125,
|
height: 5.001953125,
|
||||||
seed: 387857791,
|
seed: 387857791,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -947,6 +978,7 @@ export default {
|
|||||||
height: 5.001953125,
|
height: 5.001953125,
|
||||||
seed: 1486370207,
|
seed: 1486370207,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -970,6 +1002,7 @@ export default {
|
|||||||
height: 5.001953125,
|
height: 5.001953125,
|
||||||
seed: 610150847,
|
seed: 610150847,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -993,6 +1026,7 @@ export default {
|
|||||||
height: 42.72020253937572,
|
height: 42.72020253937572,
|
||||||
seed: 144280593,
|
seed: 144280593,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -1016,6 +1050,7 @@ export default {
|
|||||||
height: 24.44112284281997,
|
height: 24.44112284281997,
|
||||||
seed: 29167967,
|
seed: 29167967,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -1068,6 +1103,7 @@ export default {
|
|||||||
height: 0,
|
height: 0,
|
||||||
seed: 1443027377,
|
seed: 1443027377,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -1100,6 +1136,7 @@ export default {
|
|||||||
height: 5.711199931375845,
|
height: 5.711199931375845,
|
||||||
seed: 244310513,
|
seed: 244310513,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -1138,6 +1175,7 @@ export default {
|
|||||||
height: 44.82230388130942,
|
height: 44.82230388130942,
|
||||||
seed: 683572113,
|
seed: 683572113,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -1161,6 +1199,7 @@ export default {
|
|||||||
height: 5.896061363392446,
|
height: 5.896061363392446,
|
||||||
seed: 318798801,
|
seed: 318798801,
|
||||||
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
groupIds: ["TC0RSM64Cxmu17MlE12-o"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "round",
|
strokeSharpness: "round",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
startBinding: null,
|
startBinding: null,
|
||||||
@ -1200,6 +1239,7 @@ export default {
|
|||||||
height: 108.30428902193904,
|
height: 108.30428902193904,
|
||||||
seed: 1914896753,
|
seed: 1914896753,
|
||||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -1223,6 +1263,7 @@ export default {
|
|||||||
height: 82.83278895375764,
|
height: 82.83278895375764,
|
||||||
seed: 1306468145,
|
seed: 1306468145,
|
||||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -1246,6 +1287,7 @@ export default {
|
|||||||
height: 11.427824006438863,
|
height: 11.427824006438863,
|
||||||
seed: 93422161,
|
seed: 93422161,
|
||||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -1269,6 +1311,7 @@ export default {
|
|||||||
height: 19.889460471185775,
|
height: 19.889460471185775,
|
||||||
seed: 11646495,
|
seed: 11646495,
|
||||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
@ -1292,6 +1335,7 @@ export default {
|
|||||||
height: 19.889460471185775,
|
height: 19.889460471185775,
|
||||||
seed: 291717649,
|
seed: 291717649,
|
||||||
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
groupIds: ["GMZ-NW9lG7c1AtfBInZ0n"],
|
||||||
|
frameId: null,
|
||||||
strokeSharpness: "sharp",
|
strokeSharpness: "sharp",
|
||||||
boundElementIds: [],
|
boundElementIds: [],
|
||||||
},
|
},
|
||||||
|
@ -34,6 +34,7 @@ import { AppState, BinaryFiles, Zoom } from "../types";
|
|||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
import {
|
import {
|
||||||
BOUND_TEXT_PADDING,
|
BOUND_TEXT_PADDING,
|
||||||
|
FRAME_STYLE,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
MIME_TYPES,
|
MIME_TYPES,
|
||||||
SVG_NS,
|
SVG_NS,
|
||||||
@ -48,6 +49,7 @@ import {
|
|||||||
getBoundTextMaxWidth,
|
getBoundTextMaxWidth,
|
||||||
} from "../element/textElement";
|
} from "../element/textElement";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { getContainingFrame } from "../frame";
|
||||||
|
|
||||||
// using a stronger invert (100% vs our regular 93%) and saturate
|
// using a stronger invert (100% vs our regular 93%) and saturate
|
||||||
// as a temp hack to make images in dark theme look closer to original
|
// as a temp hack to make images in dark theme look closer to original
|
||||||
@ -92,6 +94,7 @@ export interface ExcalidrawElementWithCanvas {
|
|||||||
canvasOffsetX: number;
|
canvasOffsetX: number;
|
||||||
canvasOffsetY: number;
|
canvasOffsetY: number;
|
||||||
boundTextElementVersion: number | null;
|
boundTextElementVersion: number | null;
|
||||||
|
containingFrameOpacity: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cappedElementCanvasSize = (
|
const cappedElementCanvasSize = (
|
||||||
@ -207,6 +210,7 @@ const generateElementCanvas = (
|
|||||||
canvasOffsetX,
|
canvasOffsetX,
|
||||||
canvasOffsetY,
|
canvasOffsetY,
|
||||||
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
boundTextElementVersion: getBoundTextElement(element)?.version || null,
|
||||||
|
containingFrameOpacity: getContainingFrame(element)?.opacity || 100,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,7 +257,8 @@ const drawElementOnCanvas = (
|
|||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
renderConfig: RenderConfig,
|
renderConfig: RenderConfig,
|
||||||
) => {
|
) => {
|
||||||
context.globalAlpha = element.opacity / 100;
|
context.globalAlpha =
|
||||||
|
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "diamond":
|
case "diamond":
|
||||||
@ -469,7 +474,7 @@ const generateElementShape = (
|
|||||||
elementWithCanvasCache.delete(element);
|
elementWithCanvasCache.delete(element);
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "rectangle":
|
case "rectangle": {
|
||||||
if (element.roundness) {
|
if (element.roundness) {
|
||||||
const w = element.width;
|
const w = element.width;
|
||||||
const h = element.height;
|
const h = element.height;
|
||||||
@ -494,6 +499,7 @@ const generateElementShape = (
|
|||||||
setShapeForElement(element, shape);
|
setShapeForElement(element, shape);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "diamond": {
|
case "diamond": {
|
||||||
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
const [topX, topY, rightX, rightY, bottomX, bottomY, leftX, leftY] =
|
||||||
getDiamondPoints(element);
|
getDiamondPoints(element);
|
||||||
@ -717,12 +723,14 @@ const generateElementWithCanvas = (
|
|||||||
prevElementWithCanvas.zoomValue !== zoom.value &&
|
prevElementWithCanvas.zoomValue !== zoom.value &&
|
||||||
!renderConfig?.shouldCacheIgnoreZoom;
|
!renderConfig?.shouldCacheIgnoreZoom;
|
||||||
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
const boundTextElementVersion = getBoundTextElement(element)?.version || null;
|
||||||
|
const containingFrameOpacity = getContainingFrame(element)?.opacity || 100;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!prevElementWithCanvas ||
|
!prevElementWithCanvas ||
|
||||||
shouldRegenerateBecauseZoom ||
|
shouldRegenerateBecauseZoom ||
|
||||||
prevElementWithCanvas.theme !== renderConfig.theme ||
|
prevElementWithCanvas.theme !== renderConfig.theme ||
|
||||||
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion
|
prevElementWithCanvas.boundTextElementVersion !== boundTextElementVersion ||
|
||||||
|
prevElementWithCanvas.containingFrameOpacity !== containingFrameOpacity
|
||||||
) {
|
) {
|
||||||
const elementWithCanvas = generateElementCanvas(
|
const elementWithCanvas = generateElementCanvas(
|
||||||
element,
|
element,
|
||||||
@ -897,25 +905,59 @@ export const renderElement = (
|
|||||||
const generator = rc.generator;
|
const generator = rc.generator;
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "selection": {
|
case "selection": {
|
||||||
context.save();
|
// do not render selection when exporting
|
||||||
context.translate(
|
if (!renderConfig.isExporting) {
|
||||||
element.x + renderConfig.scrollX,
|
context.save();
|
||||||
element.y + renderConfig.scrollY,
|
context.translate(
|
||||||
);
|
element.x + renderConfig.scrollX,
|
||||||
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
element.y + renderConfig.scrollY,
|
||||||
|
);
|
||||||
|
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||||
|
|
||||||
// render from 0.5px offset to get 1px wide line
|
// render from 0.5px offset to get 1px wide line
|
||||||
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
// https://stackoverflow.com/questions/7530593/html5-canvas-and-line-width/7531540#7531540
|
||||||
// TODO can be be improved by offseting to the negative when user selects
|
// TODO can be be improved by offseting to the negative when user selects
|
||||||
// from right to left
|
// from right to left
|
||||||
const offset = 0.5 / renderConfig.zoom.value;
|
const offset = 0.5 / renderConfig.zoom.value;
|
||||||
|
|
||||||
context.fillRect(offset, offset, element.width, element.height);
|
context.fillRect(offset, offset, element.width, element.height);
|
||||||
context.lineWidth = 1 / renderConfig.zoom.value;
|
context.lineWidth = 1 / renderConfig.zoom.value;
|
||||||
context.strokeStyle = "rgb(105, 101, 219)";
|
context.strokeStyle = " rgb(105, 101, 219)";
|
||||||
context.strokeRect(offset, offset, element.width, element.height);
|
context.strokeRect(offset, offset, element.width, element.height);
|
||||||
|
|
||||||
context.restore();
|
context.restore();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "frame": {
|
||||||
|
if (!renderConfig.isExporting && appState.shouldRenderFrames) {
|
||||||
|
context.save();
|
||||||
|
context.translate(
|
||||||
|
element.x + renderConfig.scrollX,
|
||||||
|
element.y + renderConfig.scrollY,
|
||||||
|
);
|
||||||
|
context.fillStyle = "rgba(0, 0, 200, 0.04)";
|
||||||
|
|
||||||
|
context.lineWidth = 2 / renderConfig.zoom.value;
|
||||||
|
context.strokeStyle = FRAME_STYLE.strokeColor;
|
||||||
|
|
||||||
|
if (FRAME_STYLE.radius && context.roundRect) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
element.width,
|
||||||
|
element.height,
|
||||||
|
FRAME_STYLE.radius / renderConfig.zoom.value,
|
||||||
|
);
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
} else {
|
||||||
|
context.strokeRect(0, 0, element.width, element.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.restore();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
@ -1107,6 +1149,23 @@ const roughSVGDrawWithPrecision = (
|
|||||||
return rsvg.draw(pshape);
|
return rsvg.draw(pshape);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const maybeWrapNodesInFrameClipPath = (
|
||||||
|
element: NonDeletedExcalidrawElement,
|
||||||
|
root: SVGElement,
|
||||||
|
nodes: SVGElement[],
|
||||||
|
exportedFrameId?: string | null,
|
||||||
|
) => {
|
||||||
|
const frame = getContainingFrame(element);
|
||||||
|
if (frame && frame.id === exportedFrameId) {
|
||||||
|
const g = root.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
|
g.setAttributeNS(SVG_NS, "clip-path", `url(#${frame.id})`);
|
||||||
|
nodes.forEach((node) => g.appendChild(node));
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
export const renderElementToSvg = (
|
export const renderElementToSvg = (
|
||||||
element: NonDeletedExcalidrawElement,
|
element: NonDeletedExcalidrawElement,
|
||||||
rsvg: RoughSVG,
|
rsvg: RoughSVG,
|
||||||
@ -1115,6 +1174,7 @@ export const renderElementToSvg = (
|
|||||||
offsetX: number,
|
offsetX: number,
|
||||||
offsetY: number,
|
offsetY: number,
|
||||||
exportWithDarkMode?: boolean,
|
exportWithDarkMode?: boolean,
|
||||||
|
exportingFrameId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||||
let cx = (x2 - x1) / 2 - (element.x - x1);
|
let cx = (x2 - x1) / 2 - (element.x - x1);
|
||||||
@ -1148,6 +1208,9 @@ export const renderElementToSvg = (
|
|||||||
root = anchorTag;
|
root = anchorTag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const opacity =
|
||||||
|
((getContainingFrame(element)?.opacity ?? 100) * element.opacity) / 10000;
|
||||||
|
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case "selection": {
|
case "selection": {
|
||||||
// Since this is used only during editing experience, which is canvas based,
|
// Since this is used only during editing experience, which is canvas based,
|
||||||
@ -1163,7 +1226,6 @@ export const renderElementToSvg = (
|
|||||||
getShapeForElement(element)!,
|
getShapeForElement(element)!,
|
||||||
MAX_DECIMALS_FOR_SVG_EXPORT,
|
MAX_DECIMALS_FOR_SVG_EXPORT,
|
||||||
);
|
);
|
||||||
const opacity = element.opacity / 100;
|
|
||||||
if (opacity !== 1) {
|
if (opacity !== 1) {
|
||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
node.setAttribute("fill-opacity", `${opacity}`);
|
node.setAttribute("fill-opacity", `${opacity}`);
|
||||||
@ -1175,7 +1237,15 @@ export const renderElementToSvg = (
|
|||||||
offsetY || 0
|
offsetY || 0
|
||||||
}) rotate(${degree} ${cx} ${cy})`,
|
}) rotate(${degree} ${cx} ${cy})`,
|
||||||
);
|
);
|
||||||
root.appendChild(node);
|
|
||||||
|
const g = maybeWrapNodesInFrameClipPath(
|
||||||
|
element,
|
||||||
|
root,
|
||||||
|
[node],
|
||||||
|
exportingFrameId,
|
||||||
|
);
|
||||||
|
|
||||||
|
g ? root.appendChild(g) : root.appendChild(node);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "line":
|
case "line":
|
||||||
@ -1228,7 +1298,6 @@ export const renderElementToSvg = (
|
|||||||
if (boundText) {
|
if (boundText) {
|
||||||
group.setAttribute("mask", `url(#mask-${element.id})`);
|
group.setAttribute("mask", `url(#mask-${element.id})`);
|
||||||
}
|
}
|
||||||
const opacity = element.opacity / 100;
|
|
||||||
group.setAttribute("stroke-linecap", "round");
|
group.setAttribute("stroke-linecap", "round");
|
||||||
|
|
||||||
getShapeForElement(element)!.forEach((shape) => {
|
getShapeForElement(element)!.forEach((shape) => {
|
||||||
@ -1256,14 +1325,24 @@ export const renderElementToSvg = (
|
|||||||
}
|
}
|
||||||
group.appendChild(node);
|
group.appendChild(node);
|
||||||
});
|
});
|
||||||
root.appendChild(group);
|
|
||||||
root.append(maskPath);
|
const g = maybeWrapNodesInFrameClipPath(
|
||||||
|
element,
|
||||||
|
root,
|
||||||
|
[group, maskPath],
|
||||||
|
exportingFrameId,
|
||||||
|
);
|
||||||
|
if (g) {
|
||||||
|
root.appendChild(g);
|
||||||
|
} else {
|
||||||
|
root.appendChild(group);
|
||||||
|
root.append(maskPath);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "freedraw": {
|
case "freedraw": {
|
||||||
generateElementShape(element, generator);
|
generateElementShape(element, generator);
|
||||||
generateFreeDrawShape(element);
|
generateFreeDrawShape(element);
|
||||||
const opacity = element.opacity / 100;
|
|
||||||
const shape = getShapeForElement(element);
|
const shape = getShapeForElement(element);
|
||||||
const node = shape
|
const node = shape
|
||||||
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
|
? roughSVGDrawWithPrecision(rsvg, shape, MAX_DECIMALS_FOR_SVG_EXPORT)
|
||||||
@ -1283,7 +1362,15 @@ export const renderElementToSvg = (
|
|||||||
path.setAttribute("fill", element.strokeColor);
|
path.setAttribute("fill", element.strokeColor);
|
||||||
path.setAttribute("d", getFreeDrawSvgPath(element));
|
path.setAttribute("d", getFreeDrawSvgPath(element));
|
||||||
node.appendChild(path);
|
node.appendChild(path);
|
||||||
root.appendChild(node);
|
|
||||||
|
const g = maybeWrapNodesInFrameClipPath(
|
||||||
|
element,
|
||||||
|
root,
|
||||||
|
[node],
|
||||||
|
exportingFrameId,
|
||||||
|
);
|
||||||
|
|
||||||
|
g ? root.appendChild(g) : root.appendChild(node);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "image": {
|
case "image": {
|
||||||
@ -1319,6 +1406,7 @@ export const renderElementToSvg = (
|
|||||||
|
|
||||||
use.setAttribute("width", `${width}`);
|
use.setAttribute("width", `${width}`);
|
||||||
use.setAttribute("height", `${height}`);
|
use.setAttribute("height", `${height}`);
|
||||||
|
use.setAttribute("opacity", `${opacity}`);
|
||||||
|
|
||||||
// We first apply `scale` transforms (horizontal/vertical mirroring)
|
// We first apply `scale` transforms (horizontal/vertical mirroring)
|
||||||
// on the <use> element, then apply translation and rotation
|
// on the <use> element, then apply translation and rotation
|
||||||
@ -1344,13 +1432,22 @@ export const renderElementToSvg = (
|
|||||||
}) rotate(${degree} ${cx} ${cy})`,
|
}) rotate(${degree} ${cx} ${cy})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
root.appendChild(g);
|
const clipG = maybeWrapNodesInFrameClipPath(
|
||||||
|
element,
|
||||||
|
root,
|
||||||
|
[g],
|
||||||
|
exportingFrameId,
|
||||||
|
);
|
||||||
|
clipG ? root.appendChild(clipG) : root.appendChild(g);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// frames are not rendered and only acts as a container
|
||||||
|
case "frame": {
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
const opacity = element.opacity / 100;
|
|
||||||
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
const node = svgRoot.ownerDocument!.createElementNS(SVG_NS, "g");
|
||||||
if (opacity !== 1) {
|
if (opacity !== 1) {
|
||||||
node.setAttribute("stroke-opacity", `${opacity}`);
|
node.setAttribute("stroke-opacity", `${opacity}`);
|
||||||
@ -1395,7 +1492,15 @@ export const renderElementToSvg = (
|
|||||||
text.setAttribute("dominant-baseline", "text-before-edge");
|
text.setAttribute("dominant-baseline", "text-before-edge");
|
||||||
node.appendChild(text);
|
node.appendChild(text);
|
||||||
}
|
}
|
||||||
root.appendChild(node);
|
|
||||||
|
const g = maybeWrapNodesInFrameClipPath(
|
||||||
|
element,
|
||||||
|
root,
|
||||||
|
[node],
|
||||||
|
exportingFrameId,
|
||||||
|
);
|
||||||
|
|
||||||
|
g ? root.appendChild(g) : root.appendChild(node);
|
||||||
} else {
|
} else {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
throw new Error(`Unimplemented type ${element.type}`);
|
throw new Error(`Unimplemented type ${element.type}`);
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
NonDeleted,
|
NonDeleted,
|
||||||
GroupId,
|
GroupId,
|
||||||
ExcalidrawBindableElement,
|
ExcalidrawBindableElement,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import {
|
import {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
@ -36,6 +37,7 @@ import {
|
|||||||
isSelectedViaGroup,
|
isSelectedViaGroup,
|
||||||
getSelectedGroupIds,
|
getSelectedGroupIds,
|
||||||
getElementsInGroup,
|
getElementsInGroup,
|
||||||
|
selectGroupsFromGivenElements,
|
||||||
} from "../groups";
|
} from "../groups";
|
||||||
import { maxBindingGap } from "../element/collision";
|
import { maxBindingGap } from "../element/collision";
|
||||||
import {
|
import {
|
||||||
@ -44,18 +46,28 @@ import {
|
|||||||
isBindingEnabled,
|
isBindingEnabled,
|
||||||
} from "../element/binding";
|
} from "../element/binding";
|
||||||
import {
|
import {
|
||||||
|
OMIT_SIDES_FOR_FRAME,
|
||||||
shouldShowBoundingBox,
|
shouldShowBoundingBox,
|
||||||
TransformHandles,
|
TransformHandles,
|
||||||
TransformHandleType,
|
TransformHandleType,
|
||||||
} from "../element/transformHandles";
|
} from "../element/transformHandles";
|
||||||
import { viewportCoordsToSceneCoords, throttleRAF } from "../utils";
|
import {
|
||||||
|
viewportCoordsToSceneCoords,
|
||||||
|
throttleRAF,
|
||||||
|
isOnlyExportingSingleFrame,
|
||||||
|
} from "../utils";
|
||||||
import { UserIdleState } from "../types";
|
import { UserIdleState } from "../types";
|
||||||
import { THEME_FILTER } from "../constants";
|
import { FRAME_STYLE, THEME_FILTER } from "../constants";
|
||||||
import {
|
import {
|
||||||
EXTERNAL_LINK_IMG,
|
EXTERNAL_LINK_IMG,
|
||||||
getLinkHandleFromCoords,
|
getLinkHandleFromCoords,
|
||||||
} from "../element/Hyperlink";
|
} from "../element/Hyperlink";
|
||||||
import { isLinearElement } from "../element/typeChecks";
|
import { isFrameElement, isLinearElement } from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
elementOverlapsWithFrame,
|
||||||
|
getTargetFrame,
|
||||||
|
isElementInFrame,
|
||||||
|
} from "../frame";
|
||||||
import "canvas-roundrect-polyfill";
|
import "canvas-roundrect-polyfill";
|
||||||
|
|
||||||
export const DEFAULT_SPACING = 2;
|
export const DEFAULT_SPACING = 2;
|
||||||
@ -70,6 +82,8 @@ const strokeRectWithRotation = (
|
|||||||
cy: number,
|
cy: number,
|
||||||
angle: number,
|
angle: number,
|
||||||
fill: boolean = false,
|
fill: boolean = false,
|
||||||
|
/** should account for zoom */
|
||||||
|
radius: number = 0,
|
||||||
) => {
|
) => {
|
||||||
context.save();
|
context.save();
|
||||||
context.translate(cx, cy);
|
context.translate(cx, cy);
|
||||||
@ -77,7 +91,14 @@ const strokeRectWithRotation = (
|
|||||||
if (fill) {
|
if (fill) {
|
||||||
context.fillRect(x - cx, y - cy, width, height);
|
context.fillRect(x - cx, y - cy, width, height);
|
||||||
}
|
}
|
||||||
context.strokeRect(x - cx, y - cy, width, height);
|
if (radius && context.roundRect) {
|
||||||
|
context.beginPath();
|
||||||
|
context.roundRect(x - cx, y - cy, width, height, radius);
|
||||||
|
context.stroke();
|
||||||
|
context.closePath();
|
||||||
|
} else {
|
||||||
|
context.strokeRect(x - cx, y - cy, width, height);
|
||||||
|
}
|
||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -299,6 +320,34 @@ const renderLinearElementPointHighlight = (
|
|||||||
context.restore();
|
context.restore();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const frameClip = (
|
||||||
|
frame: ExcalidrawFrameElement,
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
renderConfig: RenderConfig,
|
||||||
|
) => {
|
||||||
|
context.translate(
|
||||||
|
frame.x + renderConfig.scrollX,
|
||||||
|
frame.y + renderConfig.scrollY,
|
||||||
|
);
|
||||||
|
context.beginPath();
|
||||||
|
if (context.roundRect && !renderConfig.isExporting) {
|
||||||
|
context.roundRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
frame.width,
|
||||||
|
frame.height,
|
||||||
|
FRAME_STYLE.radius / renderConfig.zoom.value,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
context.rect(0, 0, frame.width, frame.height);
|
||||||
|
}
|
||||||
|
context.clip();
|
||||||
|
context.translate(
|
||||||
|
-(frame.x + renderConfig.scrollX),
|
||||||
|
-(frame.y + renderConfig.scrollY),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const _renderScene = ({
|
export const _renderScene = ({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
@ -390,11 +439,51 @@ export const _renderScene = ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const groupsToBeAddedToFrame = new Set<string>();
|
||||||
|
|
||||||
|
visibleElements.forEach((element) => {
|
||||||
|
if (
|
||||||
|
element.groupIds.length > 0 &&
|
||||||
|
appState.frameToHighlight &&
|
||||||
|
appState.selectedElementIds[element.id] &&
|
||||||
|
(elementOverlapsWithFrame(element, appState.frameToHighlight) ||
|
||||||
|
element.groupIds.find((groupId) =>
|
||||||
|
groupsToBeAddedToFrame.has(groupId),
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
element.groupIds.forEach((groupId) =>
|
||||||
|
groupsToBeAddedToFrame.add(groupId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
|
let editingLinearElement: NonDeleted<ExcalidrawLinearElement> | undefined =
|
||||||
undefined;
|
undefined;
|
||||||
|
|
||||||
visibleElements.forEach((element) => {
|
visibleElements.forEach((element) => {
|
||||||
try {
|
try {
|
||||||
renderElement(element, rc, context, renderConfig, appState);
|
// - when exporting the whole canvas, we DO NOT apply clipping
|
||||||
|
// - when we are exporting a particular frame, apply clipping
|
||||||
|
// if the containing frame is not selected, apply clipping
|
||||||
|
const frameId = element.frameId || appState.frameToHighlight?.id;
|
||||||
|
|
||||||
|
if (
|
||||||
|
frameId &&
|
||||||
|
((renderConfig.isExporting && isOnlyExportingSingleFrame(elements)) ||
|
||||||
|
(!renderConfig.isExporting && appState.shouldRenderFrames))
|
||||||
|
) {
|
||||||
|
context.save();
|
||||||
|
|
||||||
|
const frame = getTargetFrame(element, appState);
|
||||||
|
|
||||||
|
if (frame && isElementInFrame(element, elements, appState)) {
|
||||||
|
frameClip(frame, context, renderConfig);
|
||||||
|
}
|
||||||
|
renderElement(element, rc, context, renderConfig, appState);
|
||||||
|
context.restore();
|
||||||
|
} else {
|
||||||
|
renderElement(element, rc, context, renderConfig, appState);
|
||||||
|
}
|
||||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||||
// ShapeCache returns empty hence making sure that we get the
|
// ShapeCache returns empty hence making sure that we get the
|
||||||
// correct element from visible elements
|
// correct element from visible elements
|
||||||
@ -443,7 +532,24 @@ export const _renderScene = ({
|
|||||||
renderBindingHighlight(context, renderConfig, suggestedBinding!);
|
renderBindingHighlight(context, renderConfig, suggestedBinding!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (appState.frameToHighlight) {
|
||||||
|
renderFrameHighlight(context, renderConfig, appState.frameToHighlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appState.elementsToHighlight) {
|
||||||
|
renderElementsBoxHighlight(
|
||||||
|
context,
|
||||||
|
renderConfig,
|
||||||
|
appState.elementsToHighlight,
|
||||||
|
appState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const locallySelectedElements = getSelectedElements(elements, appState);
|
const locallySelectedElements = getSelectedElements(elements, appState);
|
||||||
|
const isFrameSelected = locallySelectedElements.some((element) =>
|
||||||
|
isFrameElement(element),
|
||||||
|
);
|
||||||
|
|
||||||
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
// Getting the element using LinearElementEditor during collab mismatches version - being one head of visible elements due to
|
||||||
// ShapeCache returns empty hence making sure that we get the
|
// ShapeCache returns empty hence making sure that we get the
|
||||||
@ -613,7 +719,9 @@ export const _renderScene = ({
|
|||||||
0,
|
0,
|
||||||
renderConfig.zoom,
|
renderConfig.zoom,
|
||||||
"mouse",
|
"mouse",
|
||||||
OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
isFrameSelected
|
||||||
|
? OMIT_SIDES_FOR_FRAME
|
||||||
|
: OMIT_SIDES_FOR_MULTIPLE_ELEMENTS,
|
||||||
);
|
);
|
||||||
if (locallySelectedElements.some((element) => !element.locked)) {
|
if (locallySelectedElements.some((element) => !element.locked)) {
|
||||||
renderTransformHandles(context, renderConfig, transformHandles, 0);
|
renderTransformHandles(context, renderConfig, transformHandles, 0);
|
||||||
@ -974,6 +1082,7 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
case "rectangle":
|
case "rectangle":
|
||||||
case "text":
|
case "text":
|
||||||
case "image":
|
case "image":
|
||||||
|
case "frame":
|
||||||
strokeRectWithRotation(
|
strokeRectWithRotation(
|
||||||
context,
|
context,
|
||||||
x1 - padding,
|
x1 - padding,
|
||||||
@ -1011,6 +1120,82 @@ const renderBindingHighlightForBindableElement = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderFrameHighlight = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
renderConfig: RenderConfig,
|
||||||
|
frame: NonDeleted<ExcalidrawFrameElement>,
|
||||||
|
) => {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(frame);
|
||||||
|
const width = x2 - x1;
|
||||||
|
const height = y2 - y1;
|
||||||
|
|
||||||
|
context.strokeStyle = "rgb(0,118,255)";
|
||||||
|
context.lineWidth = (FRAME_STYLE.strokeWidth * 2) / renderConfig.zoom.value;
|
||||||
|
|
||||||
|
context.save();
|
||||||
|
context.translate(renderConfig.scrollX, renderConfig.scrollY);
|
||||||
|
strokeRectWithRotation(
|
||||||
|
context,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x1 + width / 2,
|
||||||
|
y1 + height / 2,
|
||||||
|
frame.angle,
|
||||||
|
false,
|
||||||
|
FRAME_STYLE.radius / renderConfig.zoom.value,
|
||||||
|
);
|
||||||
|
context.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderElementsBoxHighlight = (
|
||||||
|
context: CanvasRenderingContext2D,
|
||||||
|
renderConfig: RenderConfig,
|
||||||
|
elements: NonDeleted<ExcalidrawElement>[],
|
||||||
|
appState: AppState,
|
||||||
|
) => {
|
||||||
|
const individualElements = elements.filter(
|
||||||
|
(element) => element.groupIds.length === 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementsInGroups = elements.filter(
|
||||||
|
(element) => element.groupIds.length > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSelectionFromElements = (elements: ExcalidrawElement[]) => {
|
||||||
|
const [elementX1, elementY1, elementX2, elementY2] =
|
||||||
|
getCommonBounds(elements);
|
||||||
|
return {
|
||||||
|
angle: 0,
|
||||||
|
elementX1,
|
||||||
|
elementX2,
|
||||||
|
elementY1,
|
||||||
|
elementY2,
|
||||||
|
selectionColors: ["rgb(0,118,255)"],
|
||||||
|
dashed: false,
|
||||||
|
cx: elementX1 + (elementX2 - elementX1) / 2,
|
||||||
|
cy: elementY1 + (elementY2 - elementY1) / 2,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSelectionForGroupId = (groupId: GroupId) => {
|
||||||
|
const groupElements = getElementsInGroup(elements, groupId);
|
||||||
|
return getSelectionFromElements(groupElements);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.entries(selectGroupsFromGivenElements(elementsInGroups, appState))
|
||||||
|
.filter(([id, isSelected]) => isSelected)
|
||||||
|
.map(([id, isSelected]) => id)
|
||||||
|
.map((groupId) => getSelectionForGroupId(groupId))
|
||||||
|
.concat(
|
||||||
|
individualElements.map((element) => getSelectionFromElements([element])),
|
||||||
|
)
|
||||||
|
.forEach((selection) =>
|
||||||
|
renderSelectionBorder(context, renderConfig, selection),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderBindingHighlightForSuggestedPointBinding = (
|
const renderBindingHighlightForSuggestedPointBinding = (
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
suggestedBinding: SuggestedPointBinding,
|
suggestedBinding: SuggestedPointBinding,
|
||||||
@ -1092,7 +1277,7 @@ const renderLinkIcon = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isVisibleElement = (
|
export const isVisibleElement = (
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
canvasWidth: number,
|
canvasWidth: number,
|
||||||
canvasHeight: number,
|
canvasHeight: number,
|
||||||
@ -1138,15 +1323,18 @@ export const renderSceneToSvg = (
|
|||||||
offsetX = 0,
|
offsetX = 0,
|
||||||
offsetY = 0,
|
offsetY = 0,
|
||||||
exportWithDarkMode = false,
|
exportWithDarkMode = false,
|
||||||
|
exportingFrameId = null,
|
||||||
}: {
|
}: {
|
||||||
offsetX?: number;
|
offsetX?: number;
|
||||||
offsetY?: number;
|
offsetY?: number;
|
||||||
exportWithDarkMode?: boolean;
|
exportWithDarkMode?: boolean;
|
||||||
|
exportingFrameId?: string | null;
|
||||||
} = {},
|
} = {},
|
||||||
) => {
|
) => {
|
||||||
if (!svgRoot) {
|
if (!svgRoot) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// render elements
|
// render elements
|
||||||
elements.forEach((element) => {
|
elements.forEach((element) => {
|
||||||
if (!element.isDeleted) {
|
if (!element.isDeleted) {
|
||||||
@ -1159,6 +1347,7 @@ export const renderSceneToSvg = (
|
|||||||
element.x + offsetX,
|
element.x + offsetX,
|
||||||
element.y + offsetY,
|
element.y + offsetY,
|
||||||
exportWithDarkMode,
|
exportWithDarkMode,
|
||||||
|
exportingFrameId,
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
@ -2,9 +2,15 @@ import {
|
|||||||
ExcalidrawElement,
|
ExcalidrawElement,
|
||||||
NonDeletedExcalidrawElement,
|
NonDeletedExcalidrawElement,
|
||||||
NonDeleted,
|
NonDeleted,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "../element/types";
|
} from "../element/types";
|
||||||
import { getNonDeletedElements, isNonDeletedElement } from "../element";
|
import {
|
||||||
|
getNonDeletedElements,
|
||||||
|
getNonDeletedFrames,
|
||||||
|
isNonDeletedElement,
|
||||||
|
} from "../element";
|
||||||
import { LinearElementEditor } from "../element/linearElementEditor";
|
import { LinearElementEditor } from "../element/linearElementEditor";
|
||||||
|
import { isFrameElement } from "../element/typeChecks";
|
||||||
|
|
||||||
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
type ElementIdKey = InstanceType<typeof LinearElementEditor>["elementId"];
|
||||||
type ElementKey = ExcalidrawElement | ElementIdKey;
|
type ElementKey = ExcalidrawElement | ElementIdKey;
|
||||||
@ -12,6 +18,10 @@ type ElementKey = ExcalidrawElement | ElementIdKey;
|
|||||||
type SceneStateCallback = () => void;
|
type SceneStateCallback = () => void;
|
||||||
type SceneStateCallbackRemover = () => void;
|
type SceneStateCallbackRemover = () => void;
|
||||||
|
|
||||||
|
// ideally this would be a branded type but it'd be insanely hard to work with
|
||||||
|
// in our codebase
|
||||||
|
export type ExcalidrawElementsIncludingDeleted = readonly ExcalidrawElement[];
|
||||||
|
|
||||||
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
|
const isIdKey = (elementKey: ElementKey): elementKey is ElementIdKey => {
|
||||||
if (typeof elementKey === "string") {
|
if (typeof elementKey === "string") {
|
||||||
return true;
|
return true;
|
||||||
@ -55,6 +65,8 @@ class Scene {
|
|||||||
|
|
||||||
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
private nonDeletedElements: readonly NonDeletedExcalidrawElement[] = [];
|
||||||
private elements: readonly ExcalidrawElement[] = [];
|
private elements: readonly ExcalidrawElement[] = [];
|
||||||
|
private nonDeletedFrames: readonly NonDeleted<ExcalidrawFrameElement>[] = [];
|
||||||
|
private frames: readonly ExcalidrawFrameElement[] = [];
|
||||||
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
private elementsMap = new Map<ExcalidrawElement["id"], ExcalidrawElement>();
|
||||||
|
|
||||||
getElementsIncludingDeleted() {
|
getElementsIncludingDeleted() {
|
||||||
@ -65,6 +77,14 @@ class Scene {
|
|||||||
return this.nonDeletedElements;
|
return this.nonDeletedElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFramesIncludingDeleted() {
|
||||||
|
return this.frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNonDeletedFrames(): readonly NonDeleted<ExcalidrawFrameElement>[] {
|
||||||
|
return this.nonDeletedFrames;
|
||||||
|
}
|
||||||
|
|
||||||
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
|
getElement<T extends ExcalidrawElement>(id: T["id"]): T | null {
|
||||||
return (this.elementsMap.get(id) as T | undefined) || null;
|
return (this.elementsMap.get(id) as T | undefined) || null;
|
||||||
}
|
}
|
||||||
@ -110,12 +130,19 @@ class Scene {
|
|||||||
|
|
||||||
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
|
replaceAllElements(nextElements: readonly ExcalidrawElement[]) {
|
||||||
this.elements = nextElements;
|
this.elements = nextElements;
|
||||||
|
const nextFrames: ExcalidrawFrameElement[] = [];
|
||||||
this.elementsMap.clear();
|
this.elementsMap.clear();
|
||||||
nextElements.forEach((element) => {
|
nextElements.forEach((element) => {
|
||||||
|
if (isFrameElement(element)) {
|
||||||
|
nextFrames.push(element);
|
||||||
|
}
|
||||||
this.elementsMap.set(element.id, element);
|
this.elementsMap.set(element.id, element);
|
||||||
Scene.mapElementToScene(element, this);
|
Scene.mapElementToScene(element, this);
|
||||||
});
|
});
|
||||||
this.nonDeletedElements = getNonDeletedElements(this.elements);
|
this.nonDeletedElements = getNonDeletedElements(this.elements);
|
||||||
|
this.frames = nextFrames;
|
||||||
|
this.nonDeletedFrames = getNonDeletedFrames(this.frames);
|
||||||
|
|
||||||
this.informMutation();
|
this.informMutation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -165,6 +192,29 @@ class Scene {
|
|||||||
this.replaceAllElements(nextElements);
|
this.replaceAllElements(nextElements);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
insertElementsAtIndex(elements: ExcalidrawElement[], index: number) {
|
||||||
|
if (!Number.isFinite(index) || index < 0) {
|
||||||
|
throw new Error(
|
||||||
|
"insertElementAtIndex can only be called with index >= 0",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nextElements = [
|
||||||
|
...this.elements.slice(0, index),
|
||||||
|
...elements,
|
||||||
|
...this.elements.slice(index),
|
||||||
|
];
|
||||||
|
|
||||||
|
this.replaceAllElements(nextElements);
|
||||||
|
}
|
||||||
|
|
||||||
|
addNewElement = (element: ExcalidrawElement) => {
|
||||||
|
if (element.frameId) {
|
||||||
|
this.insertElementAtIndex(element, this.getElementIndex(element.frameId));
|
||||||
|
} else {
|
||||||
|
this.replaceAllElements([...this.elements, element]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
getElementIndex(elementId: string) {
|
getElementIndex(elementId: string) {
|
||||||
return this.elements.findIndex((element) => element.id === elementId);
|
return this.elements.findIndex((element) => element.id === elementId);
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,8 @@ export const hasBackground = (type: string) =>
|
|||||||
type === "line" ||
|
type === "line" ||
|
||||||
type === "freedraw";
|
type === "freedraw";
|
||||||
|
|
||||||
export const hasStrokeColor = (type: string) => type !== "image";
|
export const hasStrokeColor = (type: string) =>
|
||||||
|
type !== "image" && type !== "frame";
|
||||||
|
|
||||||
export const hasStrokeWidth = (type: string) =>
|
export const hasStrokeWidth = (type: string) =>
|
||||||
type === "rectangle" ||
|
type === "rectangle" ||
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import rough from "roughjs/bin/rough";
|
import rough from "roughjs/bin/rough";
|
||||||
import { NonDeletedExcalidrawElement } from "../element/types";
|
import { NonDeletedExcalidrawElement } from "../element/types";
|
||||||
import { getCommonBounds } from "../element/bounds";
|
import { getCommonBounds, getElementAbsoluteCoords } from "../element/bounds";
|
||||||
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
import { renderScene, renderSceneToSvg } from "../renderer/renderScene";
|
||||||
import { distance } from "../utils";
|
import { distance, isOnlyExportingSingleFrame } from "../utils";
|
||||||
import { AppState, BinaryFiles } from "../types";
|
import { AppState, BinaryFiles } from "../types";
|
||||||
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
import { DEFAULT_EXPORT_PADDING, SVG_NS, THEME_FILTER } from "../constants";
|
||||||
import { getDefaultAppState } from "../appState";
|
import { getDefaultAppState } from "../appState";
|
||||||
@ -11,6 +11,7 @@ import {
|
|||||||
getInitializedImageElements,
|
getInitializedImageElements,
|
||||||
updateImageCache,
|
updateImageCache,
|
||||||
} from "../element/image";
|
} from "../element/image";
|
||||||
|
import Scene from "./Scene";
|
||||||
|
|
||||||
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
|
||||||
|
|
||||||
@ -51,6 +52,8 @@ export const exportToCanvas = async (
|
|||||||
files,
|
files,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||||
|
|
||||||
renderScene({
|
renderScene({
|
||||||
elements,
|
elements,
|
||||||
appState,
|
appState,
|
||||||
@ -59,8 +62,8 @@ export const exportToCanvas = async (
|
|||||||
canvas,
|
canvas,
|
||||||
renderConfig: {
|
renderConfig: {
|
||||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||||
scrollX: -minX + exportPadding,
|
scrollX: -minX + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||||
scrollY: -minY + exportPadding,
|
scrollY: -minY + (onlyExportingSingleFrame ? 0 : exportPadding),
|
||||||
zoom: defaultAppState.zoom,
|
zoom: defaultAppState.zoom,
|
||||||
remotePointerViewportCoords: {},
|
remotePointerViewportCoords: {},
|
||||||
remoteSelectedElementIds: {},
|
remoteSelectedElementIds: {},
|
||||||
@ -88,6 +91,7 @@ export const exportToSvg = async (
|
|||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
exportWithDarkMode?: boolean;
|
exportWithDarkMode?: boolean;
|
||||||
exportEmbedScene?: boolean;
|
exportEmbedScene?: boolean;
|
||||||
|
renderFrame?: boolean;
|
||||||
},
|
},
|
||||||
files: BinaryFiles | null,
|
files: BinaryFiles | null,
|
||||||
opts?: {
|
opts?: {
|
||||||
@ -140,6 +144,39 @@ export const exportToSvg = async (
|
|||||||
}
|
}
|
||||||
assetPath = `${assetPath}/dist/excalidraw-assets/`;
|
assetPath = `${assetPath}/dist/excalidraw-assets/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// do not apply clipping when we're exporting the whole scene
|
||||||
|
const isExportingWholeCanvas =
|
||||||
|
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
||||||
|
elements.length;
|
||||||
|
|
||||||
|
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||||
|
|
||||||
|
const offsetX = -minX + (onlyExportingSingleFrame ? 0 : exportPadding);
|
||||||
|
const offsetY = -minY + (onlyExportingSingleFrame ? 0 : exportPadding);
|
||||||
|
|
||||||
|
const exportingFrame =
|
||||||
|
isExportingWholeCanvas || !onlyExportingSingleFrame
|
||||||
|
? undefined
|
||||||
|
: elements.find((element) => element.type === "frame");
|
||||||
|
|
||||||
|
let exportingFrameClipPath = "";
|
||||||
|
if (exportingFrame) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(exportingFrame);
|
||||||
|
const cx = (x2 - x1) / 2 - (exportingFrame.x - x1);
|
||||||
|
const cy = (y2 - y1) / 2 - (exportingFrame.y - y1);
|
||||||
|
|
||||||
|
exportingFrameClipPath = `<clipPath id=${exportingFrame.id}>
|
||||||
|
<rect transform="translate(${exportingFrame.x + offsetX} ${
|
||||||
|
exportingFrame.y + offsetY
|
||||||
|
}) rotate(${exportingFrame.angle} ${cx} ${cy})"
|
||||||
|
width="${exportingFrame.width}"
|
||||||
|
height="${exportingFrame.height}"
|
||||||
|
>
|
||||||
|
</rect>
|
||||||
|
</clipPath>`;
|
||||||
|
}
|
||||||
|
|
||||||
svgRoot.innerHTML = `
|
svgRoot.innerHTML = `
|
||||||
${SVG_EXPORT_TAG}
|
${SVG_EXPORT_TAG}
|
||||||
${metadata}
|
${metadata}
|
||||||
@ -154,8 +191,10 @@ export const exportToSvg = async (
|
|||||||
src: url("${assetPath}Cascadia.woff2");
|
src: url("${assetPath}Cascadia.woff2");
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
${exportingFrameClipPath}
|
||||||
</defs>
|
</defs>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// render background rect
|
// render background rect
|
||||||
if (appState.exportBackground && viewBackgroundColor) {
|
if (appState.exportBackground && viewBackgroundColor) {
|
||||||
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
const rect = svgRoot.ownerDocument!.createElementNS(SVG_NS, "rect");
|
||||||
@ -169,9 +208,10 @@ export const exportToSvg = async (
|
|||||||
|
|
||||||
const rsvg = rough.svg(svgRoot);
|
const rsvg = rough.svg(svgRoot);
|
||||||
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
renderSceneToSvg(elements, rsvg, svgRoot, files || {}, {
|
||||||
offsetX: -minX + exportPadding,
|
offsetX,
|
||||||
offsetY: -minY + exportPadding,
|
offsetY,
|
||||||
exportWithDarkMode: appState.exportWithDarkMode,
|
exportWithDarkMode: appState.exportWithDarkMode,
|
||||||
|
exportingFrameId: exportingFrame?.id || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return svgRoot;
|
return svgRoot;
|
||||||
@ -182,9 +222,36 @@ const getCanvasSize = (
|
|||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
exportPadding: number,
|
exportPadding: number,
|
||||||
): [number, number, number, number] => {
|
): [number, number, number, number] => {
|
||||||
|
// we should decide if we are exporting the whole canvas
|
||||||
|
// if so, we are not clipping elements in the frame
|
||||||
|
// and therefore, we should not do anything special
|
||||||
|
|
||||||
|
const isExportingWholeCanvas =
|
||||||
|
Scene.getScene(elements[0])?.getNonDeletedElements()?.length ===
|
||||||
|
elements.length;
|
||||||
|
|
||||||
|
const onlyExportingSingleFrame = isOnlyExportingSingleFrame(elements);
|
||||||
|
|
||||||
|
if (!isExportingWholeCanvas || onlyExportingSingleFrame) {
|
||||||
|
const frames = elements.filter((element) => element.type === "frame");
|
||||||
|
|
||||||
|
const exportedFrameIds = frames.reduce((acc, frame) => {
|
||||||
|
acc[frame.id] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, true>);
|
||||||
|
|
||||||
|
// elements in a frame do not affect the canvas size if we're not exporting
|
||||||
|
// the whole canvas
|
||||||
|
elements = elements.filter(
|
||||||
|
(element) => !exportedFrameIds[element.frameId ?? ""],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
const [minX, minY, maxX, maxY] = getCommonBounds(elements);
|
||||||
const width = distance(minX, maxX) + exportPadding * 2;
|
const width =
|
||||||
const height = distance(minY, maxY) + exportPadding + exportPadding;
|
distance(minX, maxX) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
||||||
|
const height =
|
||||||
|
distance(minY, maxY) + (onlyExportingSingleFrame ? 0 : exportPadding * 2);
|
||||||
|
|
||||||
return [minX, minY, width, height];
|
return [minX, minY, width, height];
|
||||||
};
|
};
|
||||||
|
@ -5,17 +5,61 @@ import {
|
|||||||
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
import { getElementAbsoluteCoords, getElementBounds } from "../element";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
import { isBoundToContainer } from "../element/typeChecks";
|
import { isBoundToContainer } from "../element/typeChecks";
|
||||||
|
import {
|
||||||
|
elementOverlapsWithFrame,
|
||||||
|
getContainingFrame,
|
||||||
|
getFrameElements,
|
||||||
|
} from "../frame";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Frames and their containing elements are not to be selected at the same time.
|
||||||
|
* Given an array of selected elements, if there are frames and their containing elements
|
||||||
|
* we only keep the frames.
|
||||||
|
* @param selectedElements
|
||||||
|
*/
|
||||||
|
export const excludeElementsInFramesFromSelection = <
|
||||||
|
T extends ExcalidrawElement,
|
||||||
|
>(
|
||||||
|
selectedElements: readonly T[],
|
||||||
|
) => {
|
||||||
|
const framesInSelection = new Set<T["id"]>();
|
||||||
|
|
||||||
|
selectedElements.forEach((element) => {
|
||||||
|
if (element.type === "frame") {
|
||||||
|
framesInSelection.add(element.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return selectedElements.filter((element) => {
|
||||||
|
if (element.frameId && framesInSelection.has(element.frameId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const getElementsWithinSelection = (
|
export const getElementsWithinSelection = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
selection: NonDeletedExcalidrawElement,
|
selection: NonDeletedExcalidrawElement,
|
||||||
|
excludeElementsInFrames: boolean = true,
|
||||||
) => {
|
) => {
|
||||||
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
const [selectionX1, selectionY1, selectionX2, selectionY2] =
|
||||||
getElementAbsoluteCoords(selection);
|
getElementAbsoluteCoords(selection);
|
||||||
return elements.filter((element) => {
|
|
||||||
const [elementX1, elementY1, elementX2, elementY2] =
|
let elementsInSelection = elements.filter((element) => {
|
||||||
|
let [elementX1, elementY1, elementX2, elementY2] =
|
||||||
getElementBounds(element);
|
getElementBounds(element);
|
||||||
|
|
||||||
|
const containingFrame = getContainingFrame(element);
|
||||||
|
if (containingFrame) {
|
||||||
|
const [fx1, fy1, fx2, fy2] = getElementBounds(containingFrame);
|
||||||
|
|
||||||
|
elementX1 = Math.max(fx1, elementX1);
|
||||||
|
elementY1 = Math.max(fy1, elementY1);
|
||||||
|
elementX2 = Math.min(fx2, elementX2);
|
||||||
|
elementY2 = Math.min(fy2, elementY2);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
element.locked === false &&
|
element.locked === false &&
|
||||||
element.type !== "selection" &&
|
element.type !== "selection" &&
|
||||||
@ -26,6 +70,22 @@ export const getElementsWithinSelection = (
|
|||||||
selectionY2 >= elementY2
|
selectionY2 >= elementY2
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
elementsInSelection = excludeElementsInFrames
|
||||||
|
? excludeElementsInFramesFromSelection(elementsInSelection)
|
||||||
|
: elementsInSelection;
|
||||||
|
|
||||||
|
elementsInSelection = elementsInSelection.filter((element) => {
|
||||||
|
const containingFrame = getContainingFrame(element);
|
||||||
|
|
||||||
|
if (containingFrame) {
|
||||||
|
return elementOverlapsWithFrame(element, containingFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return elementsInSelection;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isSomeElementSelected = (
|
export const isSomeElementSelected = (
|
||||||
@ -56,14 +116,17 @@ export const getCommonAttributeOfSelectedElements = <T>(
|
|||||||
export const getSelectedElements = (
|
export const getSelectedElements = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: Pick<AppState, "selectedElementIds">,
|
appState: Pick<AppState, "selectedElementIds">,
|
||||||
includeBoundTextElement: boolean = false,
|
opts?: {
|
||||||
) =>
|
includeBoundTextElement?: boolean;
|
||||||
elements.filter((element) => {
|
includeElementsInFrames?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const selectedElements = elements.filter((element) => {
|
||||||
if (appState.selectedElementIds[element.id]) {
|
if (appState.selectedElementIds[element.id]) {
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
includeBoundTextElement &&
|
opts?.includeBoundTextElement &&
|
||||||
isBoundToContainer(element) &&
|
isBoundToContainer(element) &&
|
||||||
appState.selectedElementIds[element?.containerId]
|
appState.selectedElementIds[element?.containerId]
|
||||||
) {
|
) {
|
||||||
@ -72,10 +135,29 @@ export const getSelectedElements = (
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts?.includeElementsInFrames) {
|
||||||
|
const elementsToInclude: ExcalidrawElement[] = [];
|
||||||
|
selectedElements.forEach((element) => {
|
||||||
|
if (element.type === "frame") {
|
||||||
|
getFrameElements(elements, element.id).forEach((e) =>
|
||||||
|
elementsToInclude.push(e),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
elementsToInclude.push(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
return elementsToInclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedElements;
|
||||||
|
};
|
||||||
|
|
||||||
export const getTargetElements = (
|
export const getTargetElements = (
|
||||||
elements: readonly NonDeletedExcalidrawElement[],
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
appState: Pick<AppState, "selectedElementIds" | "editingElement">,
|
appState: Pick<AppState, "selectedElementIds" | "editingElement">,
|
||||||
) =>
|
) =>
|
||||||
appState.editingElement
|
appState.editingElement
|
||||||
? [appState.editingElement]
|
? [appState.editingElement]
|
||||||
: getSelectedElements(elements, appState, true);
|
: getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
});
|
||||||
|
@ -83,6 +83,14 @@ export const SHAPES = [
|
|||||||
numericKey: KEYS["0"],
|
numericKey: KEYS["0"],
|
||||||
fillable: false,
|
fillable: false,
|
||||||
},
|
},
|
||||||
|
// TODO: frame, create icon and set up numeric key
|
||||||
|
// {
|
||||||
|
// icon: RectangleIcon,
|
||||||
|
// value: "frame",
|
||||||
|
// key: KEYS.F,
|
||||||
|
// numericKey: KEYS.SUBTRACT,
|
||||||
|
// fillable: false,
|
||||||
|
// },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const findShapeByKey = (key: string) => {
|
export const findShapeByKey = (key: string) => {
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -10,6 +10,7 @@ Object {
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -56,6 +57,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -89,6 +91,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -122,6 +125,7 @@ Object {
|
|||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -168,6 +172,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
@ -15,6 +15,7 @@ exports[`export exporting svg containing transformed images: svg export output 1
|
|||||||
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
|
src: url(\\"https://excalidraw.com/Cascadia.woff2\\");
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
</defs>
|
</defs>
|
||||||
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
|
<g transform=\\"translate(30.710678118654755 30.710678118654755) rotate(315 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\"></use></g><g transform=\\"translate(130.71067811865476 30.710678118654755) rotate(45 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, 1) translate(-50 0)\\"></use></g><g transform=\\"translate(30.710678118654755 130.71067811865476) rotate(45 50 50)\\"><use href=\\"#image-file_A\\" width=\\"100\\" height=\\"100\\" opacity=\\"1\\" transform=\\"scale(1, -1) translate(0 -100)\\"></use></g><g transform=\\"translate(130.71067811865476 130.71067811865476) rotate(315 25 25)\\"><use href=\\"#image-file_A\\" width=\\"50\\" height=\\"50\\" opacity=\\"1\\" transform=\\"scale(-1, -1) translate(-50 -50)\\"></use></g></svg>"
|
||||||
`;
|
`;
|
||||||
|
@ -6,6 +6,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0_copy",
|
"id": "id0_copy",
|
||||||
@ -37,6 +38,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -68,6 +70,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -104,6 +107,7 @@ Object {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -140,6 +144,7 @@ Object {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 300,
|
"height": 300,
|
||||||
"id": "id1",
|
"id": "id1",
|
||||||
@ -177,6 +182,7 @@ Object {
|
|||||||
"gap": 10,
|
"gap": 10,
|
||||||
},
|
},
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 81.48231043525051,
|
"height": 81.48231043525051,
|
||||||
"id": "id2",
|
"id": "id2",
|
||||||
|
@ -8,6 +8,7 @@ Object {
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 110,
|
"height": 110,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -61,6 +62,7 @@ Object {
|
|||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 110,
|
"height": 110,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ Object {
|
|||||||
"endArrowhead": "arrow",
|
"endArrowhead": "arrow",
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -54,6 +55,7 @@ Object {
|
|||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -98,6 +100,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -129,6 +132,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
@ -160,6 +164,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": null,
|
"boundElements": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 50,
|
"height": 50,
|
||||||
"id": "id0",
|
"id": "id0",
|
||||||
|
@ -8,6 +8,7 @@ Object {
|
|||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-arrow01",
|
"id": "id-arrow01",
|
||||||
@ -52,6 +53,7 @@ Object {
|
|||||||
"backgroundColor": "blue",
|
"backgroundColor": "blue",
|
||||||
"boundElements": Array [],
|
"boundElements": Array [],
|
||||||
"fillStyle": "cross-hatch",
|
"fillStyle": "cross-hatch",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [
|
"groupIds": Array [
|
||||||
"1",
|
"1",
|
||||||
"2",
|
"2",
|
||||||
@ -87,6 +89,7 @@ Object {
|
|||||||
"backgroundColor": "blue",
|
"backgroundColor": "blue",
|
||||||
"boundElements": Array [],
|
"boundElements": Array [],
|
||||||
"fillStyle": "cross-hatch",
|
"fillStyle": "cross-hatch",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [
|
"groupIds": Array [
|
||||||
"1",
|
"1",
|
||||||
"2",
|
"2",
|
||||||
@ -122,6 +125,7 @@ Object {
|
|||||||
"backgroundColor": "blue",
|
"backgroundColor": "blue",
|
||||||
"boundElements": Array [],
|
"boundElements": Array [],
|
||||||
"fillStyle": "cross-hatch",
|
"fillStyle": "cross-hatch",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [
|
"groupIds": Array [
|
||||||
"1",
|
"1",
|
||||||
"2",
|
"2",
|
||||||
@ -157,6 +161,7 @@ Object {
|
|||||||
"backgroundColor": "transparent",
|
"backgroundColor": "transparent",
|
||||||
"boundElements": Array [],
|
"boundElements": Array [],
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 0,
|
"height": 0,
|
||||||
"id": "id-freedraw01",
|
"id": "id-freedraw01",
|
||||||
@ -194,6 +199,7 @@ Object {
|
|||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-line01",
|
"id": "id-line01",
|
||||||
@ -240,6 +246,7 @@ Object {
|
|||||||
"endArrowhead": null,
|
"endArrowhead": null,
|
||||||
"endBinding": null,
|
"endBinding": null,
|
||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-draw01",
|
"id": "id-draw01",
|
||||||
@ -288,6 +295,7 @@ Object {
|
|||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 14,
|
"fontSize": 14,
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-text01",
|
"id": "id-text01",
|
||||||
@ -328,6 +336,7 @@ Object {
|
|||||||
"fillStyle": "hachure",
|
"fillStyle": "hachure",
|
||||||
"fontFamily": 1,
|
"fontFamily": 1,
|
||||||
"fontSize": 10,
|
"fontSize": 10,
|
||||||
|
"frameId": null,
|
||||||
"groupIds": Array [],
|
"groupIds": Array [],
|
||||||
"height": 100,
|
"height": 100,
|
||||||
"id": "id-text01",
|
"id": "id-text01",
|
||||||
|
1
src/tests/fixtures/elementFixture.ts
vendored
1
src/tests/fixtures/elementFixture.ts
vendored
@ -15,6 +15,7 @@ const elementBase: Omit<ExcalidrawElement, "type"> = {
|
|||||||
roughness: 1,
|
roughness: 1,
|
||||||
opacity: 100,
|
opacity: 100,
|
||||||
groupIds: [],
|
groupIds: [],
|
||||||
|
frameId: null,
|
||||||
roundness: null,
|
roundness: null,
|
||||||
seed: 1041657908,
|
seed: 1041657908,
|
||||||
version: 120,
|
version: 120,
|
||||||
|
@ -37,8 +37,12 @@ export class API {
|
|||||||
|
|
||||||
static getSelectedElements = (
|
static getSelectedElements = (
|
||||||
includeBoundTextElement: boolean = false,
|
includeBoundTextElement: boolean = false,
|
||||||
|
includeElementsInFrames: boolean = false,
|
||||||
): ExcalidrawElement[] => {
|
): ExcalidrawElement[] => {
|
||||||
return getSelectedElements(h.elements, h.state, includeBoundTextElement);
|
return getSelectedElements(h.elements, h.state, {
|
||||||
|
includeBoundTextElement,
|
||||||
|
includeElementsInFrames,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
static getSelectedElement = (): ExcalidrawElement => {
|
static getSelectedElement = (): ExcalidrawElement => {
|
||||||
@ -141,6 +145,7 @@ export class API {
|
|||||||
| "versionNonce"
|
| "versionNonce"
|
||||||
| "isDeleted"
|
| "isDeleted"
|
||||||
| "groupIds"
|
| "groupIds"
|
||||||
|
| "frameId"
|
||||||
| "link"
|
| "link"
|
||||||
| "updated"
|
| "updated"
|
||||||
> = {
|
> = {
|
||||||
|
@ -28,8 +28,10 @@ Object {
|
|||||||
"defaultSidebarDockedPreference": false,
|
"defaultSidebarDockedPreference": false,
|
||||||
"draggingElement": null,
|
"draggingElement": null,
|
||||||
"editingElement": null,
|
"editingElement": null,
|
||||||
|
"editingFrame": null,
|
||||||
"editingGroupId": null,
|
"editingGroupId": null,
|
||||||
"editingLinearElement": null,
|
"editingLinearElement": null,
|
||||||
|
"elementsToHighlight": null,
|
||||||
"errorMessage": null,
|
"errorMessage": null,
|
||||||
"exportBackground": true,
|
"exportBackground": true,
|
||||||
"exportEmbedScene": false,
|
"exportEmbedScene": false,
|
||||||
@ -37,6 +39,7 @@ Object {
|
|||||||
"exportScale": 1,
|
"exportScale": 1,
|
||||||
"exportWithDarkMode": false,
|
"exportWithDarkMode": false,
|
||||||
"fileHandle": null,
|
"fileHandle": null,
|
||||||
|
"frameToHighlight": null,
|
||||||
"gridSize": null,
|
"gridSize": null,
|
||||||
"isBindingEnabled": true,
|
"isBindingEnabled": true,
|
||||||
"isLoading": false,
|
"isLoading": false,
|
||||||
@ -62,10 +65,12 @@ Object {
|
|||||||
"scrollY": 0,
|
"scrollY": 0,
|
||||||
"scrolledOutside": false,
|
"scrolledOutside": false,
|
||||||
"selectedElementIds": Object {},
|
"selectedElementIds": Object {},
|
||||||
|
"selectedElementsAreBeingDragged": false,
|
||||||
"selectedGroupIds": Object {},
|
"selectedGroupIds": Object {},
|
||||||
"selectedLinearElement": null,
|
"selectedLinearElement": null,
|
||||||
"selectionElement": null,
|
"selectionElement": null,
|
||||||
"shouldCacheIgnoreZoom": false,
|
"shouldCacheIgnoreZoom": false,
|
||||||
|
"shouldRenderFrames": true,
|
||||||
"showHyperlinkPopup": false,
|
"showHyperlinkPopup": false,
|
||||||
"showStats": false,
|
"showStats": false,
|
||||||
"showWelcomeScreen": false,
|
"showWelcomeScreen": false,
|
||||||
|
@ -11,6 +11,7 @@ const toolMap = {
|
|||||||
freedraw: "freedraw",
|
freedraw: "freedraw",
|
||||||
text: "text",
|
text: "text",
|
||||||
eraser: "eraser",
|
eraser: "eraser",
|
||||||
|
frame: "frame",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ToolName = keyof typeof toolMap;
|
export type ToolName = keyof typeof toolMap;
|
||||||
|
File diff suppressed because one or more lines are too long
35
src/types.ts
35
src/types.ts
@ -15,6 +15,7 @@ import {
|
|||||||
ExcalidrawImageElement,
|
ExcalidrawImageElement,
|
||||||
Theme,
|
Theme,
|
||||||
StrokeRoundness,
|
StrokeRoundness,
|
||||||
|
ExcalidrawFrameElement,
|
||||||
} from "./element/types";
|
} from "./element/types";
|
||||||
import { SHAPES } from "./shapes";
|
import { SHAPES } from "./shapes";
|
||||||
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
import { Point as RoughPoint } from "roughjs/bin/geometry";
|
||||||
@ -85,7 +86,7 @@ export type BinaryFiles = Record<ExcalidrawElement["id"], BinaryFileData>;
|
|||||||
|
|
||||||
export type LastActiveTool =
|
export type LastActiveTool =
|
||||||
| {
|
| {
|
||||||
type: typeof SHAPES[number]["value"] | "eraser" | "hand";
|
type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
|
||||||
customType: null;
|
customType: null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -113,6 +114,10 @@ export type AppState = {
|
|||||||
isBindingEnabled: boolean;
|
isBindingEnabled: boolean;
|
||||||
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
|
startBoundElement: NonDeleted<ExcalidrawBindableElement> | null;
|
||||||
suggestedBindings: SuggestedBinding[];
|
suggestedBindings: SuggestedBinding[];
|
||||||
|
frameToHighlight: NonDeleted<ExcalidrawFrameElement> | null;
|
||||||
|
shouldRenderFrames: boolean;
|
||||||
|
editingFrame: string | null;
|
||||||
|
elementsToHighlight: NonDeleted<ExcalidrawElement>[] | null;
|
||||||
// element being edited, but not necessarily added to elements array yet
|
// element being edited, but not necessarily added to elements array yet
|
||||||
// (e.g. text element when typing into the input)
|
// (e.g. text element when typing into the input)
|
||||||
editingElement: NonDeletedExcalidrawElement | null;
|
editingElement: NonDeletedExcalidrawElement | null;
|
||||||
@ -126,7 +131,7 @@ export type AppState = {
|
|||||||
locked: boolean;
|
locked: boolean;
|
||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
type: typeof SHAPES[number]["value"] | "eraser" | "hand";
|
type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame";
|
||||||
customType: null;
|
customType: null;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
@ -178,6 +183,7 @@ export type AppState = {
|
|||||||
lastPointerDownWith: PointerType;
|
lastPointerDownWith: PointerType;
|
||||||
selectedElementIds: { [id: string]: boolean };
|
selectedElementIds: { [id: string]: boolean };
|
||||||
previousSelectedElementIds: { [id: string]: boolean };
|
previousSelectedElementIds: { [id: string]: boolean };
|
||||||
|
selectedElementsAreBeingDragged: boolean;
|
||||||
shouldCacheIgnoreZoom: boolean;
|
shouldCacheIgnoreZoom: boolean;
|
||||||
toast: { message: string; closable?: boolean; duration?: number } | null;
|
toast: { message: string; closable?: boolean; duration?: number } | null;
|
||||||
zenModeEnabled: boolean;
|
zenModeEnabled: boolean;
|
||||||
@ -532,6 +538,12 @@ export type ExcalidrawImperativeAPI = {
|
|||||||
setCursor: InstanceType<typeof App>["setCursor"];
|
setCursor: InstanceType<typeof App>["setCursor"];
|
||||||
resetCursor: InstanceType<typeof App>["resetCursor"];
|
resetCursor: InstanceType<typeof App>["resetCursor"];
|
||||||
toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
|
toggleSidebar: InstanceType<typeof App>["toggleSidebar"];
|
||||||
|
/**
|
||||||
|
* Disables rendering of frames (including element clipping), but currently
|
||||||
|
* the frames are still interactive in edit mode. As such, this API should be
|
||||||
|
* used in conjunction with view mode (props.viewModeEnabled).
|
||||||
|
*/
|
||||||
|
toggleFrameRendering: InstanceType<typeof App>["toggleFrameRendering"];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Device = Readonly<{
|
export type Device = Readonly<{
|
||||||
@ -541,3 +553,22 @@ export type Device = Readonly<{
|
|||||||
canDeviceFitSidebar: boolean;
|
canDeviceFitSidebar: boolean;
|
||||||
isLandscape: boolean;
|
isLandscape: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
type FrameNameBounds = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
angle: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FrameNameBoundsCache = {
|
||||||
|
get: (frameElement: ExcalidrawFrameElement) => FrameNameBounds | null;
|
||||||
|
_cache: Map<
|
||||||
|
string,
|
||||||
|
FrameNameBounds & {
|
||||||
|
zoom: AppState["zoom"]["value"];
|
||||||
|
versionNonce: ExcalidrawFrameElement["versionNonce"];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
21
src/utils.ts
21
src/utils.ts
@ -10,7 +10,11 @@ import {
|
|||||||
THEME,
|
THEME,
|
||||||
WINDOWS_EMOJI_FALLBACK_FONT,
|
WINDOWS_EMOJI_FALLBACK_FONT,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import { FontFamilyValues, FontString } from "./element/types";
|
import {
|
||||||
|
FontFamilyValues,
|
||||||
|
FontString,
|
||||||
|
NonDeletedExcalidrawElement,
|
||||||
|
} from "./element/types";
|
||||||
import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
|
import { AppState, DataURL, LastActiveTool, Zoom } from "./types";
|
||||||
import { unstable_batchedUpdates } from "react-dom";
|
import { unstable_batchedUpdates } from "react-dom";
|
||||||
import { SHAPES } from "./shapes";
|
import { SHAPES } from "./shapes";
|
||||||
@ -299,7 +303,7 @@ export const distance = (x: number, y: number) => Math.abs(x - y);
|
|||||||
export const updateActiveTool = (
|
export const updateActiveTool = (
|
||||||
appState: Pick<AppState, "activeTool">,
|
appState: Pick<AppState, "activeTool">,
|
||||||
data: (
|
data: (
|
||||||
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" }
|
| { type: typeof SHAPES[number]["value"] | "eraser" | "hand" | "frame" }
|
||||||
| { type: "custom"; customType: string }
|
| { type: "custom"; customType: string }
|
||||||
) & { lastActiveToolBeforeEraser?: LastActiveTool },
|
) & { lastActiveToolBeforeEraser?: LastActiveTool },
|
||||||
): AppState["activeTool"] => {
|
): AppState["activeTool"] => {
|
||||||
@ -824,3 +828,16 @@ export const composeEventHandlers = <E>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isOnlyExportingSingleFrame = (
|
||||||
|
elements: readonly NonDeletedExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
const frames = elements.filter((element) => element.type === "frame");
|
||||||
|
|
||||||
|
return (
|
||||||
|
frames.length === 1 &&
|
||||||
|
elements.every(
|
||||||
|
(element) => element.type === "frame" || element.frameId === frames[0].id,
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
133
src/zindex.ts
133
src/zindex.ts
@ -1,36 +1,52 @@
|
|||||||
import { bumpVersion } from "./element/mutateElement";
|
import { bumpVersion } from "./element/mutateElement";
|
||||||
|
import { isFrameElement } from "./element/typeChecks";
|
||||||
import { ExcalidrawElement } from "./element/types";
|
import { ExcalidrawElement } from "./element/types";
|
||||||
|
import { groupByFrames } from "./frame";
|
||||||
import { getElementsInGroup } from "./groups";
|
import { getElementsInGroup } from "./groups";
|
||||||
import { getSelectedElements } from "./scene";
|
import { getSelectedElements } from "./scene";
|
||||||
import Scene from "./scene/Scene";
|
import Scene from "./scene/Scene";
|
||||||
import { AppState } from "./types";
|
import { AppState } from "./types";
|
||||||
import { arrayToMap, findIndex, findLastIndex } from "./utils";
|
import { arrayToMap, findIndex, findLastIndex } from "./utils";
|
||||||
|
|
||||||
|
// elements that do not belong to a frame are considered a root element
|
||||||
|
const isRootElement = (element: ExcalidrawElement) => {
|
||||||
|
return !element.frameId;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns indices of elements to move based on selected elements.
|
* Returns indices of elements to move based on selected elements.
|
||||||
* Includes contiguous deleted elements that are between two selected elements,
|
* Includes contiguous deleted elements that are between two selected elements,
|
||||||
* e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
|
* e.g.: [0 (selected), 1 (deleted), 2 (deleted), 3 (selected)]
|
||||||
|
*
|
||||||
|
* Specified elements (elementsToBeMoved) take precedence over
|
||||||
|
* appState.selectedElementsIds
|
||||||
*/
|
*/
|
||||||
const getIndicesToMove = (
|
const getIndicesToMove = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
let selectedIndices: number[] = [];
|
let selectedIndices: number[] = [];
|
||||||
let deletedIndices: number[] = [];
|
let deletedIndices: number[] = [];
|
||||||
let includeDeletedIndex = null;
|
let includeDeletedIndex = null;
|
||||||
let index = -1;
|
let index = -1;
|
||||||
const selectedElementIds = arrayToMap(
|
const selectedElementIds = arrayToMap(
|
||||||
getSelectedElements(elements, appState, true),
|
elementsToBeMoved
|
||||||
|
? elementsToBeMoved
|
||||||
|
: getSelectedElements(elements, appState, {
|
||||||
|
includeBoundTextElement: true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
while (++index < elements.length) {
|
while (++index < elements.length) {
|
||||||
if (selectedElementIds.get(elements[index].id)) {
|
const element = elements[index];
|
||||||
|
if (selectedElementIds.get(element.id)) {
|
||||||
if (deletedIndices.length) {
|
if (deletedIndices.length) {
|
||||||
selectedIndices = selectedIndices.concat(deletedIndices);
|
selectedIndices = selectedIndices.concat(deletedIndices);
|
||||||
deletedIndices = [];
|
deletedIndices = [];
|
||||||
}
|
}
|
||||||
selectedIndices.push(index);
|
selectedIndices.push(index);
|
||||||
includeDeletedIndex = index + 1;
|
includeDeletedIndex = index + 1;
|
||||||
} else if (elements[index].isDeleted && includeDeletedIndex === index) {
|
} else if (element.isDeleted && includeDeletedIndex === index) {
|
||||||
includeDeletedIndex = index + 1;
|
includeDeletedIndex = index + 1;
|
||||||
deletedIndices.push(index);
|
deletedIndices.push(index);
|
||||||
} else {
|
} else {
|
||||||
@ -168,8 +184,8 @@ const getTargetIndex = (
|
|||||||
return candidateIndex;
|
return candidateIndex;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getTargetElementsMap = (
|
const getTargetElementsMap = <T extends ExcalidrawElement>(
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly T[],
|
||||||
indices: number[],
|
indices: number[],
|
||||||
) => {
|
) => {
|
||||||
return indices.reduce((acc, index) => {
|
return indices.reduce((acc, index) => {
|
||||||
@ -179,12 +195,13 @@ const getTargetElementsMap = (
|
|||||||
}, {} as Record<string, ExcalidrawElement>);
|
}, {} as Record<string, ExcalidrawElement>);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shiftElements = (
|
const _shiftElements = (
|
||||||
appState: AppState,
|
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
direction: "left" | "right",
|
direction: "left" | "right",
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
const indicesToMove = getIndicesToMove(elements, appState);
|
const indicesToMove = getIndicesToMove(elements, appState, elementsToBeMoved);
|
||||||
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
const targetElementsMap = getTargetElementsMap(elements, indicesToMove);
|
||||||
let groupedIndices = toContiguousGroups(indicesToMove);
|
let groupedIndices = toContiguousGroups(indicesToMove);
|
||||||
|
|
||||||
@ -246,7 +263,22 @@ const shiftElements = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const shiftElementsToEnd = (
|
const shiftElements = (
|
||||||
|
appState: AppState,
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
direction: "left" | "right",
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
return shift(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
direction,
|
||||||
|
_shiftElements,
|
||||||
|
elementsToBeMoved,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _shiftElementsToEnd = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
direction: "left" | "right",
|
direction: "left" | "right",
|
||||||
@ -317,33 +349,108 @@ const shiftElementsToEnd = (
|
|||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shiftElementsToEnd = (
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
direction: "left" | "right",
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
|
) => {
|
||||||
|
return shift(
|
||||||
|
elements,
|
||||||
|
appState,
|
||||||
|
direction,
|
||||||
|
_shiftElementsToEnd,
|
||||||
|
elementsToBeMoved,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function shift(
|
||||||
|
elements: readonly ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
direction: "left" | "right",
|
||||||
|
shiftFunction: (
|
||||||
|
elements: ExcalidrawElement[],
|
||||||
|
appState: AppState,
|
||||||
|
direction: "left" | "right",
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
|
) => ExcalidrawElement[] | readonly ExcalidrawElement[],
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
|
) {
|
||||||
|
let rootElements = elements.filter((element) => isRootElement(element));
|
||||||
|
const frameElementsMap = groupByFrames(elements);
|
||||||
|
|
||||||
|
// shift the root elements first
|
||||||
|
rootElements = shiftFunction(
|
||||||
|
rootElements,
|
||||||
|
appState,
|
||||||
|
direction,
|
||||||
|
elementsToBeMoved,
|
||||||
|
) as ExcalidrawElement[];
|
||||||
|
|
||||||
|
// shift the elements in frames if needed
|
||||||
|
frameElementsMap.forEach((frameElements, frameId) => {
|
||||||
|
if (!appState.selectedElementIds[frameId]) {
|
||||||
|
frameElementsMap.set(
|
||||||
|
frameId,
|
||||||
|
shiftFunction(
|
||||||
|
frameElements,
|
||||||
|
appState,
|
||||||
|
direction,
|
||||||
|
elementsToBeMoved,
|
||||||
|
) as ExcalidrawElement[],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// return the final elements
|
||||||
|
let finalElements: ExcalidrawElement[] = [];
|
||||||
|
|
||||||
|
rootElements.forEach((element) => {
|
||||||
|
if (isFrameElement(element)) {
|
||||||
|
finalElements = [
|
||||||
|
...finalElements,
|
||||||
|
...(frameElementsMap.get(element.id) ?? []),
|
||||||
|
element,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
finalElements = [...finalElements, element];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalElements;
|
||||||
|
}
|
||||||
|
|
||||||
// public API
|
// public API
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
export const moveOneLeft = (
|
export const moveOneLeft = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return shiftElements(appState, elements, "left");
|
return shiftElements(appState, elements, "left", elementsToBeMoved);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveOneRight = (
|
export const moveOneRight = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return shiftElements(appState, elements, "right");
|
return shiftElements(appState, elements, "right", elementsToBeMoved);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveAllLeft = (
|
export const moveAllLeft = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return shiftElementsToEnd(elements, appState, "left");
|
return shiftElementsToEnd(elements, appState, "left", elementsToBeMoved);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveAllRight = (
|
export const moveAllRight = (
|
||||||
elements: readonly ExcalidrawElement[],
|
elements: readonly ExcalidrawElement[],
|
||||||
appState: AppState,
|
appState: AppState,
|
||||||
|
elementsToBeMoved?: readonly ExcalidrawElement[],
|
||||||
) => {
|
) => {
|
||||||
return shiftElementsToEnd(elements, appState, "right");
|
return shiftElementsToEnd(elements, appState, "right", elementsToBeMoved);
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user