feat: introduce frames (#6123)

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Ryan Di 2023-06-15 00:42:01 +08:00 committed by GitHub
parent 4d7d96eb7b
commit 81ebf82979
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
78 changed files with 4563 additions and 480 deletions

View file

@ -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 {

View file

@ -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)}

View file

@ -249,6 +249,7 @@ export const actionWrapTextInContainer = register({
"rectangle",
),
groupIds: textElement.groupIds,
frameId: textElement.frameId,
});
// update bindings

View file

@ -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, {

View file

@ -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",

View file

@ -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]

View file

@ -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,
);
};

View file

@ -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;

View file

@ -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
);
},
});

View file

@ -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
View 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,
});

View file

@ -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,
};

View file

@ -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"

View file

@ -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({

View file

@ -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) ||

View file

@ -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(

View file

@ -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;

View file

@ -116,6 +116,11 @@ export type ActionName =
| "toggleLinearEditor"
| "toggleEraserTool"
| "toggleHandTool"
| "selectAllElementsInFrame"
| "removeAllElementsFromFrame"
| "toggleFrameRendering"
| "setFrameAsActiveTool"
| "createContainerFromText"
| "wrapTextInContainer";
export type PanelComponentProps = {