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

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