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