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

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