Compare commits

..

1 Commits

Author SHA1 Message Date
dwelle 0458834681 wip 2024-09-16 10:49:08 +02:00
38 changed files with 345 additions and 664 deletions
+1 -13
View File
@@ -126,8 +126,6 @@ import DebugCanvas, {
loadSavedDebugState, loadSavedDebugState,
} from "./components/DebugCanvas"; } from "./components/DebugCanvas";
import { AIComponents } from "./components/AI"; import { AIComponents } from "./components/AI";
import type { SaveWarningRef } from "./components/SaveWarning";
import { SaveWarning } from "./components/SaveWarning";
polyfill(); polyfill();
@@ -333,8 +331,6 @@ const ExcalidrawWrapper = () => {
const [langCode, setLangCode] = useAppLangCode(); const [langCode, setLangCode] = useAppLangCode();
const activityRef = useRef<SaveWarningRef | null>(null);
// initial state // initial state
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -619,8 +615,6 @@ const ExcalidrawWrapper = () => {
collabAPI.syncElements(elements); collabAPI.syncElements(elements);
} }
activityRef.current?.activity();
// this check is redundant, but since this is a hot path, it's best // this check is redundant, but since this is a hot path, it's best
// not to evaludate the nested expression every time // not to evaludate the nested expression every time
if (!LocalData.isSavePaused()) { if (!LocalData.isSavePaused()) {
@@ -655,12 +649,7 @@ const ExcalidrawWrapper = () => {
// Render the debug scene if the debug canvas is available // Render the debug scene if the debug canvas is available
if (debugCanvasRef.current && excalidrawAPI) { if (debugCanvasRef.current && excalidrawAPI) {
debugRenderer( debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
debugCanvasRef.current,
appState,
window.devicePixelRatio,
() => forceRefresh((prev) => !prev),
);
} }
}; };
@@ -862,7 +851,6 @@ const ExcalidrawWrapper = () => {
setTheme={(theme) => setAppTheme(theme)} setTheme={(theme) => setAppTheme(theme)}
refresh={() => forceRefresh((prev) => !prev)} refresh={() => forceRefresh((prev) => !prev)}
/> />
<SaveWarning ref={activityRef} />
<AppWelcomeScreen <AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen} onCollabDialogOpen={onCollabDialogOpen}
isCollabEnabled={!isCollabDisabled} isCollabEnabled={!isCollabDisabled}
+2 -12
View File
@@ -68,17 +68,12 @@ const _debugRenderer = (
canvas: HTMLCanvasElement, canvas: HTMLCanvasElement,
appState: AppState, appState: AppState,
scale: number, scale: number,
refresh: () => void,
) => { ) => {
const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions( const [normalizedWidth, normalizedHeight] = getNormalizedCanvasDimensions(
canvas, canvas,
scale, scale,
); );
if (appState.height !== canvas.height || appState.width !== canvas.width) {
refresh();
}
const context = bootstrapCanvas({ const context = bootstrapCanvas({
canvas, canvas,
scale, scale,
@@ -143,13 +138,8 @@ export const saveDebugState = (debug: { enabled: boolean }) => {
}; };
export const debugRenderer = throttleRAF( export const debugRenderer = throttleRAF(
( (canvas: HTMLCanvasElement, appState: AppState, scale: number) => {
canvas: HTMLCanvasElement, _debugRenderer(canvas, appState, scale);
appState: AppState,
scale: number,
refresh: () => void,
) => {
_debugRenderer(canvas, appState, scale, refresh);
}, },
{ trailing: true }, { trailing: true },
); );
-40
View File
@@ -1,40 +0,0 @@
import { forwardRef, useImperativeHandle, useRef } from "react";
import { t } from "../../packages/excalidraw/i18n";
import { getShortcutKey } from "../../packages/excalidraw/utils";
export type SaveWarningRef = {
activity: () => Promise<void>;
};
export const SaveWarning = forwardRef<SaveWarningRef, {}>((props, ref) => {
const dialogRef = useRef<HTMLDivElement | null>(null);
const timerRef = useRef<NodeJS.Timeout | null>(null);
useImperativeHandle(ref, () => ({
/**
* Call this API method via the ref to hide warning message
* and start an idle timer again.
*/
activity: async () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
dialogRef.current?.classList.remove("animate");
}
timerRef.current = setTimeout(() => {
timerRef.current = null;
dialogRef.current?.classList.add("animate");
}, 5000);
},
}));
return (
<div ref={dialogRef} className="alert-save">
<div className="dialog">
{t("alerts.saveYourContent", {
shortcut: getShortcutKey("CtrlOrCmd + S"),
})}
</div>
</div>
);
});
-37
View File
@@ -18,43 +18,6 @@
margin-inline-start: auto; margin-inline-start: auto;
} }
.alert-save {
position: absolute;
z-index: 10;
left: 0;
right: 0;
bottom: 10vh;
margin-inline: auto;
width: fit-content;
opacity: 0;
transition: all 0s;
&.animate {
opacity: 1;
transition: all 0.2s ease-in;
}
.dialog {
margin-inline: 10px;
padding: 1rem;
padding-inline: 1.25rem;
resize: none;
white-space: pre-wrap;
box-sizing: border-box;
background-color: var(--color-warning);
border-radius: var(--border-radius-md);
border: 1px solid var(--dialog-border-color);
font-size: 0.875rem;
text-align: center;
line-height: 1.5;
color: var(--color-text-warning);
}
}
.encrypted-icon { .encrypted-icon {
border-radius: var(--space-factor); border-radius: var(--space-factor);
color: var(--color-primary); color: var(--color-primary);
@@ -1,211 +0,0 @@
import React from "react";
import { Excalidraw } from "../index";
import { render } from "../tests/test-utils";
import { API } from "../tests/helpers/api";
import { point } from "../../math";
import { actionFlipHorizontal, actionFlipVertical } from "./actionFlip";
const { h } = window;
describe("flipping re-centers selection", () => {
it("elbow arrow touches group selection side yet it remains in place after multiple moves", async () => {
const elements = [
API.createElement({
type: "rectangle",
id: "rec1",
x: 100,
y: 100,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "rectangle",
id: "rec2",
x: 220,
y: 250,
width: 100,
height: 100,
boundElements: [{ id: "arr", type: "arrow" }],
}),
API.createElement({
type: "arrow",
id: "arr",
x: 149.9,
y: 95,
width: 156,
height: 239.9,
startBinding: {
elementId: "rec1",
focus: 0,
gap: 5,
fixedPoint: [0.49, -0.05],
},
endBinding: {
elementId: "rec2",
focus: 0,
gap: 5,
fixedPoint: [-0.05, 0.49],
},
startArrowhead: null,
endArrowhead: "arrow",
points: [
point(0, 0),
point(0, -35),
point(-90.9, -35),
point(-90.9, 204.9),
point(65.1, 204.9),
],
elbowed: true,
}),
];
await render(<Excalidraw initialData={{ elements }} />);
API.setSelectedElements(elements);
expect(Object.keys(h.state.selectedElementIds).length).toBe(3);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
API.executeAction(actionFlipHorizontal);
const rec1 = h.elements.find((el) => el.id === "rec1");
expect(rec1?.x).toBeCloseTo(100);
expect(rec1?.y).toBeCloseTo(100);
const rec2 = h.elements.find((el) => el.id === "rec2");
expect(rec2?.x).toBeCloseTo(220);
expect(rec2?.y).toBeCloseTo(250);
});
});
describe("flipping arrowheads", () => {
beforeEach(async () => {
await render(<Excalidraw />);
});
it("flipping bound arrow should flip arrowheads only", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe(null);
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
});
it("flipping bound arrow should flip arrowheads only 2", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const rect2 = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
startBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
endBinding: {
elementId: rect2.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, rect2, arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("circle");
expect(API.getElement(arrow).endArrowhead).toBe("arrow");
API.executeAction(actionFlipVertical);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping unbound arrow shouldn't flip arrowheads", () => {
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: "circle",
});
API.setElements([arrow]);
API.setSelectedElements([arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe("circle");
});
it("flipping bound arrow shouldn't flip arrowheads if selected alongside non-arrow eleemnt", () => {
const rect = API.createElement({
type: "rectangle",
boundElements: [{ type: "arrow", id: "arrow1" }],
});
const arrow = API.createElement({
type: "arrow",
id: "arrow1",
startArrowhead: "arrow",
endArrowhead: null,
endBinding: {
elementId: rect.id,
focus: 0.5,
gap: 5,
},
});
API.setElements([rect, arrow]);
API.setSelectedElements([rect, arrow]);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
API.executeAction(actionFlipHorizontal);
expect(API.getElement(arrow).startArrowhead).toBe("arrow");
expect(API.getElement(arrow).endArrowhead).toBe(null);
});
});
+2 -69
View File
@@ -2,8 +2,6 @@ import { register } from "./register";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { getNonDeletedElements } from "../element"; import { getNonDeletedElements } from "../element";
import type { import type {
ExcalidrawArrowElement,
ExcalidrawElbowArrowElement,
ExcalidrawElement, ExcalidrawElement,
NonDeleted, NonDeleted,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
@@ -20,13 +18,7 @@ import {
import { updateFrameMembershipOfSelectedElements } from "../frame"; import { updateFrameMembershipOfSelectedElements } from "../frame";
import { flipHorizontal, flipVertical } from "../components/icons"; import { flipHorizontal, flipVertical } from "../components/icons";
import { StoreAction } from "../store"; import { StoreAction } from "../store";
import { import { isLinearElement } from "../element/typeChecks";
isArrowElement,
isElbowArrow,
isLinearElement,
} from "../element/typeChecks";
import { mutateElbowArrow } from "../element/routing";
import { mutateElement, newElementWith } from "../element/mutateElement";
export const actionFlipHorizontal = register({ export const actionFlipHorizontal = register({
name: "flipHorizontal", name: "flipHorizontal",
@@ -117,23 +109,7 @@ const flipElements = (
flipDirection: "horizontal" | "vertical", flipDirection: "horizontal" | "vertical",
app: AppClassProperties, app: AppClassProperties,
): ExcalidrawElement[] => { ): ExcalidrawElement[] => {
if ( const { minX, minY, maxX, maxY } = getCommonBoundingBox(selectedElements);
selectedElements.every(
(element) =>
isArrowElement(element) && (element.startBinding || element.endBinding),
)
) {
return selectedElements.map((element) => {
const _element = element as ExcalidrawArrowElement;
return newElementWith(_element, {
startArrowhead: _element.endArrowhead,
endArrowhead: _element.startArrowhead,
});
});
}
const { minX, minY, maxX, maxY, midX, midY } =
getCommonBoundingBox(selectedElements);
resizeMultipleElements( resizeMultipleElements(
elementsMap, elementsMap,
@@ -155,48 +131,5 @@ const flipElements = (
[], [],
); );
// ---------------------------------------------------------------------------
// flipping arrow elements (and potentially other) makes the selection group
// "move" across the canvas because of how arrows can bump against the "wall"
// of the selection, so we need to center the group back to the original
// position so that repeated flips don't accumulate the offset
const { elbowArrows, otherElements } = selectedElements.reduce(
(
acc: {
elbowArrows: ExcalidrawElbowArrowElement[];
otherElements: ExcalidrawElement[];
},
element,
) =>
isElbowArrow(element)
? { ...acc, elbowArrows: acc.elbowArrows.concat(element) }
: { ...acc, otherElements: acc.otherElements.concat(element) },
{ elbowArrows: [], otherElements: [] },
);
const { midX: newMidX, midY: newMidY } =
getCommonBoundingBox(selectedElements);
const [diffX, diffY] = [midX - newMidX, midY - newMidY];
otherElements.forEach((element) =>
mutateElement(element, {
x: element.x + diffX,
y: element.y + diffY,
}),
);
elbowArrows.forEach((element) =>
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
),
);
// ---------------------------------------------------------------------------
return selectedElements; return selectedElements;
}; };
@@ -1685,6 +1685,19 @@ export const actionChangeArrowType = register({
: {}), : {}),
}, },
); );
} else {
mutateElement(
newElement,
{
startBinding: newElement.startBinding
? { ...newElement.startBinding, fixedPoint: null }
: null,
endBinding: newElement.endBinding
? { ...newElement.endBinding, fixedPoint: null }
: null,
},
false,
);
} }
return newElement; return newElement;
+85
View File
@@ -1,5 +1,90 @@
import oc from "open-color"; import oc from "open-color";
import type { Merge } from "./utility-types"; import type { Merge } from "./utility-types";
import { clamp } from "../math/utils";
import tinycolor from "tinycolor2";
import { degreesToRadians } from "../math/angle";
import type { Degrees } from "../math/types";
function cssHueRotate(
red: number,
green: number,
blue: number,
degrees: Degrees,
): { r: number; g: number; b: number } {
// normalize
const r = red / 255;
const g = green / 255;
const b = blue / 255;
// Convert degrees to radians
const a = degreesToRadians(degrees);
const c = Math.cos(a);
const s = Math.sin(a);
// rotation matrix
const matrix = [
0.213 + c * 0.787 - s * 0.213,
0.715 - c * 0.715 - s * 0.715,
0.072 - c * 0.072 + s * 0.928,
0.213 - c * 0.213 + s * 0.143,
0.715 + c * 0.285 + s * 0.14,
0.072 - c * 0.072 - s * 0.283,
0.213 - c * 0.213 - s * 0.787,
0.715 - c * 0.715 + s * 0.715,
0.072 + c * 0.928 + s * 0.072,
];
// transform
const newR = r * matrix[0] + g * matrix[1] + b * matrix[2];
const newG = r * matrix[3] + g * matrix[4] + b * matrix[5];
const newB = r * matrix[6] + g * matrix[7] + b * matrix[8];
// clamp the values to [0, 1] range and convert back to [0, 255]
return {
r: Math.round(Math.max(0, Math.min(1, newR)) * 255),
g: Math.round(Math.max(0, Math.min(1, newG)) * 255),
b: Math.round(Math.max(0, Math.min(1, newB)) * 255),
};
}
const cssInvert = (
r: number,
g: number,
b: number,
percent: number,
): { r: number; g: number; b: number } => {
const p = clamp(percent, 0, 100) / 100;
// Function to invert a single color component
const invertComponent = (color: number): number => {
// Apply the invert formula
const inverted = color * (1 - p) + (255 - color) * p;
// Round to the nearest integer and clamp to [0, 255]
return Math.round(clamp(inverted, 0, 255));
};
// Calculate the inverted RGB components
const invertedR = invertComponent(r);
const invertedG = invertComponent(g);
const invertedB = invertComponent(b);
return { r: invertedR, g: invertedG, b: invertedB };
};
export const applyDarkModeFilter = (color: string) => {
let tc = tinycolor(color);
const _alpha = tc._a;
// order of operations matters
// (corresponds to "filter: invert(invertPercent) hue-rotate(hueDegrees)" in css)
tc = tinycolor(cssInvert(tc._r, tc._g, tc._b, 93));
tc = tinycolor(cssHueRotate(tc._r, tc._g, tc._b, 180 as Degrees));
tc.setAlpha(_alpha);
return tc.toHex8String();
};
// FIXME can't put to utils.ts rn because of circular dependency // FIXME can't put to utils.ts rn because of circular dependency
const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>( const pick = <R extends Record<string, any>, K extends readonly (keyof R)[]>(
+9 -42
View File
@@ -185,7 +185,6 @@ import type {
MagicGenerationData, MagicGenerationData,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
ExcalidrawArrowElement, ExcalidrawArrowElement,
NonDeletedSceneElementsMap,
} from "../element/types"; } from "../element/types";
import { getCenter, getDistance } from "../gesture"; import { getCenter, getDistance } from "../gesture";
import { import {
@@ -288,7 +287,6 @@ import {
getDateTime, getDateTime,
isShallowEqual, isShallowEqual,
arrayToMap, arrayToMap,
toBrandedType,
} from "../utils"; } from "../utils";
import { import {
createSrcDoc, createSrcDoc,
@@ -437,7 +435,7 @@ import { actionTextAutoResize } from "../actions/actionTextAutoResize";
import { getVisibleSceneBounds } from "../element/bounds"; import { getVisibleSceneBounds } from "../element/bounds";
import { isMaybeMermaidDefinition } from "../mermaid"; import { isMaybeMermaidDefinition } from "../mermaid";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { mutateElbowArrow, updateElbowArrow } from "../element/routing"; import { mutateElbowArrow } from "../element/routing";
import { import {
FlowChartCreator, FlowChartCreator,
FlowChartNavigator, FlowChartNavigator,
@@ -1702,6 +1700,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: this.elementsPendingErasure, elementsPendingErasure: this.elementsPendingErasure,
pendingFlowchartNodes: pendingFlowchartNodes:
this.flowChartCreator.pendingNodes, this.flowChartCreator.pendingNodes,
theme: this.state.theme,
}} }}
/> />
{this.state.newElement && ( {this.state.newElement && (
@@ -1722,6 +1721,7 @@ class App extends React.Component<AppProps, AppState> {
elementsPendingErasure: elementsPendingErasure:
this.elementsPendingErasure, this.elementsPendingErasure,
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme: this.state.theme,
}} }}
/> />
)} )}
@@ -2697,6 +2697,11 @@ class App extends React.Component<AppProps, AppState> {
activeTool: updateActiveTool(this.state, { type: "selection" }), activeTool: updateActiveTool(this.state, { type: "selection" }),
}); });
} }
if (prevState.theme !== this.state.theme) {
this.scene
.getElementsIncludingDeleted()
.forEach((element) => ShapeCache.delete(element));
}
if ( if (
this.state.activeTool.type === "eraser" && this.state.activeTool.type === "eraser" &&
prevState.theme !== this.state.theme prevState.theme !== this.state.theme
@@ -3111,45 +3116,7 @@ class App extends React.Component<AppProps, AppState> {
retainSeed?: boolean; retainSeed?: boolean;
fitToContent?: boolean; fitToContent?: boolean;
}) => { }) => {
let elements = opts.elements.map((el, _, elements) => { const elements = restoreElements(opts.elements, null, undefined);
if (isElbowArrow(el)) {
const startEndElements = [
el.startBinding &&
elements.find((l) => l.id === el.startBinding?.elementId),
el.endBinding &&
elements.find((l) => l.id === el.endBinding?.elementId),
];
const startBinding = startEndElements[0] ? el.startBinding : null;
const endBinding = startEndElements[1] ? el.endBinding : null;
return {
...el,
...updateElbowArrow(
{
...el,
startBinding,
endBinding,
},
toBrandedType<NonDeletedSceneElementsMap>(
new Map(
startEndElements
.filter((x) => x != null)
.map(
(el) =>
[el!.id, el] as [
string,
Ordered<NonDeletedExcalidrawElement>,
],
),
),
),
[el.points[0], el.points[el.points.length - 1]],
),
};
}
return el;
});
elements = restoreElements(elements, null, undefined);
const [minX, minY, maxX, maxY] = getCommonBounds(elements); const [minX, minY, maxX, maxY] = getCommonBounds(elements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
+1 -1
View File
@@ -124,7 +124,7 @@ body.excalidraw-cursor-resize * {
// recommends surface color of #121212, 93% yields #111111 for #FFF // recommends surface color of #121212, 93% yields #111111 for #FFF
canvas { canvas {
filter: var(--theme-filter); // filter: var(--theme-filter);
} }
} }
@@ -189,7 +189,11 @@
} }
} }
$theme-filter: "invert(93%) hue-rotate(180deg)"; $theme-filter: "invert(93%) hue-rotate(180deg)"; // prod
// $theme-filter: "invert(93%)"; // prod
// $theme-filter: "hue-rotate(180deg)"; // prod
// $theme-filter: "hue-rotate(180deg) invert(93%)";
$right-sidebar-width: "302px"; $right-sidebar-width: "302px";
:export { :export {
+5 -9
View File
@@ -5,7 +5,6 @@ import type {
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawSelectionElement, ExcalidrawSelectionElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding,
FontFamilyValues, FontFamilyValues,
OrderedExcalidrawElement, OrderedExcalidrawElement,
PointBinding, PointBinding,
@@ -22,7 +21,6 @@ import {
import { import {
isArrowElement, isArrowElement,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isLinearElement, isLinearElement,
isTextElement, isTextElement,
isUsingAdaptiveRadius, isUsingAdaptiveRadius,
@@ -103,8 +101,8 @@ const getFontFamilyByName = (fontFamilyName: string): FontFamilyValues => {
const repairBinding = ( const repairBinding = (
element: ExcalidrawLinearElement, element: ExcalidrawLinearElement,
binding: PointBinding | FixedPointBinding | null, binding: PointBinding | null,
): PointBinding | FixedPointBinding | null => { ): PointBinding | null => {
if (!binding) { if (!binding) {
return null; return null;
} }
@@ -112,11 +110,9 @@ const repairBinding = (
return { return {
...binding, ...binding,
focus: binding.focus || 0, focus: binding.focus || 0,
...(isElbowArrow(element) && isFixedPointBinding(binding) fixedPoint: isElbowArrow(element)
? { ? normalizeFixedPoint(binding.fixedPoint ?? [0, 0])
fixedPoint: normalizeFixedPoint(binding.fixedPoint ?? [0, 0]), : null,
}
: {}),
}; };
}; };
+2 -3
View File
@@ -39,7 +39,6 @@ import {
isBindingElement, isBindingElement,
isBoundToContainer, isBoundToContainer,
isElbowArrow, isElbowArrow,
isFixedPointBinding,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isRectangularElement, isRectangularElement,
@@ -798,7 +797,7 @@ export const bindPointToSnapToElementOutline = (
isVertical isVertical
? Math.abs(p[1] - i[1]) < 0.1 ? Math.abs(p[1] - i[1]) < 0.1
: Math.abs(p[0] - i[0]) < 0.1, : Math.abs(p[0] - i[0]) < 0.1,
)[0] ?? p; )[0] ?? point;
} }
return p; return p;
@@ -1014,7 +1013,7 @@ const updateBoundPoint = (
const direction = startOrEnd === "startBinding" ? -1 : 1; const direction = startOrEnd === "startBinding" ? -1 : 1;
const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1; const edgePointIndex = direction === -1 ? 0 : linearElement.points.length - 1;
if (isElbowArrow(linearElement) && isFixedPointBinding(binding)) { if (isElbowArrow(linearElement)) {
const fixedPoint = const fixedPoint =
normalizeFixedPoint(binding.fixedPoint) ?? normalizeFixedPoint(binding.fixedPoint) ??
calculateFixedPointForElbowArrowBinding( calculateFixedPointForElbowArrowBinding(
+8 -1
View File
@@ -35,6 +35,7 @@ export const dragSelectedElements = (
) => { ) => {
if ( if (
_selectedElements.length === 1 && _selectedElements.length === 1 &&
isArrowElement(_selectedElements[0]) &&
isElbowArrow(_selectedElements[0]) && isElbowArrow(_selectedElements[0]) &&
(_selectedElements[0].startBinding || _selectedElements[0].endBinding) (_selectedElements[0].startBinding || _selectedElements[0].endBinding)
) { ) {
@@ -42,7 +43,13 @@ export const dragSelectedElements = (
} }
const selectedElements = _selectedElements.filter( const selectedElements = _selectedElements.filter(
(el) => !(isElbowArrow(el) && el.startBinding && el.endBinding), (el) =>
!(
isArrowElement(el) &&
isElbowArrow(el) &&
el.startBinding &&
el.endBinding
),
); );
// we do not want a frame and its elements to be selected at the same time // we do not want a frame and its elements to be selected at the same time
@@ -102,7 +102,6 @@ export class LinearElementEditor {
public readonly endBindingElement: ExcalidrawBindableElement | null | "keep"; public readonly endBindingElement: ExcalidrawBindableElement | null | "keep";
public readonly hoverPointIndex: number; public readonly hoverPointIndex: number;
public readonly segmentMidPointHoveredCoords: GlobalPoint | null; public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
public readonly elbowed: boolean;
constructor(element: NonDeleted<ExcalidrawLinearElement>) { constructor(element: NonDeleted<ExcalidrawLinearElement>) {
this.elementId = element.id as string & { this.elementId = element.id as string & {
@@ -132,7 +131,6 @@ export class LinearElementEditor {
}; };
this.hoverPointIndex = -1; this.hoverPointIndex = -1;
this.segmentMidPointHoveredCoords = null; this.segmentMidPointHoveredCoords = null;
this.elbowed = isElbowArrow(element) && element.elbowed;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1479,9 +1477,7 @@ export class LinearElementEditor {
nextPoints, nextPoints,
vector(offsetX, offsetY), vector(offsetX, offsetY),
bindings, bindings,
{ options,
isDragging: options?.isDragging,
},
); );
} else { } else {
const nextCoords = getElementPointsCoords(element, nextPoints); const nextCoords = getElementPointsCoords(element, nextPoints);
+14 -4
View File
@@ -9,7 +9,6 @@ import type {
ExcalidrawTextElementWithContainer, ExcalidrawTextElementWithContainer,
ExcalidrawImageElement, ExcalidrawImageElement,
ElementsMap, ElementsMap,
ExcalidrawArrowElement,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -910,8 +909,6 @@ export const resizeMultipleElements = (
fontSize?: ExcalidrawTextElement["fontSize"]; fontSize?: ExcalidrawTextElement["fontSize"];
scale?: ExcalidrawImageElement["scale"]; scale?: ExcalidrawImageElement["scale"];
boundTextFontSize?: ExcalidrawTextElement["fontSize"]; boundTextFontSize?: ExcalidrawTextElement["fontSize"];
startBinding?: ExcalidrawArrowElement["startBinding"];
endBinding?: ExcalidrawArrowElement["endBinding"];
}; };
}[] = []; }[] = [];
@@ -996,6 +993,19 @@ export const resizeMultipleElements = (
mutateElement(element, update, false); mutateElement(element, update, false);
if (isArrowElement(element) && isElbowArrow(element)) {
mutateElbowArrow(
element,
elementsMap,
element.points,
undefined,
undefined,
{
informMutation: false,
},
);
}
updateBoundElements(element, elementsMap, { updateBoundElements(element, elementsMap, {
simultaneouslyUpdated: elementsToUpdate, simultaneouslyUpdated: elementsToUpdate,
oldSize: { width: oldWidth, height: oldHeight }, oldSize: { width: oldWidth, height: oldHeight },
@@ -1049,7 +1059,7 @@ const rotateMultipleElements = (
(centerAngle + origAngle - element.angle) as Radians, (centerAngle + origAngle - element.angle) as Radians,
); );
if (isElbowArrow(element)) { if (isArrowElement(element) && isElbowArrow(element)) {
const points = getArrowLocalFixedPoints(element, elementsMap); const points = getArrowLocalFixedPoints(element, elementsMap);
mutateElbowArrow(element, elementsMap, points); mutateElbowArrow(element, elementsMap, points);
} else { } else {
+12 -13
View File
@@ -94,16 +94,7 @@ describe("elbow arrow routing", () => {
describe("elbow arrow ui", () => { describe("elbow arrow ui", () => {
beforeEach(async () => { beforeEach(async () => {
localStorage.clear();
await render(<Excalidraw handleKeyboardGlobally={true} />); await render(<Excalidraw handleKeyboardGlobally={true} />);
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
}); });
it("can follow bound shapes", async () => { it("can follow bound shapes", async () => {
@@ -139,8 +130,8 @@ describe("elbow arrow ui", () => {
expect(arrow.elbowed).toBe(true); expect(arrow.elbowed).toBe(true);
expect(arrow.points).toEqual([ expect(arrow.points).toEqual([
[0, 0], [0, 0],
[45, 0], [35, 0],
[45, 200], [35, 200],
[90, 200], [90, 200],
]); ]);
}); });
@@ -172,6 +163,14 @@ describe("elbow arrow ui", () => {
h.state, h.state,
)[0] as ExcalidrawArrowElement; )[0] as ExcalidrawArrowElement;
fireEvent.contextMenu(GlobalTestState.interactiveCanvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
fireEvent.click(queryByTestId(contextMenu!, "stats")!);
mouse.click(51, 51); mouse.click(51, 51);
const inputAngle = UI.queryStatsProperty("A")?.querySelector( const inputAngle = UI.queryStatsProperty("A")?.querySelector(
@@ -183,8 +182,8 @@ describe("elbow arrow ui", () => {
[0, 0], [0, 0],
[35, 0], [35, 0],
[35, 90], [35, 90],
[35, 90], // Note that coordinates are rounded above! [25, 90],
[35, 165], [25, 165],
[103, 165], [103, 165],
]); ]);
}); });
+32 -62
View File
@@ -36,11 +36,11 @@ import {
HEADING_UP, HEADING_UP,
vectorToHeading, vectorToHeading,
} from "./heading"; } from "./heading";
import type { ElementUpdate } from "./mutateElement";
import { mutateElement } from "./mutateElement"; import { mutateElement } from "./mutateElement";
import { isBindableElement, isRectanguloidElement } from "./typeChecks"; import { isBindableElement, isRectanguloidElement } from "./typeChecks";
import type { import type {
ExcalidrawElbowArrowElement, ExcalidrawElbowArrowElement,
FixedPointBinding,
NonDeletedSceneElementsMap, NonDeletedSceneElementsMap,
SceneElementsMap, SceneElementsMap,
} from "./types"; } from "./types";
@@ -72,48 +72,16 @@ export const mutateElbowArrow = (
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap, elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[], nextPoints: readonly LocalPoint[],
offset?: Vector, offset?: Vector,
otherUpdates?: Omit< otherUpdates?: {
ElementUpdate<ExcalidrawElbowArrowElement>, startBinding?: FixedPointBinding | null;
"angle" | "x" | "y" | "width" | "height" | "elbowed" | "points" endBinding?: FixedPointBinding | null;
>,
options?: {
isDragging?: boolean;
informMutation?: boolean;
}, },
) => {
const update = updateElbowArrow(
arrow,
elementsMap,
nextPoints,
offset,
options,
);
if (update) {
mutateElement(
arrow,
{
...otherUpdates,
...update,
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
}
};
export const updateElbowArrow = (
arrow: ExcalidrawElbowArrowElement,
elementsMap: NonDeletedSceneElementsMap | SceneElementsMap,
nextPoints: readonly LocalPoint[],
offset?: Vector,
options?: { options?: {
isDragging?: boolean; isDragging?: boolean;
disableBinding?: boolean; disableBinding?: boolean;
informMutation?: boolean; informMutation?: boolean;
}, },
): ElementUpdate<ExcalidrawElbowArrowElement> | null => { ) => {
const origStartGlobalPoint: GlobalPoint = pointTranslate( const origStartGlobalPoint: GlobalPoint = pointTranslate(
pointTranslate<LocalPoint, GlobalPoint>( pointTranslate<LocalPoint, GlobalPoint>(
nextPoints[0], nextPoints[0],
@@ -267,8 +235,6 @@ export const updateElbowArrow = (
BASE_PADDING, BASE_PADDING,
), ),
boundsOverlap, boundsOverlap,
hoveredStartElement && aabbForElement(hoveredStartElement),
hoveredEndElement && aabbForElement(hoveredEndElement),
); );
const startDonglePosition = getDonglePosition( const startDonglePosition = getDonglePosition(
dynamicAABBs[0], dynamicAABBs[0],
@@ -329,10 +295,18 @@ export const updateElbowArrow = (
startDongle && points.unshift(startGlobalPoint); startDongle && points.unshift(startGlobalPoint);
endDongle && points.push(endGlobalPoint); endDongle && points.push(endGlobalPoint);
return normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0); mutateElement(
arrow,
{
...otherUpdates,
...normalizedArrowElementUpdate(simplifyElbowArrowPoints(points), 0, 0),
angle: 0 as Radians,
},
options?.informMutation,
);
} else {
console.error("Elbow arrow cannot find a route");
} }
return null;
}; };
const offsetFromHeading = ( const offsetFromHeading = (
@@ -501,11 +475,7 @@ const generateDynamicAABBs = (
startDifference?: [number, number, number, number], startDifference?: [number, number, number, number],
endDifference?: [number, number, number, number], endDifference?: [number, number, number, number],
disableSideHack?: boolean, disableSideHack?: boolean,
startElementBounds?: Bounds | null,
endElementBounds?: Bounds | null,
): Bounds[] => { ): Bounds[] => {
const startEl = startElementBounds ?? a;
const endEl = endElementBounds ?? b;
const [startUp, startRight, startDown, startLeft] = startDifference ?? [ const [startUp, startRight, startDown, startLeft] = startDifference ?? [
0, 0, 0, 0, 0, 0, 0, 0,
]; ];
@@ -514,29 +484,29 @@ const generateDynamicAABBs = (
const first = [ const first = [
a[0] > b[2] a[0] > b[2]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.min((startEl[0] + endEl[2]) / 2, a[0] - startLeft) ? Math.min((a[0] + b[2]) / 2, a[0] - startLeft)
: (startEl[0] + endEl[2]) / 2 : (a[0] + b[2]) / 2
: a[0] > b[0] : a[0] > b[0]
? a[0] - startLeft ? a[0] - startLeft
: common[0] - startLeft, : common[0] - startLeft,
a[1] > b[3] a[1] > b[3]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.min((startEl[1] + endEl[3]) / 2, a[1] - startUp) ? Math.min((a[1] + b[3]) / 2, a[1] - startUp)
: (startEl[1] + endEl[3]) / 2 : (a[1] + b[3]) / 2
: a[1] > b[1] : a[1] > b[1]
? a[1] - startUp ? a[1] - startUp
: common[1] - startUp, : common[1] - startUp,
a[2] < b[0] a[2] < b[0]
? a[1] > b[3] || a[3] < b[1] ? a[1] > b[3] || a[3] < b[1]
? Math.max((startEl[2] + endEl[0]) / 2, a[2] + startRight) ? Math.max((a[2] + b[0]) / 2, a[2] + startRight)
: (startEl[2] + endEl[0]) / 2 : (a[2] + b[0]) / 2
: a[2] < b[2] : a[2] < b[2]
? a[2] + startRight ? a[2] + startRight
: common[2] + startRight, : common[2] + startRight,
a[3] < b[1] a[3] < b[1]
? a[0] > b[2] || a[2] < b[0] ? a[0] > b[2] || a[2] < b[0]
? Math.max((startEl[3] + endEl[1]) / 2, a[3] + startDown) ? Math.max((a[3] + b[1]) / 2, a[3] + startDown)
: (startEl[3] + endEl[1]) / 2 : (a[3] + b[1]) / 2
: a[3] < b[3] : a[3] < b[3]
? a[3] + startDown ? a[3] + startDown
: common[3] + startDown, : common[3] + startDown,
@@ -544,29 +514,29 @@ const generateDynamicAABBs = (
const second = [ const second = [
b[0] > a[2] b[0] > a[2]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.min((endEl[0] + startEl[2]) / 2, b[0] - endLeft) ? Math.min((b[0] + a[2]) / 2, b[0] - endLeft)
: (endEl[0] + startEl[2]) / 2 : (b[0] + a[2]) / 2
: b[0] > a[0] : b[0] > a[0]
? b[0] - endLeft ? b[0] - endLeft
: common[0] - endLeft, : common[0] - endLeft,
b[1] > a[3] b[1] > a[3]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.min((endEl[1] + startEl[3]) / 2, b[1] - endUp) ? Math.min((b[1] + a[3]) / 2, b[1] - endUp)
: (endEl[1] + startEl[3]) / 2 : (b[1] + a[3]) / 2
: b[1] > a[1] : b[1] > a[1]
? b[1] - endUp ? b[1] - endUp
: common[1] - endUp, : common[1] - endUp,
b[2] < a[0] b[2] < a[0]
? b[1] > a[3] || b[3] < a[1] ? b[1] > a[3] || b[3] < a[1]
? Math.max((endEl[2] + startEl[0]) / 2, b[2] + endRight) ? Math.max((b[2] + a[0]) / 2, b[2] + endRight)
: (endEl[2] + startEl[0]) / 2 : (b[2] + a[0]) / 2
: b[2] < a[2] : b[2] < a[2]
? b[2] + endRight ? b[2] + endRight
: common[2] + endRight, : common[2] + endRight,
b[3] < a[1] b[3] < a[1]
? b[0] > a[2] || b[2] < a[0] ? b[0] > a[2] || b[2] < a[0]
? Math.max((endEl[3] + startEl[1]) / 2, b[3] + endDown) ? Math.max((b[3] + a[1]) / 2, b[3] + endDown)
: (endEl[3] + startEl[1]) / 2 : (b[3] + a[1]) / 2
: b[3] < a[3] : b[3] < a[3]
? b[3] + endDown ? b[3] + endDown
: common[3] + endDown, : common[3] + endDown,
+7 -1
View File
@@ -11,7 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isTextElement, isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { CLASSES, isSafari, POINTER_BUTTON } from "../constants"; import { CLASSES, isSafari, POINTER_BUTTON, THEME } from "../constants";
import type { import type {
ExcalidrawElement, ExcalidrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
@@ -50,6 +50,7 @@ import {
originalContainerCache, originalContainerCache,
updateOriginalContainerCache, updateOriginalContainerCache,
} from "./containerCache"; } from "./containerCache";
import { applyDarkModeFilter } from "../colors";
const getTransform = ( const getTransform = (
width: number, width: number,
@@ -273,10 +274,15 @@ export const textWysiwyg = ({
textAlign, textAlign,
verticalAlign, verticalAlign,
color: updatedTextElement.strokeColor, color: updatedTextElement.strokeColor,
// color:
// appState.theme === THEME.DARK
// ? applyDarkModeFilter(updatedTextElement.strokeColor)
// : updatedTextElement.strokeColor,
opacity: updatedTextElement.opacity / 100, opacity: updatedTextElement.opacity / 100,
filter: "var(--theme-filter)", filter: "var(--theme-filter)",
maxHeight: `${editorMaxHeight}px`, maxHeight: `${editorMaxHeight}px`,
}); });
// console.log("...", updatedTextElement.strokeColor);
editable.scrollTop = 0; editable.scrollTop = 0;
// For some reason updating font attribute doesn't set font family // For some reason updating font attribute doesn't set font family
// hence updating font family explicitly for test environment // hence updating font family explicitly for test environment
+2 -5
View File
@@ -320,12 +320,9 @@ export const getDefaultRoundnessTypeForElement = (
}; };
export const isFixedPointBinding = ( export const isFixedPointBinding = (
binding: PointBinding | FixedPointBinding, binding: PointBinding,
): binding is FixedPointBinding => { ): binding is FixedPointBinding => {
return ( return binding.fixedPoint != null;
Object.hasOwn(binding, "fixedPoint") &&
(binding as FixedPointBinding).fixedPoint != null
);
}; };
// TODO: Move this to @excalidraw/math // TODO: Move this to @excalidraw/math
+7 -12
View File
@@ -193,7 +193,6 @@ export type ExcalidrawElement =
| ExcalidrawGenericElement | ExcalidrawGenericElement
| ExcalidrawTextElement | ExcalidrawTextElement
| ExcalidrawLinearElement | ExcalidrawLinearElement
| ExcalidrawArrowElement
| ExcalidrawFreeDrawElement | ExcalidrawFreeDrawElement
| ExcalidrawImageElement | ExcalidrawImageElement
| ExcalidrawFrameElement | ExcalidrawFrameElement
@@ -269,19 +268,15 @@ export type PointBinding = {
elementId: ExcalidrawBindableElement["id"]; elementId: ExcalidrawBindableElement["id"];
focus: number; focus: number;
gap: number; gap: number;
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint | null;
}; };
export type FixedPointBinding = Merge< export type FixedPointBinding = Merge<PointBinding, { fixedPoint: FixedPoint }>;
PointBinding,
{
// Represents the fixed point binding information in form of a vertical and
// horizontal ratio (i.e. a percentage value in the 0.0-1.0 range). This ratio
// gives the user selected fixed point by multiplying the bound element width
// with fixedPoint[0] and the bound element height with fixedPoint[1] to get the
// bound element-local point coordinate.
fixedPoint: FixedPoint;
}
>;
export type Arrowhead = export type Arrowhead =
| "arrow" | "arrow"
+10
View File
@@ -104,3 +104,13 @@ declare namespace jest {
toBeNonNaNNumber(): void; toBeNonNaNNumber(): void;
} }
} }
declare namespace tinycolor {
interface Instance {
_r: number;
_g: number;
_b: number;
_a: number;
_ok: boolean;
}
}
+1 -2
View File
@@ -230,8 +230,7 @@
"resetLibrary": "This will clear your library. Are you sure?", "resetLibrary": "This will clear your library. Are you sure?",
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?", "removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.", "invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!", "collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!"
"saveYourContent": "Don't forget to save your content ({{shortcut}})!"
}, },
"errors": { "errors": {
"unsupportedFileType": "Unsupported file type.", "unsupportedFileType": "Unsupported file type.",
+2
View File
@@ -63,6 +63,7 @@
"@radix-ui/react-popover": "1.0.3", "@radix-ui/react-popover": "1.0.3",
"@radix-ui/react-tabs": "1.0.2", "@radix-ui/react-tabs": "1.0.2",
"@tldraw/vec": "1.7.1", "@tldraw/vec": "1.7.1",
"@types/tinycolor2": "1.4.6",
"browser-fs-access": "0.29.1", "browser-fs-access": "0.29.1",
"canvas-roundrect-polyfill": "0.0.1", "canvas-roundrect-polyfill": "0.0.1",
"clsx": "1.1.1", "clsx": "1.1.1",
@@ -84,6 +85,7 @@
"pwacompat": "2.0.17", "pwacompat": "2.0.17",
"roughjs": "4.6.4", "roughjs": "4.6.4",
"sass": "1.51.0", "sass": "1.51.0",
"tinycolor2": "1.6.0",
"tunnel-rat": "0.1.2" "tunnel-rat": "0.1.2"
}, },
"devDependencies": { "devDependencies": {
+6 -2
View File
@@ -3,6 +3,7 @@ import type { StaticCanvasAppState, AppState } from "../types";
import type { StaticCanvasRenderConfig } from "../scene/types"; import type { StaticCanvasRenderConfig } from "../scene/types";
import { THEME, THEME_FILTER } from "../constants"; import { THEME, THEME_FILTER } from "../constants";
import { applyDarkModeFilter } from "../colors";
export const fillCircle = ( export const fillCircle = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
@@ -50,7 +51,7 @@ export const bootstrapCanvas = ({
context.scale(scale, scale); context.scale(scale, scale);
if (isExporting && theme === THEME.DARK) { if (isExporting && theme === THEME.DARK) {
context.filter = THEME_FILTER; // context.filter = THEME_FILTER;
} }
// Paint background // Paint background
@@ -64,7 +65,10 @@ export const bootstrapCanvas = ({
context.clearRect(0, 0, normalizedWidth, normalizedHeight); context.clearRect(0, 0, normalizedWidth, normalizedHeight);
} }
context.save(); context.save();
context.fillStyle = viewBackgroundColor; context.fillStyle =
theme === THEME.DARK
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor;
context.fillRect(0, 0, normalizedWidth, normalizedHeight); context.fillRect(0, 0, normalizedWidth, normalizedHeight);
context.restore(); context.restore();
} else { } else {
@@ -52,6 +52,7 @@ import {
} from "./helpers"; } from "./helpers";
import oc from "open-color"; import oc from "open-color";
import { import {
isArrowElement,
isElbowArrow, isElbowArrow,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
@@ -806,6 +807,7 @@ const _renderInteractiveScene = ({
// Elbow arrow elements cannot be selected when bound on either end // Elbow arrow elements cannot be selected when bound on either end
( (
isSingleLinearElementSelected && isSingleLinearElementSelected &&
isArrowElement(element) &&
isElbowArrow(element) && isElbowArrow(element) &&
(element.startBinding || element.endBinding) (element.startBinding || element.endBinding)
) )
+20 -7
View File
@@ -61,6 +61,7 @@ import { ShapeCache } from "../scene/ShapeCache";
import { getVerticalOffset } from "../fonts"; import { getVerticalOffset } from "../fonts";
import { isRightAngleRads } from "../../math"; import { isRightAngleRads } from "../../math";
import { getCornerRadius } from "../shapes"; import { getCornerRadius } from "../shapes";
import { applyDarkModeFilter } from "../colors";
// using a stronger invert (100% vs our regular 93%) and saturate // using a stronger invert (100% vs our regular 93%) and saturate
// as a temp hack to make images in dark theme look closer to original // as a temp hack to make images in dark theme look closer to original
@@ -247,9 +248,9 @@ const generateElementCanvas = (
const rc = rough.canvas(canvas); const rc = rough.canvas(canvas);
// in dark theme, revert the image color filter // in dark theme, revert the image color filter
if (shouldResetImageFilter(element, renderConfig, appState)) { // if (shouldResetImageFilter(element, renderConfig, appState)) {
context.filter = IMAGE_INVERT_FILTER; // context.filter = IMAGE_INVERT_FILTER;
} // }
drawElementOnCanvas(element, rc, context, renderConfig, appState); drawElementOnCanvas(element, rc, context, renderConfig, appState);
@@ -403,7 +404,10 @@ const drawElementOnCanvas = (
case "freedraw": { case "freedraw": {
// Draw directly to canvas // Draw directly to canvas
context.save(); context.save();
context.fillStyle = element.strokeColor; context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
const path = getFreeDrawPath2D(element) as Path2D; const path = getFreeDrawPath2D(element) as Path2D;
const fillShape = ShapeCache.get(element); const fillShape = ShapeCache.get(element);
@@ -412,7 +416,10 @@ const drawElementOnCanvas = (
rc.draw(fillShape); rc.draw(fillShape);
} }
context.fillStyle = element.strokeColor; context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.fill(path); context.fill(path);
context.restore(); context.restore();
@@ -458,7 +465,10 @@ const drawElementOnCanvas = (
context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr"); context.canvas.setAttribute("dir", rtl ? "rtl" : "ltr");
context.save(); context.save();
context.font = getFontString(element); context.font = getFontString(element);
context.fillStyle = element.strokeColor; context.fillStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor;
context.textAlign = element.textAlign as CanvasTextAlign; context.textAlign = element.textAlign as CanvasTextAlign;
// Canvas does not support multiline text by default // Canvas does not support multiline text by default
@@ -699,7 +709,10 @@ export const renderElement = (
context.fillStyle = "rgba(0, 0, 200, 0.04)"; context.fillStyle = "rgba(0, 0, 200, 0.04)";
context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value; context.lineWidth = FRAME_STYLE.strokeWidth / appState.zoom.value;
context.strokeStyle = FRAME_STYLE.strokeColor; context.strokeStyle =
appState.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: FRAME_STYLE.strokeColor;
// TODO change later to only affect AI frames // TODO change later to only affect AI frames
if (isMagicFrameElement(element)) { if (isMagicFrameElement(element)) {
+21 -4
View File
@@ -5,6 +5,7 @@ import {
MAX_DECIMALS_FOR_SVG_EXPORT, MAX_DECIMALS_FOR_SVG_EXPORT,
MIME_TYPES, MIME_TYPES,
SVG_NS, SVG_NS,
THEME,
} from "../constants"; } from "../constants";
import { normalizeLink, toValidURL } from "../data/url"; import { normalizeLink, toValidURL } from "../data/url";
import { getElementAbsoluteCoords } from "../element"; import { getElementAbsoluteCoords } from "../element";
@@ -37,6 +38,7 @@ import { getFontFamilyString, isRTL, isTestEnv } from "../utils";
import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement"; import { getFreeDrawSvgPath, IMAGE_INVERT_FILTER } from "./renderElement";
import { getVerticalOffset } from "../fonts"; import { getVerticalOffset } from "../fonts";
import { getCornerRadius, isPathALoop } from "../shapes"; import { getCornerRadius, isPathALoop } from "../shapes";
import { applyDarkModeFilter } from "../colors";
const roughSVGDrawWithPrecision = ( const roughSVGDrawWithPrecision = (
rsvg: RoughSVG, rsvg: RoughSVG,
@@ -139,7 +141,7 @@ const renderElementToSvg = (
case "rectangle": case "rectangle":
case "diamond": case "diamond":
case "ellipse": { case "ellipse": {
const shape = ShapeCache.generateElementShape(element, null); const shape = ShapeCache.generateElementShape(element, renderConfig);
const node = roughSVGDrawWithPrecision( const node = roughSVGDrawWithPrecision(
rsvg, rsvg,
shape, shape,
@@ -389,7 +391,12 @@ const renderElementToSvg = (
); );
node.setAttribute("stroke", "none"); node.setAttribute("stroke", "none");
const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path"); const path = svgRoot.ownerDocument!.createElementNS(SVG_NS, "path");
path.setAttribute("fill", element.strokeColor); path.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
path.setAttribute("d", getFreeDrawSvgPath(element)); path.setAttribute("d", getFreeDrawSvgPath(element));
node.appendChild(path); node.appendChild(path);
@@ -526,7 +533,12 @@ const renderElementToSvg = (
rect.setAttribute("ry", FRAME_STYLE.radius.toString()); rect.setAttribute("ry", FRAME_STYLE.radius.toString());
rect.setAttribute("fill", "none"); rect.setAttribute("fill", "none");
rect.setAttribute("stroke", FRAME_STYLE.strokeColor); rect.setAttribute(
"stroke",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(FRAME_STYLE.strokeColor)
: FRAME_STYLE.strokeColor,
);
rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString()); rect.setAttribute("stroke-width", FRAME_STYLE.strokeWidth.toString());
addToRoot(rect, element); addToRoot(rect, element);
@@ -577,7 +589,12 @@ const renderElementToSvg = (
text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`); text.setAttribute("y", `${i * lineHeightPx + verticalOffset}`);
text.setAttribute("font-family", getFontFamilyString(element)); text.setAttribute("font-family", getFontFamilyString(element));
text.setAttribute("font-size", `${element.fontSize}px`); text.setAttribute("font-size", `${element.fontSize}px`);
text.setAttribute("fill", element.strokeColor); text.setAttribute(
"fill",
renderConfig.theme === THEME.DARK
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
);
text.setAttribute("text-anchor", textAnchor); text.setAttribute("text-anchor", textAnchor);
text.setAttribute("style", "white-space: pre;"); text.setAttribute("style", "white-space: pre;");
text.setAttribute("direction", direction); text.setAttribute("direction", direction);
+34 -10
View File
@@ -13,7 +13,7 @@ import type {
import { generateFreeDrawShape } from "../renderer/renderElement"; import { generateFreeDrawShape } from "../renderer/renderElement";
import { isTransparent, assertNever } from "../utils"; import { isTransparent, assertNever } from "../utils";
import { simplify } from "points-on-curve"; import { simplify } from "points-on-curve";
import { ROUGHNESS } from "../constants"; import { ROUGHNESS, THEME } from "../constants";
import { import {
isElbowArrow, isElbowArrow,
isEmbeddableElement, isEmbeddableElement,
@@ -22,7 +22,7 @@ import {
isLinearElement, isLinearElement,
} from "../element/typeChecks"; } from "../element/typeChecks";
import { canChangeRoundness } from "./comparisons"; import { canChangeRoundness } from "./comparisons";
import type { EmbedsValidationStatus } from "../types"; import type { AppState, EmbedsValidationStatus } from "../types";
import { import {
point, point,
pointDistance, pointDistance,
@@ -30,6 +30,7 @@ import {
type LocalPoint, type LocalPoint,
} from "../../math"; } from "../../math";
import { getCornerRadius, isPathALoop } from "../shapes"; import { getCornerRadius, isPathALoop } from "../shapes";
import { applyDarkModeFilter } from "../colors";
const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth]; const getDashArrayDashed = (strokeWidth: number) => [8, 8 + strokeWidth];
@@ -61,6 +62,7 @@ function adjustRoughness(element: ExcalidrawElement): number {
export const generateRoughOptions = ( export const generateRoughOptions = (
element: ExcalidrawElement, element: ExcalidrawElement,
continuousPath = false, continuousPath = false,
isDarkMode: boolean = false,
): Options => { ): Options => {
const options: Options = { const options: Options = {
seed: element.seed, seed: element.seed,
@@ -85,7 +87,9 @@ export const generateRoughOptions = (
fillWeight: element.strokeWidth / 2, fillWeight: element.strokeWidth / 2,
hachureGap: element.strokeWidth * 4, hachureGap: element.strokeWidth * 4,
roughness: adjustRoughness(element), roughness: adjustRoughness(element),
stroke: element.strokeColor, stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
preserveVertices: preserveVertices:
continuousPath || element.roughness < ROUGHNESS.cartoonist, continuousPath || element.roughness < ROUGHNESS.cartoonist,
}; };
@@ -99,6 +103,8 @@ export const generateRoughOptions = (
options.fillStyle = element.fillStyle; options.fillStyle = element.fillStyle;
options.fill = isTransparent(element.backgroundColor) options.fill = isTransparent(element.backgroundColor)
? undefined ? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor; : element.backgroundColor;
if (element.type === "ellipse") { if (element.type === "ellipse") {
options.curveFitting = 1; options.curveFitting = 1;
@@ -112,6 +118,8 @@ export const generateRoughOptions = (
options.fill = options.fill =
element.backgroundColor === "transparent" element.backgroundColor === "transparent"
? undefined ? undefined
: isDarkMode
? applyDarkModeFilter(element.backgroundColor)
: element.backgroundColor; : element.backgroundColor;
} }
return options; return options;
@@ -165,6 +173,7 @@ const getArrowheadShapes = (
generator: RoughGenerator, generator: RoughGenerator,
options: Options, options: Options,
canvasBackgroundColor: string, canvasBackgroundColor: string,
isDarkMode: boolean,
) => { ) => {
const arrowheadPoints = getArrowheadPoints( const arrowheadPoints = getArrowheadPoints(
element, element,
@@ -192,10 +201,14 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "circle_outline" arrowhead === "circle_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor, : element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
stroke: element.strokeColor, stroke: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor,
roughness: Math.min(0.5, options.roughness || 0), roughness: Math.min(0.5, options.roughness || 0),
}), }),
]; ];
@@ -220,6 +233,8 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "triangle_outline" arrowhead === "triangle_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor, : element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0), roughness: Math.min(1, options.roughness || 0),
@@ -248,6 +263,8 @@ const getArrowheadShapes = (
fill: fill:
arrowhead === "diamond_outline" arrowhead === "diamond_outline"
? canvasBackgroundColor ? canvasBackgroundColor
: isDarkMode
? applyDarkModeFilter(element.strokeColor)
: element.strokeColor, : element.strokeColor,
fillStyle: "solid", fillStyle: "solid",
roughness: Math.min(1, options.roughness || 0), roughness: Math.min(1, options.roughness || 0),
@@ -291,12 +308,15 @@ export const _generateElementShape = (
isExporting, isExporting,
canvasBackgroundColor, canvasBackgroundColor,
embedsValidationStatus, embedsValidationStatus,
theme,
}: { }: {
isExporting: boolean; isExporting: boolean;
canvasBackgroundColor: string; canvasBackgroundColor: string;
embedsValidationStatus: EmbedsValidationStatus | null; embedsValidationStatus: EmbedsValidationStatus | null;
theme: AppState["theme"];
}, },
): Drawable | Drawable[] | null => { ): Drawable | Drawable[] | null => {
const isDarkMode = theme === THEME.DARK;
switch (element.type) { switch (element.type) {
case "rectangle": case "rectangle":
case "iframe": case "iframe":
@@ -322,6 +342,7 @@ export const _generateElementShape = (
embedsValidationStatus, embedsValidationStatus,
), ),
true, true,
isDarkMode,
), ),
); );
} else { } else {
@@ -337,6 +358,7 @@ export const _generateElementShape = (
embedsValidationStatus, embedsValidationStatus,
), ),
false, false,
isDarkMode,
), ),
); );
} }
@@ -374,7 +396,7 @@ export const _generateElementShape = (
C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${ C ${topX} ${topY}, ${topX} ${topY}, ${topX + verticalRadius} ${
topY + horizontalRadius topY + horizontalRadius
}`, }`,
generateRoughOptions(element, true), generateRoughOptions(element, true, isDarkMode),
); );
} else { } else {
shape = generator.polygon( shape = generator.polygon(
@@ -384,7 +406,7 @@ export const _generateElementShape = (
[bottomX, bottomY], [bottomX, bottomY],
[leftX, leftY], [leftX, leftY],
], ],
generateRoughOptions(element), generateRoughOptions(element, undefined, isDarkMode),
); );
} }
return shape; return shape;
@@ -395,14 +417,14 @@ export const _generateElementShape = (
element.height / 2, element.height / 2,
element.width, element.width,
element.height, element.height,
generateRoughOptions(element), generateRoughOptions(element, undefined, isDarkMode),
); );
return shape; return shape;
} }
case "line": case "line":
case "arrow": { case "arrow": {
let shape: ElementShapes[typeof element.type]; let shape: ElementShapes[typeof element.type];
const options = generateRoughOptions(element); const options = generateRoughOptions(element, undefined, isDarkMode);
// points array can be empty in the beginning, so it is important to add // points array can be empty in the beginning, so it is important to add
// initial position to it // initial position to it
@@ -414,7 +436,7 @@ export const _generateElementShape = (
shape = [ shape = [
generator.path( generator.path(
generateElbowArrowShape(points, 16), generateElbowArrowShape(points, 16),
generateRoughOptions(element, true), generateRoughOptions(element, true, isDarkMode),
), ),
]; ];
} else if (!element.roundness) { } else if (!element.roundness) {
@@ -446,6 +468,7 @@ export const _generateElementShape = (
generator, generator,
options, options,
canvasBackgroundColor, canvasBackgroundColor,
isDarkMode,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@@ -463,6 +486,7 @@ export const _generateElementShape = (
generator, generator,
options, options,
canvasBackgroundColor, canvasBackgroundColor,
isDarkMode,
); );
shape.push(...shapes); shape.push(...shapes);
} }
@@ -477,7 +501,7 @@ export const _generateElementShape = (
// generate rough polygon to fill freedraw shape // generate rough polygon to fill freedraw shape
const simplifiedPoints = simplify(element.points, 0.75); const simplifiedPoints = simplify(element.points, 0.75);
shape = generator.curve(simplifiedPoints as [number, number][], { shape = generator.curve(simplifiedPoints as [number, number][], {
...generateRoughOptions(element), ...generateRoughOptions(element, undefined, isDarkMode),
stroke: "none", stroke: "none",
}); });
} else { } else {
+3
View File
@@ -9,6 +9,7 @@ import { _generateElementShape } from "./Shape";
import type { ElementShape, ElementShapes } from "./types"; import type { ElementShape, ElementShapes } from "./types";
import { COLOR_PALETTE } from "../colors"; import { COLOR_PALETTE } from "../colors";
import type { AppState, EmbedsValidationStatus } from "../types"; import type { AppState, EmbedsValidationStatus } from "../types";
import { THEME } from "..";
export class ShapeCache { export class ShapeCache {
private static rg = new RoughGenerator(); private static rg = new RoughGenerator();
@@ -52,6 +53,7 @@ export class ShapeCache {
isExporting: boolean; isExporting: boolean;
canvasBackgroundColor: AppState["viewBackgroundColor"]; canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus; embedsValidationStatus: EmbedsValidationStatus;
theme: AppState["theme"];
} | null, } | null,
) => { ) => {
// when exporting, always regenerated to guarantee the latest shape // when exporting, always regenerated to guarantee the latest shape
@@ -74,6 +76,7 @@ export class ShapeCache {
isExporting: false, isExporting: false,
canvasBackgroundColor: COLOR_PALETTE.white, canvasBackgroundColor: COLOR_PALETTE.white,
embedsValidationStatus: null, embedsValidationStatus: null,
theme: THEME.LIGHT,
}, },
) as T["type"] extends keyof ElementShapes ) as T["type"] extends keyof ElementShapes
? ElementShapes[T["type"]] ? ElementShapes[T["type"]]
+15 -13
View File
@@ -40,6 +40,7 @@ import { syncInvalidIndices } from "../fractionalIndex";
import { renderStaticScene } from "../renderer/staticScene"; import { renderStaticScene } from "../renderer/staticScene";
import { Fonts } from "../fonts"; import { Fonts } from "../fonts";
import type { Font } from "../fonts/ExcalidrawFont"; import type { Font } from "../fonts/ExcalidrawFont";
import { applyDarkModeFilter } from "../colors";
const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
@@ -185,11 +186,6 @@ export const exportToCanvas = async (
exportingFrame ?? null, exportingFrame ?? null,
appState.frameRendering ?? null, appState.frameRendering ?? null,
); );
// for canvas export, don't clip if exporting a specific frame as it would
// clip the corners of the content
if (exportingFrame) {
frameRendering.clip = false;
}
const elementsForRender = prepareElementsForRender({ const elementsForRender = prepareElementsForRender({
elements, elements,
@@ -219,6 +215,8 @@ export const exportToCanvas = async (
files, files,
}); });
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
renderStaticScene({ renderStaticScene({
canvas, canvas,
rc: rough.canvas(canvas), rc: rough.canvas(canvas),
@@ -238,7 +236,7 @@ export const exportToCanvas = async (
scrollY: -minY + exportPadding, scrollY: -minY + exportPadding,
zoom: defaultAppState.zoom, zoom: defaultAppState.zoom,
shouldCacheIgnoreZoom: false, shouldCacheIgnoreZoom: false,
theme: appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT, theme,
}, },
renderConfig: { renderConfig: {
canvasBackgroundColor: viewBackgroundColor, canvasBackgroundColor: viewBackgroundColor,
@@ -249,6 +247,7 @@ export const exportToCanvas = async (
embedsValidationStatus: new Map(), embedsValidationStatus: new Map(),
elementsPendingErasure: new Set(), elementsPendingErasure: new Set(),
pendingFlowchartNodes: null, pendingFlowchartNodes: null,
theme,
}, },
}); });
@@ -335,7 +334,7 @@ export const exportToSvg = async (
svgRoot.setAttribute("width", `${width * exportScale}`); svgRoot.setAttribute("width", `${width * exportScale}`);
svgRoot.setAttribute("height", `${height * exportScale}`); svgRoot.setAttribute("height", `${height * exportScale}`);
if (exportWithDarkMode) { if (exportWithDarkMode) {
svgRoot.setAttribute("filter", THEME_FILTER); // svgRoot.setAttribute("filter", THEME_FILTER);
} }
const offsetX = -minX + exportPadding; const offsetX = -minX + exportPadding;
@@ -356,11 +355,6 @@ export const exportToSvg = async (
}) rotate(${frame.angle} ${cx} ${cy})" }) rotate(${frame.angle} ${cx} ${cy})"
width="${frame.width}" width="${frame.width}"
height="${frame.height}" height="${frame.height}"
${
exportingFrame
? ""
: `rx=${FRAME_STYLE.radius} ry=${FRAME_STYLE.radius}`
}
> >
</rect> </rect>
</clipPath>`; </clipPath>`;
@@ -386,7 +380,12 @@ export const exportToSvg = async (
rect.setAttribute("y", "0"); rect.setAttribute("y", "0");
rect.setAttribute("width", `${width}`); rect.setAttribute("width", `${width}`);
rect.setAttribute("height", `${height}`); rect.setAttribute("height", `${height}`);
rect.setAttribute("fill", viewBackgroundColor); rect.setAttribute(
"fill",
appState.exportWithDarkMode
? applyDarkModeFilter(viewBackgroundColor)
: viewBackgroundColor,
);
svgRoot.appendChild(rect); svgRoot.appendChild(rect);
} }
@@ -394,6 +393,8 @@ export const exportToSvg = async (
const renderEmbeddables = opts?.renderEmbeddables ?? false; const renderEmbeddables = opts?.renderEmbeddables ?? false;
const theme = appState.exportWithDarkMode ? THEME.DARK : THEME.LIGHT;
renderSceneToSvg( renderSceneToSvg(
elementsForRender, elementsForRender,
toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)), toBrandedType<RenderableElementsMap>(arrayToMap(elementsForRender)),
@@ -415,6 +416,7 @@ export const exportToSvg = async (
.map((element) => [element.id, true]), .map((element) => [element.id, true]),
) )
: new Map(), : new Map(),
theme,
}, },
); );
+2
View File
@@ -35,6 +35,7 @@ export type StaticCanvasRenderConfig = {
embedsValidationStatus: EmbedsValidationStatus; embedsValidationStatus: EmbedsValidationStatus;
elementsPendingErasure: ElementsPendingErasure; elementsPendingErasure: ElementsPendingErasure;
pendingFlowchartNodes: PendingExcalidrawElements | null; pendingFlowchartNodes: PendingExcalidrawElements | null;
theme: AppState["theme"];
}; };
export type SVGRenderConfig = { export type SVGRenderConfig = {
@@ -46,6 +47,7 @@ export type SVGRenderConfig = {
frameRendering: AppState["frameRendering"]; frameRendering: AppState["frameRendering"];
canvasBackgroundColor: AppState["viewBackgroundColor"]; canvasBackgroundColor: AppState["viewBackgroundColor"];
embedsValidationStatus: EmbedsValidationStatus; embedsValidationStatus: EmbedsValidationStatus;
theme: AppState["theme"];
}; };
export type InteractiveCanvasRenderConfig = { export type InteractiveCanvasRenderConfig = {
@@ -8430,7 +8430,6 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -8650,7 +8649,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -9060,7 +9058,6 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
@@ -9457,7 +9454,6 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
"selectedElementsAreBeingDragged": false, "selectedElementsAreBeingDragged": false,
"selectedGroupIds": {}, "selectedGroupIds": {},
"selectedLinearElement": LinearElementEditor { "selectedLinearElement": LinearElementEditor {
"elbowed": false,
"elementId": "id0", "elementId": "id0",
"endBindingElement": "keep", "endBindingElement": "keep",
"hoverPointIndex": -1, "hoverPointIndex": -1,
+2 -16
View File
@@ -9,8 +9,6 @@ import type {
ExcalidrawFrameElement, ExcalidrawFrameElement,
ExcalidrawElementType, ExcalidrawElementType,
ExcalidrawMagicFrameElement, ExcalidrawMagicFrameElement,
ExcalidrawElbowArrowElement,
ExcalidrawArrowElement,
} from "../../element/types"; } from "../../element/types";
import { newElement, newTextElement, newLinearElement } from "../../element"; import { newElement, newTextElement, newLinearElement } from "../../element";
import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants"; import { DEFAULT_VERTICAL_ALIGN, ROUNDNESS } from "../../constants";
@@ -129,10 +127,6 @@ export class API {
expect(API.getSelectedElements().length).toBe(0); expect(API.getSelectedElements().length).toBe(0);
}; };
static getElement = <T extends ExcalidrawElement>(element: T): T => {
return h.app.scene.getElementsMapIncludingDeleted().get(element.id) as T || element;
}
static createElement = < static createElement = <
T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle", T extends Exclude<ExcalidrawElementType, "selection"> = "rectangle",
>({ >({
@@ -185,16 +179,10 @@ export class API {
scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never; scale?: T extends "image" ? ExcalidrawImageElement["scale"] : never;
status?: T extends "image" ? ExcalidrawImageElement["status"] : never; status?: T extends "image" ? ExcalidrawImageElement["status"] : never;
startBinding?: T extends "arrow" startBinding?: T extends "arrow"
? ExcalidrawArrowElement["startBinding"] | ExcalidrawElbowArrowElement["startBinding"] ? ExcalidrawLinearElement["startBinding"]
: never; : never;
endBinding?: T extends "arrow" endBinding?: T extends "arrow"
? ExcalidrawArrowElement["endBinding"] | ExcalidrawElbowArrowElement["endBinding"] ? ExcalidrawLinearElement["endBinding"]
: never;
startArrowhead?: T extends "arrow"
? ExcalidrawArrowElement["startArrowhead"] | ExcalidrawElbowArrowElement["startArrowhead"]
: never;
endArrowhead?: T extends "arrow"
? ExcalidrawArrowElement["endArrowhead"] | ExcalidrawElbowArrowElement["endArrowhead"]
: never; : never;
elbowed?: boolean; elbowed?: boolean;
}): T extends "arrow" | "line" }): T extends "arrow" | "line"
@@ -352,8 +340,6 @@ export class API {
if (element.type === "arrow") { if (element.type === "arrow") {
element.startBinding = rest.startBinding ?? null; element.startBinding = rest.startBinding ?? null;
element.endBinding = rest.endBinding ?? null; element.endBinding = rest.endBinding ?? null;
element.startArrowhead = rest.startArrowhead ?? null;
element.endArrowhead = rest.endArrowhead ?? null;
} }
if (id) { if (id) {
element.id = id; element.id = id;
+4 -5
View File
@@ -31,7 +31,6 @@ import type {
ExcalidrawGenericElement, ExcalidrawGenericElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
ExcalidrawTextElement, ExcalidrawTextElement,
FixedPointBinding,
FractionalIndex, FractionalIndex,
SceneElementsMap, SceneElementsMap,
} from "../element/types"; } from "../element/types";
@@ -2050,13 +2049,13 @@ describe("history", () => {
focus: -0.001587301587301948, focus: -0.001587301587301948,
gap: 5, gap: 5,
fixedPoint: [1.0318471337579618, 0.49920634920634904], fixedPoint: [1.0318471337579618, 0.49920634920634904],
} as FixedPointBinding, },
endBinding: { endBinding: {
elementId: "u2JGnnmoJ0VATV4vCNJE5", elementId: "u2JGnnmoJ0VATV4vCNJE5",
focus: -0.0016129032258049847, focus: -0.0016129032258049847,
gap: 3.537079145500037, gap: 3.537079145500037,
fixedPoint: [0.4991935483870975, -0.03875193720914723], fixedPoint: [0.4991935483870975, -0.03875193720914723],
} as FixedPointBinding, },
}, },
], ],
storeAction: StoreAction.CAPTURE, storeAction: StoreAction.CAPTURE,
@@ -4456,7 +4455,7 @@ describe("history", () => {
elements: [ elements: [
h.elements[0], h.elements[0],
newElementWith(h.elements[1], { boundElements: [] }), newElementWith(h.elements[1], { boundElements: [] }),
newElementWith(h.elements[2] as ExcalidrawElbowArrowElement, { newElementWith(h.elements[2] as ExcalidrawLinearElement, {
endBinding: { endBinding: {
elementId: remoteContainer.id, elementId: remoteContainer.id,
gap: 1, gap: 1,
@@ -4656,7 +4655,7 @@ describe("history", () => {
// Simulate remote update // Simulate remote update
API.updateScene({ API.updateScene({
elements: [ elements: [
newElementWith(h.elements[0] as ExcalidrawElbowArrowElement, { newElementWith(h.elements[0] as ExcalidrawLinearElement, {
startBinding: { startBinding: {
elementId: rect1.id, elementId: rect1.id,
gap: 1, gap: 1,
+2 -57
View File
@@ -4,7 +4,6 @@ import { render } from "./test-utils";
import { reseed } from "../random"; import { reseed } from "../random";
import { UI, Keyboard, Pointer } from "./helpers/ui"; import { UI, Keyboard, Pointer } from "./helpers/ui";
import type { import type {
ExcalidrawElbowArrowElement,
ExcalidrawFreeDrawElement, ExcalidrawFreeDrawElement,
ExcalidrawLinearElement, ExcalidrawLinearElement,
} from "../element/types"; } from "../element/types";
@@ -334,62 +333,6 @@ describe("arrow element", () => {
expect(label.angle).toBeCloseTo(0); expect(label.angle).toBeCloseTo(0);
expect(label.fontSize).toEqual(20); expect(label.fontSize).toEqual(20);
}); });
it("flips the fixed point binding on negative resize for single bindable", () => {
const rectangle = UI.createElement("rectangle", {
x: -100,
y: -75,
width: 95,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-5, 0);
mouse.click();
mouse.moveTo(120, 200);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize(rectangle, "se", [-200, -150]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
});
it("flips the fixed point binding on negative resize for group selection", () => {
const rectangle = UI.createElement("rectangle", {
x: -100,
y: -75,
width: 95,
height: 100,
});
UI.clickTool("arrow");
UI.clickOnTestId("elbow-arrow");
mouse.reset();
mouse.moveTo(-5, 0);
mouse.click();
mouse.moveTo(120, 200);
mouse.click();
const arrow = h.scene.getSelectedElements(
h.state,
)[0] as ExcalidrawElbowArrowElement;
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(1.05);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.75);
UI.resize([rectangle, arrow], "nw", [300, 350]);
expect(arrow.startBinding?.fixedPoint?.[0]).toBeCloseTo(-0.144, 2);
expect(arrow.startBinding?.fixedPoint?.[1]).toBeCloseTo(0.25);
});
}); });
describe("text element", () => { describe("text element", () => {
@@ -885,6 +828,7 @@ describe("multiple selection", () => {
expect(leftBoundArrow.endBinding?.elementId).toBe( expect(leftBoundArrow.endBinding?.elementId).toBe(
leftArrowBinding.elementId, leftArrowBinding.elementId,
); );
expect(leftBoundArrow.endBinding?.fixedPoint).toBeNull();
expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus); expect(leftBoundArrow.endBinding?.focus).toBe(leftArrowBinding.focus);
expect(rightBoundArrow.x).toBeCloseTo(210); expect(rightBoundArrow.x).toBeCloseTo(210);
@@ -899,6 +843,7 @@ describe("multiple selection", () => {
expect(rightBoundArrow.endBinding?.elementId).toBe( expect(rightBoundArrow.endBinding?.elementId).toBe(
rightArrowBinding.elementId, rightArrowBinding.elementId,
); );
expect(rightBoundArrow.endBinding?.fixedPoint).toBeNull();
expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus); expect(rightBoundArrow.endBinding?.focus).toBe(rightArrowBinding.focus);
}); });
+3 -3
View File
@@ -110,8 +110,8 @@ export const debugDrawBoundingBox = (
export const debugDrawBounds = ( export const debugDrawBounds = (
box: Bounds | Bounds[], box: Bounds | Bounds[],
opts?: { opts?: {
color?: string; color: string;
permanent?: boolean; permanent: boolean;
}, },
) => { ) => {
(isBounds(box) ? [box] : box).forEach((bbox) => (isBounds(box) ? [box] : box).forEach((bbox) =>
@@ -136,7 +136,7 @@ export const debugDrawBounds = (
], ],
{ {
color: opts?.color ?? "green", color: opts?.color ?? "green",
permanent: !!opts?.permanent, permanent: opts?.permanent,
}, },
), ),
); );
+10
View File
@@ -3299,6 +3299,11 @@
dependencies: dependencies:
"@types/jest" "*" "@types/jest" "*"
"@types/tinycolor2@1.4.6":
version "1.4.6"
resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.6.tgz#670cbc0caf4e58dd61d1e3a6f26386e473087f06"
integrity sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw==
"@types/trusted-types@^2.0.2": "@types/trusted-types@^2.0.2":
version "2.0.7" version "2.0.7"
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
@@ -9976,6 +9981,11 @@ tinybench@^2.8.0:
resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b"
integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==
tinycolor2@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
tinypool@^1.0.0: tinypool@^1.0.0:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.1.tgz#c64233c4fac4304e109a64340178760116dbe1fe" resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.0.1.tgz#c64233c4fac4304e109a64340178760116dbe1fe"