Compare commits

..

1 Commits

Author SHA1 Message Date
Mark Tolmacs
aa3e1341fb feat: Simplify the freedraw element at creation or restoration 2025-10-22 18:14:10 +02:00
11 changed files with 131 additions and 28 deletions

View File

@@ -23,7 +23,7 @@
]
},
"engines": {
"node": ">=18.0.0"
"node": "18.0.0 - 22.x.x"
},
"dependencies": {
"@excalidraw/random-username": "1.0.0",

View File

@@ -44,7 +44,7 @@
"vitest-canvas-mock": "0.3.3"
},
"engines": {
"node": ">=18.0.0"
"node": "18.0.0 - 22.x.x"
},
"homepage": ".",
"prettier": "@excalidraw/prettier-config",

View File

@@ -1052,15 +1052,14 @@ export function getFreeDrawSvgPath(element: ExcalidrawFreeDrawElement) {
export function getFreedrawOutlineAsSegments(
element: ExcalidrawFreeDrawElement,
points: [number, number][],
elementsMap: ElementsMap,
points: readonly [number, number][],
) {
const bounds = getElementBounds(
{
...element,
angle: 0 as Radians,
},
elementsMap,
new Map(),
);
const center = pointFrom<GlobalPoint>(
(bounds[0] + bounds[2]) / 2,

View File

@@ -10,6 +10,7 @@ import {
curveCatmullRomCubicApproxPoints,
curveOffsetPoints,
lineSegment,
perpendicularDistance,
pointDistance,
pointFrom,
pointFromArray,
@@ -481,3 +482,24 @@ export const getCornerRadius = (x: number, element: ExcalidrawElement) => {
return 0;
};
export function shouldSkipFreedrawPoint(
points: readonly LocalPoint[],
dx: number,
dy: number,
epsilon: number = 0.5,
) {
const lastPoint = points.length > 0 && points[points.length - 1];
const nextToLastPoint = points.length > 1 && points[points.length - 2];
return (
(lastPoint && lastPoint[0] === dx && lastPoint[1] === dy) ||
// NOTE: Apply a simplification algorithm to reduce number of points
(nextToLastPoint && lastPoint
? perpendicularDistance(
pointFrom<LocalPoint>(dx, dy),
lineSegment(nextToLastPoint, lastPoint),
) < epsilon
: !points[0] ||
pointDistance(points[0], pointFrom<LocalPoint>(dx, dy)) < epsilon)
);
}

View File

@@ -239,6 +239,7 @@ import {
StoreDelta,
type ApplyToOptions,
positionElementsOnGrid,
shouldSkipFreedrawPoint,
} from "@excalidraw/element";
import type { LocalPoint, Radians } from "@excalidraw/math";
@@ -8922,10 +8923,7 @@ class App extends React.Component<AppProps, AppState> {
const points = newElement.points;
const dx = pointerCoords.x - newElement.x;
const dy = pointerCoords.y - newElement.y;
const lastPoint = points.length > 0 && points[points.length - 1];
const discardPoint =
lastPoint && lastPoint[0] === dx && lastPoint[1] === dy;
const discardPoint = shouldSkipFreedrawPoint(points, dx, dy, 0.5);
if (!discardPoint) {
const pressures = newElement.simulatePressure

View File

@@ -8,7 +8,7 @@ import { atom, useAtom } from "../editor-jotai";
import { getLanguage, t } from "../i18n";
import Collapsible from "./Stats/Collapsible";
import { useDevice, useExcalidrawContainer } from "./App";
import { useDevice } from "./App";
import "./IconPicker.scss";
@@ -39,7 +39,6 @@ function Picker<T>({
numberOfOptionsToAlwaysShow?: number;
}) {
const device = useDevice();
const { container } = useExcalidrawContainer();
const handleKeyDown = (event: React.KeyboardEvent) => {
const pressedOption = options.find(
@@ -162,7 +161,6 @@ function Picker<T>({
sideOffset={isMobile ? 8 : 12}
style={{ zIndex: "var(--zIndex-ui-styles-popup)" }}
onKeyDown={handleKeyDown}
collisionBoundary={container ?? undefined}
>
<div
className={`picker`}

View File

@@ -40,8 +40,6 @@ export const PropertiesPopover = React.forwardRef<
ref,
) => {
const device = useDevice();
const isMobilePortrait =
device.editor.isMobile && !device.viewport.isLandscape;
return (
<Popover.Portal container={container}>
@@ -49,11 +47,18 @@ export const PropertiesPopover = React.forwardRef<
ref={ref}
className={clsx("focus-visible-none", className)}
data-prevent-outside-click
side={isMobilePortrait ? "bottom" : "right"}
align={isMobilePortrait ? "center" : "start"}
side={
device.editor.isMobile && !device.viewport.isLandscape
? "bottom"
: "right"
}
align={
device.editor.isMobile && !device.viewport.isLandscape
? "center"
: "start"
}
alignOffset={-16}
sideOffset={20}
collisionBoundary={container ?? undefined}
style={{
zIndex: "var(--zIndex-ui-styles-popup)",
marginLeft: device.editor.isMobile ? "0.5rem" : undefined,

View File

@@ -11,8 +11,6 @@ import { ToolButton } from "./ToolButton";
import "./ToolPopover.scss";
import { useExcalidrawContainer } from "./App";
import type { AppClassProperties } from "../types";
type ToolOption = {
@@ -52,7 +50,6 @@ export const ToolPopover = ({
const currentType = activeTool.type;
const isActive = displayedOption.type === currentType;
const SIDE_OFFSET = 32 / 2 + 10;
const { container } = useExcalidrawContainer();
// if currentType is not in options, close popup
if (!options.some((o) => o.type === currentType) && isPopupOpen) {
@@ -93,7 +90,6 @@ export const ToolPopover = ({
<Popover.Content
className="tool-popover-content"
sideOffset={SIDE_OFFSET}
collisionBoundary={container ?? undefined}
>
{options.map(({ type, icon, title }) => (
<ToolButton

View File

@@ -1,4 +1,8 @@
import { isFiniteNumber, pointFrom } from "@excalidraw/math";
import {
curveSimplifyWithRDP,
isFiniteNumber,
pointFrom,
} from "@excalidraw/math";
import {
DEFAULT_FONT_FAMILY,
@@ -18,7 +22,11 @@ import {
normalizeLink,
getLineHeight,
} from "@excalidraw/common";
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
import {
getNonDeletedElements,
isFreeDrawElement,
isValidPolygon,
} from "@excalidraw/element";
import { normalizeFixedPoint } from "@excalidraw/element";
import {
updateElbowArrowPoints,
@@ -623,6 +631,12 @@ export const restoreElements = (
(element as Mutable<ExcalidrawLinearElement>).endBinding = null;
}
}
if (isFreeDrawElement(element)) {
Object.assign(element, {
points: curveSimplifyWithRDP(element.points, 0.5),
});
}
}
// NOTE (mtolmacs): Temporary fix for extremely large arrows

View File

@@ -238,11 +238,7 @@ const eraserTest = (
// which offers a good visual precision at various zoom levels
if (isFreeDrawElement(element)) {
const outlinePoints = getFreedrawOutlinePoints(element);
const strokeSegments = getFreedrawOutlineAsSegments(
element,
outlinePoints,
elementsMap,
);
const strokeSegments = getFreedrawOutlineAsSegments(element, outlinePoints);
const tolerance = Math.max(2.25, 5 / zoom); // NOTE: Visually fine-tuned approximation
for (const seg of strokeSegments) {

View File

@@ -1,6 +1,7 @@
import { isPoint, pointDistance, pointFrom, pointFromVector } from "./point";
import { vector, vectorNormal, vectorNormalize, vectorScale } from "./vector";
import { LegendreGaussN24CValues, LegendreGaussN24TValues } from "./constants";
import { lineSegment } from "./segment";
import type { Curve, GlobalPoint, LineSegment, LocalPoint } from "./types";
@@ -520,3 +521,77 @@ export function curvePointAtLength<P extends GlobalPoint | LocalPoint>(
return bezierEquation(c, t);
}
export function perpendicularDistance<Point extends GlobalPoint | LocalPoint>(
point: Point,
[lineStart, lineEnd]: LineSegment<Point>,
): number {
const dx = lineEnd[0] - lineStart[0];
const dy = lineEnd[1] - lineStart[1];
const norm = dx * dx + dy * dy;
if (norm === 0) {
return pointDistance(point, lineStart);
}
const u =
((point[0] - lineStart[0]) * dx + (point[1] - lineStart[1]) * dy) / norm;
if (u < 0) {
return pointDistance(point, lineStart);
}
if (u > 1) {
return pointDistance(point, lineEnd);
}
const projX = lineStart[0] + u * dx;
const projY = lineStart[1] + u * dy;
return Math.sqrt(
(point[0] - projX) * (point[0] - projX) +
(point[1] - projY) * (point[1] - projY),
);
}
export function curveSimplifyWithRDP<Point extends LocalPoint | GlobalPoint>(
points: readonly Point[],
epsilon: number = 0.5,
): readonly Point[] {
if (points.length <= 2) {
return points;
}
const stack: Array<{ start: number; end: number }> = [
{ start: 0, end: points.length - 1 },
];
const keep = new Set<number>();
keep.add(0);
keep.add(points.length - 1);
while (stack.length > 0) {
const segment = stack.pop()!;
let maxDist = 0;
let maxIndex = -1;
for (let i = segment.start + 1; i < segment.end; i++) {
const dist = perpendicularDistance(
points[i],
lineSegment(points[segment.start], points[segment.end]),
);
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
if (maxDist > epsilon && maxIndex !== -1) {
keep.add(maxIndex);
stack.push({ start: segment.start, end: maxIndex });
stack.push({ start: maxIndex, end: segment.end });
}
}
return points.filter((_, index) => keep.has(index));
}