Compare commits

..

2 Commits

Author SHA1 Message Date
David Luzar 2a82821ec5 fix(editor): tweak freedraw settings and tablet UI/UX (#11551)
* fix(editor): align constant freedraw stroke width more with generic shapes

* reduce streamline for non-mouse

* do not change `currentItemStrokeVariability` unintentionally

* render penMode button under compact styles panel on tablets

* show stroke variability as standalone button in compact actions menu

* improve toolbar clicking UX with pen

* change streamline defaults

* change to `variable` stroke if toggling penMode for the first time
2026-06-24 16:59:10 +02:00
Márk Tolmács 070df27e4d fix(editor): Modern TS require imports from rootDir (#11552)
---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>
2026-06-24 10:52:56 +00:00
20 changed files with 188 additions and 157 deletions
+1 -1
View File
@@ -552,4 +552,4 @@ export const MOBILE_ACTION_BUTTON_BG = {
} as const;
export const DEFAULT_STROKE_STREAMLINE = 0.5;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.3;
export const DEFAULT_STROKE_STREAMLINE_PRECISE = 0.2;
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+1 -30
View File
@@ -2,7 +2,6 @@ import {
curvePointDistance,
distanceToLineSegment,
pointRotateRads,
polygonIncludesPointNonZero,
} from "@excalidraw/math";
import { ellipse, ellipseDistanceFromPoint } from "@excalidraw/math/ellipse";
@@ -48,9 +47,8 @@ export const distanceToElement = (
return distanceToEllipseElement(element, elementsMap, p);
case "line":
case "arrow":
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
case "freedraw":
return distanceToFreeDrawElement(element, elementsMap, p);
return distanceToLinearOrFreeDraElement(element, elementsMap, p);
}
};
@@ -147,30 +145,3 @@ const distanceToLinearOrFreeDraElement = (
...curves.map((a) => curvePointDistance(a, p)),
);
};
/**
* Returns the distance of a point to a freedraw element.
*
* @param element The freedraw element
* @param p The point to consider
* @returns 0 if the point is within the inked area, otherwise the euclidean
* distance to the stroke outline
*/
const distanceToFreeDrawElement = (
element: ExcalidrawFreeDrawElement,
elementsMap: ElementsMap,
p: GlobalPoint,
) => {
const [lines] = deconstructLinearOrFreeDrawElement(element, elementsMap);
if (lines.length === 0) {
return Infinity;
}
const polygon = lines.map((line) => line[0]);
if (polygonIncludesPointNonZero(p, polygon)) {
return 0;
}
return Math.min(...lines.map((s) => distanceToLineSegment(p, s)));
};
+58 -40
View File
@@ -26,7 +26,6 @@ import {
LINE_POLYGON_POINT_MERGE_DISTANCE,
applyDarkModeFilter,
DEFAULT_STROKE_STREAMLINE,
DEFAULT_STROKE_STREAMLINE_PRECISE,
} from "@excalidraw/common";
import { RoughGenerator } from "roughjs/bin/generator";
@@ -677,45 +676,69 @@ export const generateLinearCollisionShape = (
});
}
case "freedraw": {
const outlinePoints = getFreedrawOutlinePoints(element);
if (outlinePoints.length < 2) {
if (element.points.length < 2) {
return [];
}
const collisionOutline =
element.strokeOptions?.variability === "constant" &&
CONSTANT_WIDTH_FREEDRAW.STREAMLINE > 0
? (simplify(
outlinePoints as Mutable<LocalPoint[]>,
CONSTANT_WIDTH_FREEDRAW.STREAMLINE,
) as [number, number][])
: outlinePoints;
const simplifiedPoints = simplify(
element.points as Mutable<LocalPoint[]>,
0.75,
);
if (collisionOutline.length < 2) {
return [];
}
return generator
.curve(simplifiedPoints as [number, number][], options)
.sets[0].ops.slice(0, element.points.length)
.map((op, i) => {
if (i === 0) {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
);
// Close the outline polygon so its boundary never has a gap at the seam.
const [firstX, firstY] = collisionOutline[0];
const [lastX, lastY] = collisionOutline[collisionOutline.length - 1];
const closedOutline =
firstX === lastX && firstY === lastY
? collisionOutline
: [...collisionOutline, collisionOutline[0]];
return {
op: "move",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
}
return closedOutline.map((point, idx) => {
const p = pointRotateRads<GlobalPoint>(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return {
op: idx === 0 ? "move" : "lineTo",
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
};
});
return {
op: "bcurveTo",
data: [
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[0],
element.y + op.data[1],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[2],
element.y + op.data[3],
),
center,
element.angle,
),
pointRotateRads(
pointFrom<GlobalPoint>(
element.x + op.data[4],
element.y + op.data[5],
),
center,
element.angle,
),
]
.map((p) =>
pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y),
)
.flat(),
};
});
}
}
};
@@ -1162,20 +1185,15 @@ const VARIABLE_WIDTH_FREEDRAW = {
SIZE_FACTOR: 4.25,
THINNING: 0.6,
SMOOTHING: 0.5,
STREAMLINE: DEFAULT_STROKE_STREAMLINE,
} as const;
const CONSTANT_WIDTH_FREEDRAW = {
/** Stroke size relative to `strokeWidth` for uniform (laser) strokes. */
SIZE_FACTOR: 1.4,
STREAMLINE: DEFAULT_STROKE_STREAMLINE_PRECISE,
} as const;
const getFreedrawStreamline = (element: ExcalidrawFreeDrawElement) =>
element.strokeOptions?.streamline ??
(element.strokeOptions?.variability === "constant"
? CONSTANT_WIDTH_FREEDRAW.STREAMLINE
: VARIABLE_WIDTH_FREEDRAW.STREAMLINE);
element.strokeOptions?.streamline ?? DEFAULT_STROKE_STREAMLINE;
/**
* Pressure-sensitive (variable width) freedraw outline, rendered with
-51
View File
@@ -218,54 +218,3 @@ describe("hitElementItself cache", () => {
).toBe(true);
});
});
describe("freedraw collision matches the rendered stroke width", () => {
// A straight, horizontal stroke centered on y === 0.
const points = Array.from({ length: 21 }, (_, i) =>
pointFrom<LocalPoint>(i * 5, 0),
);
const createFreeDraw = (variability: "variable" | "constant") =>
API.createElement({
type: "freedraw",
x: 0,
y: 0,
strokeWidth: 10,
points,
strokeOptions: { variability, streamline: 0.5 },
});
const distanceAt = (
element: ReturnType<typeof createFreeDraw>,
x: number,
y: number,
) =>
distance.distanceToElement(
element,
arrayToMap([element]),
pointFrom<GlobalPoint>(x, y),
);
it("treats a point on the centerline as a direct hit for both modes", () => {
expect(distanceAt(createFreeDraw("variable"), 50, 0)).toBe(0);
expect(distanceAt(createFreeDraw("constant"), 50, 0)).toBe(0);
});
it("hits across the body of a thick stroke, not just near the centerline", () => {
expect(distanceAt(createFreeDraw("variable"), 50, 20)).toBe(0);
});
it("uses a wider hit area for variable-width than for constant-width strokes", () => {
const offset = 20;
const variableDistance = distanceAt(createFreeDraw("variable"), 50, offset);
const constantDistance = distanceAt(createFreeDraw("constant"), 50, offset);
expect(variableDistance).toBe(0);
expect(constantDistance).toBeGreaterThan(0);
expect(variableDistance).toBeLessThan(constantDistance);
});
it("does not hit points clearly outside even the widest stroke", () => {
expect(distanceAt(createFreeDraw("variable"), 50, 45)).toBeGreaterThan(0);
});
});
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
@@ -112,9 +112,6 @@ export const actionClearCanvas = register({
theme: appState.theme,
penMode: appState.penMode,
penDetected: appState.penDetected,
currentItemStrokeVariability: appState.penDetected
? "variable"
: "constant",
exportBackground: appState.exportBackground,
exportEmbedScene: appState.exportEmbedScene,
gridSize: appState.gridSize,
@@ -87,6 +87,7 @@ import type { CaptureUpdateActionType } from "@excalidraw/element";
import { trackEvent } from "../analytics";
import { RadioSelection } from "../components/RadioSelection";
import { ToolButton } from "../components/ToolButton";
import { ColorPicker } from "../components/ColorPicker/ColorPicker";
import { FontPicker } from "../components/FontPicker/FontPicker";
import { IconPicker } from "../components/IconPicker";
@@ -718,6 +719,25 @@ export const actionChangeFreedrawMode = register<StrokeVariability>({
hasSelection ? null : appState.currentItemStrokeVariability,
) ?? appState.currentItemStrokeVariability;
// in the compact UI the pressure setting is rendered as a single button
// that cycles between the two variability modes on click
if (data?.cycle) {
const isVariable = strokeVariability === "variable";
return (
<ToolButton
type="button"
icon={
isVariable
? strokeVariabilityVariableIcon
: strokeVariabilityConstantIcon
}
title={t("labels.pressure")}
aria-label={t("labels.pressure")}
onClick={() => updateData(isVariable ? "constant" : "variable")}
/>
);
}
return (
<fieldset>
<legend>{t("labels.pressure")}</legend>
+42 -13
View File
@@ -395,11 +395,17 @@ const CombinedShapeProperties = ({
hasStrokeWidth(element.type),
)) &&
renderAction("changeStrokeWidth")}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) =>
hasFreedrawMode(element.type),
)) &&
renderAction("changeFreedrawMode")}
{
/* in compact UI the freedraw pressure setting is rendered as a
standalone cycle button in the compact actions list; we render
it in the combined properties popup as well for clarity
*/
(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) =>
hasFreedrawMode(element.type),
)) &&
renderAction("changeFreedrawMode")
}
{(hasStrokeStyle(appState.activeTool.type) ||
targetElements.some((element) =>
hasStrokeStyle(element.type),
@@ -832,6 +838,14 @@ export const CompactShapeActions = ({
</div>
)}
{/* Freedraw pressure: standalone button cycling the variability mode */}
{(hasFreedrawMode(appState.activeTool.type) ||
targetElements.some((element) => hasFreedrawMode(element.type))) && (
<div className="compact-action-item">
{renderAction("changeFreedrawMode", { cycle: true })}
</div>
)}
<CombinedShapeProperties
appState={appState}
renderAction={renderAction}
@@ -1060,6 +1074,11 @@ export const ShapesSwitcher = ({
const isFullStylesPanel = stylesPanelMode === "full";
const isCompactStylesPanel = stylesPanelMode === "compact";
// a pen detected on a tool button's pointer-down, to be applied (enabling
// pen mode) only after the tap's `change` has committed — see the tool
// button handlers below
const pendingPenDetectionRef = useRef(false);
const SELECTION_TOOLS = [
{
type: "selection",
@@ -1158,8 +1177,13 @@ export const ShapesSwitcher = ({
aria-keyshortcuts={shortcut}
data-testid={`toolbar-${value}`}
onPointerDown={({ pointerType }) => {
// Detect the pen here (pointerType is reliable on pointer-down)
// but DON'T enable pen mode yet: calling setState mid-gesture
// re-renders the controlled radio and, on iOS/iPadOS, aborts
// the ensuing click so the tool isn't selected on the first pen
// tap. Defer it until the tap's `change` has committed (below).
if (!app.state.penDetected && pointerType === "pen") {
app.togglePenMode(true);
pendingPenDetectionRef.current = true;
}
if (value === "selection") {
@@ -1170,16 +1194,21 @@ export const ShapesSwitcher = ({
}
}
}}
onChange={({ pointerType }) => {
onChange={() => {
if (app.state.activeTool.type !== value) {
trackEvent("toolbar", value, "ui");
}
if (value === "image") {
app.setActiveTool({
type: value,
});
} else {
app.setActiveTool({ type: value });
app.setActiveTool({ type: value });
// Apply the pen detection captured on pointer-down now that the
// tool is selected. rAF keeps the resulting re-render out of the
// `change` event itself. We rely on the pointer-down detection
// rather than this handler's pointerType because the latter is
// unreliable on iOS (its backing ref is cleared before the
// delayed click fires).
if (pendingPenDetectionRef.current) {
pendingPenDetectionRef.current = false;
requestAnimationFrame(() => app.togglePenMode(true));
}
}}
/>
+4 -2
View File
@@ -4308,7 +4308,9 @@ class App extends React.Component<AppProps, AppState> {
return {
penMode: force ?? !prevState.penMode,
penDetected: true,
currentItemStrokeVariability: "variable",
currentItemStrokeVariability: !prevState.penDetected
? "variable"
: prevState.currentItemStrokeVariability,
};
});
};
@@ -9015,7 +9017,7 @@ class App extends React.Component<AppProps, AppState> {
strokeOptions: {
variability: strokeVariability,
streamline:
strokeVariability === "constant" && event.pointerType !== "mouse"
event.pointerType !== "mouse"
? DEFAULT_STROKE_STREAMLINE_PRECISE
: DEFAULT_STROKE_STREAMLINE,
},
@@ -120,6 +120,24 @@
}
}
// on tablet, the pen mode button is rendered as a separate floating button
// below the compact actions menu (see LayerUI.tsx)
.App-menu_top__left > .ToolIcon__penMode {
justify-self: center;
.ToolIcon__icon {
width: var(--lg-button-size);
height: var(--lg-button-size);
background-color: var(--island-bg-color);
box-shadow: var(--shadow-island);
}
// no shadow while pen mode is active (the active fill is enough)
.ToolIcon_type_checkbox:checked + .ToolIcon__icon {
box-shadow: none;
}
}
.disable-view-mode {
display: flex;
justify-content: center;
+30 -10
View File
@@ -235,8 +235,6 @@ const LayerUI = ({
);
const renderSelectedShapeActions = () => {
const isCompactMode = isCompactStylesPanel;
return (
<Section
heading="selectedShapeActions"
@@ -244,7 +242,7 @@ const LayerUI = ({
"transition-left": appState.zenModeEnabled,
})}
>
{isCompactMode ? (
{isCompactStylesPanel ? (
<Island
className={clsx("compact-shape-actions-island")}
padding={0}
@@ -312,6 +310,23 @@ const LayerUI = ({
>
{shouldRenderSelectedShapeActions && renderSelectedShapeActions()}
</div>
{/* in compact UI the pen mode button lives outside the toolbar, as
a separate floating button below the compact actions menu
(same as we render it on mobile); shown alongside the compact
actions island, i.e. when a drawing tool or elements are
selected */}
{isCompactStylesPanel &&
!appState.viewModeEnabled &&
shouldRenderSelectedShapeActions && (
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
isMobile
penDetected={appState.penDetected}
/>
)}
</Stack.Col>
{!appState.viewModeEnabled &&
appState.openDialog?.name !== "elementLinkSelector" && (
@@ -343,13 +358,18 @@ const LayerUI = ({
/>
{heading}
<Stack.Row gap={spacing.toolbarInnerRowGap}>
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
{/* in compact UI the pen mode button is rendered
as a separate floating button below the compact
actions menu */}
{!isCompactStylesPanel && (
<PenModeButton
zenModeEnabled={appState.zenModeEnabled}
checked={appState.penMode}
onChange={() => onPenModeToggle(null)}
title={t("toolBar.penMode")}
penDetected={appState.penDetected}
/>
)}
<LockButton
checked={appState.activeTool.locked}
onChange={onLockToggle}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["**/*"],
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+2 -2
View File
@@ -3,12 +3,12 @@
"version": "1.3.1",
"description": "Generate outline for laser pointer tool",
"type": "module",
"types": "./dist/types/index.d.ts",
"types": "./dist/types/laser-pointer/src/index.d.ts",
"main": "./dist/prod/index.js",
"module": "./dist/prod/index.js",
"exports": {
".": {
"types": "./dist/types/index.d.ts",
"types": "./dist/types/laser-pointer/src/index.d.ts",
"development": "./dist/dev/index.js",
"production": "./dist/prod/index.js",
"default": "./dist/prod/index.js"
+2 -1
View File
@@ -1,7 +1,8 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist/types"
"outDir": "./dist/types",
"rootDir": "../"
},
"include": ["src/**/*", "global.d.ts"],
"exclude": ["**/*.test.*", "tests", "types", "examples", "dist"]
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+1 -1
View File
@@ -6,7 +6,7 @@
"declaration": true,
"allowSyntheticDefaultImports": true,
"module": "ESNext",
"moduleResolution": "Node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"emitDeclarationOnly": true,
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"rootDir": "../",
"outDir": "./dist/types"
},
"include": ["src/**/*", "global.d.ts"],
+1 -1
View File
@@ -12,7 +12,7 @@
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "ESNext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,