Compare commits

...

7 Commits

Author SHA1 Message Date
dwelle 7859531efe fix ts 2026-03-21 20:56:56 +01:00
dwelle 3b47fe2f87 [debug] arrow control points 2026-03-21 20:50:19 +01:00
Mark Tolmacs d73f700fa8 fix: Arrowheads
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-21 15:46:01 +01:00
Mark Tolmacs ae1195a8f2 feat: Ghost points at the end of the splines
Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-03-20 14:33:04 +00:00
David Luzar 81ab857a6f feat(editor): various text related improvements (#10979) 2026-03-19 16:00:58 +01:00
David Luzar e8b4620a96 feat(editor): put caret at pointer coords when clicking on selected text element (#10970) 2026-03-18 19:14:44 +01:00
David Luzar 2b0e4c9623 fix(editor): remove leftover debug code path (#10954) 2026-03-14 13:12:48 +01:00
28 changed files with 2772 additions and 292 deletions
+599 -17
View File
@@ -3,24 +3,41 @@ import {
CloseIcon, CloseIcon,
TrashIcon, TrashIcon,
} from "@excalidraw/excalidraw/components/icons"; } from "@excalidraw/excalidraw/components/icons";
import { useExcalidrawAPI } from "@excalidraw/excalidraw/index";
import { import {
bootstrapCanvas, bootstrapCanvas,
fillCircle,
getNormalizedCanvasDimensions, getNormalizedCanvasDimensions,
} from "@excalidraw/excalidraw/renderer/helpers"; } from "@excalidraw/excalidraw/renderer/helpers";
import { type AppState } from "@excalidraw/excalidraw/types"; import { type AppState } from "@excalidraw/excalidraw/types";
import { arrayToMap, throttleRAF } from "@excalidraw/common"; import {
import { useCallback } from "react"; arrayToMap,
sceneCoordsToViewportCoords,
throttleRAF,
viewportCoordsToSceneCoords,
} from "@excalidraw/common";
import { useCallback, useEffect, useRef, useState } from "react";
import { import {
CaptureUpdateAction,
clearSimpleArrowTangentOverride,
getGlobalFixedPointForBindableElement, getGlobalFixedPointForBindableElement,
getElementAbsoluteCoords,
getSimpleArrowCurveDebugData,
isArrowElement, isArrowElement,
isBindableElement, isBindableElement,
setSimpleArrowTangentOverride,
ShapeCache,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { import {
isLineSegment, isLineSegment,
pointFrom,
pointRotateRads,
type GlobalPoint, type GlobalPoint,
type LocalPoint,
type LineSegment, type LineSegment,
type Radians,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { isCurve } from "@excalidraw/math/curve"; import { isCurve } from "@excalidraw/math/curve";
@@ -36,11 +53,260 @@ import type {
ExcalidrawArrowElement, ExcalidrawArrowElement,
ExcalidrawBindableElement, ExcalidrawBindableElement,
FixedPointBinding, FixedPointBinding,
Ordered,
OrderedExcalidrawElement, OrderedExcalidrawElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { SimpleArrowCurveDebugData } from "@excalidraw/element";
import { STORAGE_KEYS } from "../app_constants"; import { STORAGE_KEYS } from "../app_constants";
type DebugCanvasOverlayState = Pick<
AppState,
| "width"
| "height"
| "zoom"
| "scrollX"
| "scrollY"
| "offsetLeft"
| "offsetTop"
| "selectedElementIds"
>;
type SimpleArrowHandleDirection = "incoming" | "outgoing";
type SimpleArrowHandleDescriptor = {
key: string;
elementId: string;
pointIndex: number;
direction: SimpleArrowHandleDirection;
point: GlobalPoint;
handle: GlobalPoint;
isOverridden: boolean;
title: string;
};
type SelectedSimpleArrowDebugState = {
element: ExcalidrawArrowElement;
debugData: SimpleArrowCurveDebugData<GlobalPoint>;
handles: SimpleArrowHandleDescriptor[];
};
const areSimpleArrowTangentHandlesEnabled = () =>
window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS !== false;
const pickOverlayState = (appState: AppState): DebugCanvasOverlayState => ({
width: appState.width,
height: appState.height,
zoom: appState.zoom,
scrollX: appState.scrollX,
scrollY: appState.scrollY,
offsetLeft: appState.offsetLeft,
offsetTop: appState.offsetTop,
selectedElementIds: appState.selectedElementIds,
});
const getSimpleArrowTransform = (
element: ExcalidrawArrowElement,
elementsMap: ElementsMap,
) => {
const [, , , , cx, cy] = getElementAbsoluteCoords(element, elementsMap);
const center = pointFrom<GlobalPoint>(cx, cy);
return {
pointToGlobal: (point: LocalPoint): GlobalPoint => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(element.x + point[0], element.y + point[1]),
center,
element.angle,
);
return pointFrom<GlobalPoint>(rotated[0], rotated[1]);
},
vectorToGlobal: (vector: [number, number]): [number, number] => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(vector[0], vector[1]),
pointFrom<GlobalPoint>(0, 0),
element.angle,
);
return [rotated[0], rotated[1]];
},
vectorToLocal: (vector: [number, number]): [number, number] => {
const rotated = pointRotateRads(
pointFrom<GlobalPoint>(vector[0], vector[1]),
pointFrom<GlobalPoint>(0, 0),
-element.angle as Radians,
);
return [rotated[0], rotated[1]];
},
};
};
const getSimpleArrowDebugStateForElement = (
elements: readonly OrderedExcalidrawElement[],
elementId: string,
): SelectedSimpleArrowDebugState | null => {
const element = elements.find(
(candidate): candidate is Ordered<ExcalidrawArrowElement> =>
candidate.id === elementId &&
!candidate.isDeleted &&
isArrowElement(candidate) &&
!!candidate.roundness &&
!candidate.elbowed,
);
if (!element) {
return null;
}
const elementsMap = arrayToMap(elements);
const transform = getSimpleArrowTransform(element, elementsMap);
const localDebugData = getSimpleArrowCurveDebugData(element.points, 0.5, {
elementId: element.id,
});
const debugData: SimpleArrowCurveDebugData<GlobalPoint> = {
...localDebugData,
tangents: localDebugData.tangents.map((tangent) => ({
...tangent,
point: transform.pointToGlobal(tangent.point as LocalPoint),
base: transform.vectorToGlobal(tangent.base),
autoScaled: transform.vectorToGlobal(tangent.autoScaled),
scaled: transform.vectorToGlobal(tangent.scaled),
})),
segments: localDebugData.segments.map((segment) => ({
...segment,
start: transform.pointToGlobal(segment.start as LocalPoint),
end: transform.pointToGlobal(segment.end as LocalPoint),
baseCp1: transform.pointToGlobal(segment.baseCp1 as LocalPoint),
baseCp2: transform.pointToGlobal(segment.baseCp2 as LocalPoint),
cp1: transform.pointToGlobal(segment.cp1 as LocalPoint),
cp2: transform.pointToGlobal(segment.cp2 as LocalPoint),
})),
};
const handles = debugData.tangents.flatMap((tangent, pointIndex) => {
const descriptors: SimpleArrowHandleDescriptor[] = [];
const lengthRatio =
tangent.normalized.finalLengthVsMinNeighbor === null
? "n/a"
: tangent.normalized.finalLengthVsMinNeighbor.toFixed(3);
const angleDelta = tangent.normalized.angleDelta.toFixed(3);
const title = `point ${pointIndex} · len/min ${lengthRatio} · dAngle ${angleDelta} · dblclick resets`;
if (pointIndex < debugData.tangents.length - 1) {
descriptors.push({
key: `${element.id}:${pointIndex}:out`,
elementId: element.id,
pointIndex,
direction: "outgoing",
point: tangent.point,
handle: pointFrom<GlobalPoint>(
tangent.point[0] + tangent.scaled[0] / 3,
tangent.point[1] + tangent.scaled[1] / 3,
),
isOverridden: tangent.isOverridden,
title,
});
}
if (pointIndex > 0) {
descriptors.push({
key: `${element.id}:${pointIndex}:in`,
elementId: element.id,
pointIndex,
direction: "incoming",
point: tangent.point,
handle: pointFrom<GlobalPoint>(
tangent.point[0] - tangent.scaled[0] / 3,
tangent.point[1] - tangent.scaled[1] / 3,
),
isOverridden: tangent.isOverridden,
title,
});
}
return descriptors;
});
return {
element,
debugData,
handles,
};
};
const getSelectedSimpleArrowDebugState = (
elements: readonly OrderedExcalidrawElement[],
appState: Pick<AppState, "selectedElementIds">,
) => {
const selectedIds = Object.keys(appState.selectedElementIds);
if (selectedIds.length !== 1) {
return null;
}
return getSimpleArrowDebugStateForElement(elements, selectedIds[0]);
};
const renderSelectedSimpleArrowTangentOverlay = (
context: CanvasRenderingContext2D,
zoom: number,
debugState: SelectedSimpleArrowDebugState,
) => {
context.save();
context.lineWidth = 1;
context.setLineDash([6, 4]);
context.strokeStyle = "rgba(134, 142, 150, 0.75)";
for (const segment of debugState.debugData.segments) {
context.beginPath();
context.moveTo(segment.start[0] * zoom, segment.start[1] * zoom);
context.lineTo(segment.baseCp1[0] * zoom, segment.baseCp1[1] * zoom);
context.lineTo(segment.baseCp2[0] * zoom, segment.baseCp2[1] * zoom);
context.lineTo(segment.end[0] * zoom, segment.end[1] * zoom);
context.stroke();
}
context.setLineDash([]);
for (const segment of debugState.debugData.segments) {
context.strokeStyle = segment.overshootsBaseline
? "rgba(245, 159, 0, 0.9)"
: "rgba(94, 90, 216, 0.85)";
context.beginPath();
context.moveTo(segment.start[0] * zoom, segment.start[1] * zoom);
context.lineTo(segment.cp1[0] * zoom, segment.cp1[1] * zoom);
context.lineTo(segment.cp2[0] * zoom, segment.cp2[1] * zoom);
context.lineTo(segment.end[0] * zoom, segment.end[1] * zoom);
context.stroke();
}
for (const tangent of debugState.debugData.tangents) {
if (!tangent.isAdjusted) {
continue;
}
context.strokeStyle = tangent.isOverridden
? "rgba(230, 73, 128, 0.95)"
: "rgba(201, 42, 42, 0.85)";
context.fillStyle = tangent.isOverridden
? "rgba(230, 73, 128, 0.95)"
: "rgba(201, 42, 42, 0.95)";
fillCircle(
context,
tangent.point[0] * zoom,
tangent.point[1] * zoom,
3,
true,
);
}
context.restore();
};
const renderLine = ( const renderLine = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
zoom: number, zoom: number,
@@ -359,6 +625,13 @@ const _debugRenderer = (
renderOrigin(context, appState.zoom.value); renderOrigin(context, appState.zoom.value);
renderBindings(context, elements, appState.zoom.value); renderBindings(context, elements, appState.zoom.value);
const selectedSimpleArrowDebugState = areSimpleArrowTangentHandlesEnabled()
? getSelectedSimpleArrowDebugState(elements, appState)
: null;
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW =
selectedSimpleArrowDebugState?.debugData;
if ( if (
window.visualDebug?.currentFrame && window.visualDebug?.currentFrame &&
window.visualDebug?.data && window.visualDebug?.data &&
@@ -375,6 +648,14 @@ const _debugRenderer = (
}); });
} }
if (selectedSimpleArrowDebugState) {
renderSelectedSimpleArrowTangentOverlay(
context,
appState.zoom.value,
selectedSimpleArrowDebugState,
);
}
if (window.visualDebug) { if (window.visualDebug) {
window.visualDebug!.data = window.visualDebug!.data =
window.visualDebug?.data.map((frame) => window.visualDebug?.data.map((frame) =>
@@ -542,23 +823,324 @@ interface DebugCanvasProps {
const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>( const DebugCanvas = React.forwardRef<HTMLCanvasElement, DebugCanvasProps>(
({ appState, scale }, ref) => { ({ appState, scale }, ref) => {
const { width, height } = appState; const excalidrawAPI = useExcalidrawAPI();
const [overlayState, setOverlayState] = useState<DebugCanvasOverlayState>(
() => pickOverlayState(appState),
);
const [selectedSimpleArrowDebugState, setSelectedSimpleArrowDebugState] =
useState<SelectedSimpleArrowDebugState | null>(null);
const dragStateRef = useRef<{
elementId: string;
pointIndex: number;
direction: SimpleArrowHandleDirection;
} | null>(null);
const syncSelectedSimpleArrowDebugState = useCallback(
(
nextAppState: AppState,
nextElements?: readonly OrderedExcalidrawElement[],
) => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
const elements =
nextElements ??
(excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[]);
setOverlayState(pickOverlayState(nextAppState));
const nextDebugState = areSimpleArrowTangentHandlesEnabled()
? getSelectedSimpleArrowDebugState(elements, nextAppState)
: null;
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW =
nextDebugState?.debugData;
setSelectedSimpleArrowDebugState(nextDebugState);
},
[excalidrawAPI],
);
useEffect(() => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
syncSelectedSimpleArrowDebugState(excalidrawAPI.getAppState());
const unsubscribeChange = excalidrawAPI.onChange(
(elements, nextAppState) =>
syncSelectedSimpleArrowDebugState(
nextAppState,
elements as readonly OrderedExcalidrawElement[],
),
);
const unsubscribeState = excalidrawAPI.onStateChange(
[
"selectedElementIds",
"zoom",
"scrollX",
"scrollY",
"offsetLeft",
"offsetTop",
"width",
"height",
],
(_value, nextAppState) =>
syncSelectedSimpleArrowDebugState(nextAppState),
);
return () => {
unsubscribeChange();
unsubscribeState();
};
}, [excalidrawAPI, syncSelectedSimpleArrowDebugState]);
const rerenderSceneForSimpleArrowOverride = useCallback(() => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
excalidrawAPI.updateScene({
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
captureUpdate: CaptureUpdateAction.NEVER,
});
}, [excalidrawAPI]);
const commitOverrideForHandle = useCallback(
(
handle: Pick<
SimpleArrowHandleDescriptor,
"elementId" | "pointIndex" | "direction"
>,
clientX: number,
clientY: number,
) => {
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
const nextAppState = excalidrawAPI.getAppState();
const nextElements =
excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[];
const debugState = getSimpleArrowDebugStateForElement(
nextElements,
handle.elementId,
);
if (!debugState) {
return;
}
const scenePoint = viewportCoordsToSceneCoords(
{ clientX, clientY },
nextAppState,
);
const tangent =
handle.direction === "outgoing"
? ([
(scenePoint.x -
debugState.debugData.tangents[handle.pointIndex].point[0]) *
3,
(scenePoint.y -
debugState.debugData.tangents[handle.pointIndex].point[1]) *
3,
] as [number, number])
: ([
(debugState.debugData.tangents[handle.pointIndex].point[0] -
scenePoint.x) *
3,
(debugState.debugData.tangents[handle.pointIndex].point[1] -
scenePoint.y) *
3,
] as [number, number]);
const localTangent = getSimpleArrowTransform(
debugState.element,
arrayToMap(nextElements),
).vectorToLocal(tangent);
setSimpleArrowTangentOverride(
debugState.element.id,
handle.pointIndex,
localTangent,
);
ShapeCache.delete(debugState.element);
rerenderSceneForSimpleArrowOverride();
syncSelectedSimpleArrowDebugState(nextAppState, nextElements);
},
[
excalidrawAPI,
rerenderSceneForSimpleArrowOverride,
syncSelectedSimpleArrowDebugState,
],
);
const onPointerMove = useCallback(
(event: PointerEvent) => {
if (!dragStateRef.current) {
return;
}
event.preventDefault();
commitOverrideForHandle(
dragStateRef.current,
event.clientX,
event.clientY,
);
},
[commitOverrideForHandle],
);
const onPointerUp = useCallback(
(event: PointerEvent) => {
if (!dragStateRef.current) {
return;
}
event.preventDefault();
dragStateRef.current = null;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
},
[onPointerMove],
);
useEffect(() => {
return () => {
dragStateRef.current = null;
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerUp);
};
}, [onPointerMove, onPointerUp]);
const handlePointerDown = useCallback(
(
handle: SimpleArrowHandleDescriptor,
event: React.PointerEvent<HTMLDivElement>,
) => {
event.preventDefault();
event.stopPropagation();
dragStateRef.current = {
elementId: handle.elementId,
pointIndex: handle.pointIndex,
direction: handle.direction,
};
window.addEventListener("pointermove", onPointerMove, {
passive: false,
});
window.addEventListener("pointerup", onPointerUp, {
passive: false,
});
window.addEventListener("pointercancel", onPointerUp, {
passive: false,
});
commitOverrideForHandle(handle, event.clientX, event.clientY);
},
[commitOverrideForHandle, onPointerMove, onPointerUp],
);
const resetHandleOverride = useCallback(
(
handle: SimpleArrowHandleDescriptor,
event: React.MouseEvent<HTMLDivElement>,
) => {
event.preventDefault();
event.stopPropagation();
if (!excalidrawAPI || excalidrawAPI.isDestroyed) {
return;
}
clearSimpleArrowTangentOverride(handle.elementId, handle.pointIndex);
const nextElements =
excalidrawAPI.getSceneElements() as OrderedExcalidrawElement[];
const element = nextElements.find(
(candidate): candidate is Ordered<ExcalidrawArrowElement> =>
candidate.id === handle.elementId &&
!candidate.isDeleted &&
isArrowElement(candidate),
);
if (element) {
ShapeCache.delete(element);
rerenderSceneForSimpleArrowOverride();
}
syncSelectedSimpleArrowDebugState(
excalidrawAPI.getAppState(),
nextElements,
);
},
[
excalidrawAPI,
rerenderSceneForSimpleArrowOverride,
syncSelectedSimpleArrowDebugState,
],
);
const { width, height } = overlayState;
return ( return (
<canvas <>
style={{ <canvas
width, style={{
height, width,
position: "absolute", height,
zIndex: 2, position: "absolute",
pointerEvents: "none", inset: 0,
}} zIndex: 2,
width={width * scale} pointerEvents: "none",
height={height * scale} }}
ref={ref} width={width * scale}
> height={height * scale}
Debug Canvas ref={ref}
</canvas> >
Debug Canvas
</canvas>
{selectedSimpleArrowDebugState?.handles.map((handle) => {
const { x, y } = sceneCoordsToViewportCoords(
{
sceneX: handle.handle[0],
sceneY: handle.handle[1],
},
overlayState,
);
return (
<div
key={handle.key}
title={handle.title}
onPointerDown={(event) => handlePointerDown(handle, event)}
onDoubleClick={(event) => resetHandleOverride(handle, event)}
style={{
position: "absolute",
left: x - overlayState.offsetLeft,
top: y - overlayState.offsetTop,
width: 12,
height: 12,
zIndex: 3,
borderRadius: handle.direction === "outgoing" ? "999px" : 3,
transform: "translate(-50%, -50%)",
background: handle.isOverridden
? "rgba(230, 73, 128, 0.95)"
: handle.direction === "outgoing"
? "rgba(94, 90, 216, 0.95)"
: "rgba(245, 159, 0, 0.95)",
border: "2px solid rgba(255,255,255,0.95)",
boxShadow: "0 0 0 1px rgba(0, 0, 0, 0.18)",
pointerEvents: "auto",
cursor: "grab",
}}
/>
);
})}
</>
); );
}, },
); );
@@ -1822,7 +1822,7 @@ exports[`Test Transform > should transform the elements correctly when linear el
"versionNonce": Any<Number>, "versionNonce": Any<Number>,
"verticalAlign": "middle", "verticalAlign": "middle",
"width": 120, "width": 120,
"x": 187.75450000000004, "x": 187.7545,
"y": 44.5, "y": 44.5,
} }
`; `;
+33 -19
View File
@@ -789,27 +789,41 @@ export const getArrowheadPoints = (
p0 = pointFrom(prevOp.data[4], prevOp.data[5]); p0 = pointFrom(prevOp.data[4], prevOp.data[5]);
} }
// B(t) = p0 * (1-t)^3 + 3p1 * t * (1-t)^2 + 3p2 * t^2 * (1-t) + p3 * t^3 // We know the last point of the arrow (or the first, if start arrowhead).
const equation = (t: number, idx: number) =>
Math.pow(1 - t, 3) * p3[idx] +
3 * t * Math.pow(1 - t, 2) * p2[idx] +
3 * Math.pow(t, 2) * (1 - t) * p1[idx] +
p0[idx] * Math.pow(t, 3);
// Ee know the last point of the arrow (or the first, if start arrowhead).
const [x2, y2] = position === "start" ? p0 : p3; const [x2, y2] = position === "start" ? p0 : p3;
// By using cubic bezier equation (B(t)) and the given parameters, // Use the analytic tangent at the Bézier endpoint for a precise arrowhead
// we calculate a point that is closer to the last point. // direction. For a cubic Bézier B(t) with control points p0p3:
// The value 0.3 is chosen arbitrarily and it works best for all // B'(1): (p3 p2) tangent at the end
// the tested cases. // B'(0): (p1 p0) for start arrowhead, arrow points away: (p0 p1)
const [x1, y1] = [equation(0.3, 0), equation(0.3, 1)]; let dx: number;
let dy: number;
// Find the normalized direction vector based on the if (position === "end") {
// previously calculated points. dx = p3[0] - p2[0];
const distance = Math.hypot(x2 - x1, y2 - y1); dy = p3[1] - p2[1];
const nx = (x2 - x1) / distance; if (Math.hypot(dx, dy) < 1e-6) {
const ny = (y2 - y1) / distance; dx = p3[0] - p1[0];
dy = p3[1] - p1[1];
}
if (Math.hypot(dx, dy) < 1e-6) {
dx = p3[0] - p0[0];
dy = p3[1] - p0[1];
}
} else {
dx = p0[0] - p1[0];
dy = p0[1] - p1[1];
if (Math.hypot(dx, dy) < 1e-6) {
dx = p0[0] - p2[0];
dy = p0[1] - p2[1];
}
if (Math.hypot(dx, dy) < 1e-6) {
dx = p0[0] - p3[0];
dy = p0[1] - p3[1];
}
}
const distance = Math.hypot(dx, dy);
const nx = dx / distance;
const ny = dy / distance;
const size = getArrowheadSize(arrowhead); const size = getArrowheadSize(arrowhead);
@@ -784,9 +784,20 @@ export class LinearElementEditor {
elementsMap, elementsMap,
); );
// For curved (non-elbow) arrows the quadratic spline produces N-2 arcs
// for N anchor points. Cap the loop to the actual segment count so we
// don't request a midpoint for a segment that doesn't exist.
const [lines, segCurves] = deconstructLinearOrFreeDrawElement(element);
const segmentCount = lines.length + segCurves.length;
let index = 0; let index = 0;
const midpoints: (GlobalPoint | null)[] = []; const midpoints: (GlobalPoint | null)[] = [];
while (index < points.length - 1) { while (index < points.length - 1) {
if (segmentCount > 0 && index >= segmentCount) {
midpoints.push(null);
index++;
continue;
}
if ( if (
LinearElementEditor.isSegmentTooShort( LinearElementEditor.isSegmentTooShort(
element, element,
+18
View File
@@ -11,6 +11,7 @@ import {
isBoundToContainer, isBoundToContainer,
isFrameLikeElement, isFrameLikeElement,
isLinearElement, isLinearElement,
isTextElement,
} from "./typeChecks"; } from "./typeChecks";
import { import {
elementOverlapsWithFrame, elementOverlapsWithFrame,
@@ -25,6 +26,7 @@ import type {
ElementsMap, ElementsMap,
ElementsMapOrArray, ElementsMapOrArray,
ExcalidrawElement, ExcalidrawElement,
NonDeleted,
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./types"; } from "./types";
@@ -288,3 +290,19 @@ export const getSelectionStateForElements = (
), ),
}; };
}; };
/**
* Returns editing or single-selected text element, if any.
*/
export const getActiveTextElement = (
selectedElements: readonly NonDeleted<ExcalidrawElement>[],
appState: Pick<AppState, "editingTextElement">,
) => {
const activeTextElement =
appState.editingTextElement ||
(selectedElements.length === 1 &&
isTextElement(selectedElements[0]) &&
selectedElements[0]);
return activeTextElement || null;
};
+774 -51
View File
@@ -634,60 +634,34 @@ export const generateLinearCollisionShape = (
}); });
} }
return generator // Rotate the same cubic ops used for rendering so hit-testing matches the
.curve(points as unknown as RoughPoint[], options) // visible arrow path.
.sets[0].ops.slice(0, element.points.length) const rotateLocal = (lx: number, ly: number): LocalPoint => {
.map((op, i) => { const g = pointRotateRads<GlobalPoint>(
if (i === 0) { pointFrom<GlobalPoint>(element.x + lx, element.y + ly),
const p = pointRotateRads<GlobalPoint>( center,
pointFrom<GlobalPoint>( element.angle,
element.x + op.data[0], );
element.y + op.data[1], return pointFrom<LocalPoint>(g[0] - element.x, g[1] - element.y);
), };
center,
element.angle,
);
return { return generateSimpleArrowPathOps(points, 0.5, element.id).map((op) => {
op: "move", if (op.op === "bcurveTo") {
data: pointFrom<LocalPoint>(p[0] - element.x, p[1] - element.y), const rcp1 = rotateLocal(op.data[0], op.data[1]);
}; const rcp2 = rotateLocal(op.data[2], op.data[3]);
} const rend = rotateLocal(op.data[4], op.data[5]);
return { return {
op: "bcurveTo", op: "bcurveTo",
data: [ data: [rcp1[0], rcp1[1], rcp2[0], rcp2[1], rend[0], rend[1]],
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(),
}; };
}); }
return {
op: op.op,
data: rotateLocal(op.data[0], op.data[1]),
};
});
} }
case "freedraw": { case "freedraw": {
if (element.points.length < 2) { if (element.points.length < 2) {
@@ -929,7 +903,12 @@ const _generateElementShape = (
]; ];
} }
} else { } else {
shape = [generator.curve(points as unknown as RoughPoint[], options)]; shape = [
generator.path(
generateSimpleArrowShape(points, 0.5, element.id),
generateRoughOptions(element, true, isDarkMode),
),
];
} }
// add lines only in arrow // add lines only in arrow
@@ -1013,10 +992,754 @@ const _generateElementShape = (
} }
}; };
type SimpleArrowPathOp =
| { op: "move" | "lineTo"; data: LocalPoint }
| { op: "bcurveTo"; data: [number, number, number, number, number, number] };
const SIMPLE_ARROW_OVERSHOOT_EPSILON = 0.5;
const SIMPLE_ARROW_SCALE_EPSILON = 1e-4;
const SIMPLE_ARROW_SCALE_SEARCH_STEPS = 24;
const SIMPLE_ARROW_SCALE_PASSES = 8;
type SimpleArrowVector = [number, number];
type SimpleArrowTangentOverrides = Record<
string,
Record<number, SimpleArrowVector>
>;
declare global {
interface Window {
EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENT_OVERRIDES?:
| SimpleArrowTangentOverrides
| undefined;
}
}
type SimpleArrowCurveDebugDataOptions = {
elementId?: string;
};
export type SimpleArrowCurveDebugData<
Point extends GlobalPoint | LocalPoint = LocalPoint,
> = {
elementId?: string;
tangents: Array<{
point: Point;
base: SimpleArrowVector;
autoScaled: SimpleArrowVector;
scale: number;
autoScale: number;
scaled: SimpleArrowVector;
isAdjusted: boolean;
isOverridden: boolean;
normalized: {
baseLength: number;
autoLength: number;
finalLength: number;
prevSegmentLength: number | null;
nextSegmentLength: number | null;
minNeighborLength: number | null;
finalLengthVsMinNeighbor: number | null;
autoLengthVsMinNeighbor: number | null;
angleDelta: number;
turnAngle: number | null;
};
}>;
segments: Array<{
start: Point;
end: Point;
baseCp1: Point;
baseCp2: Point;
cp1: Point;
cp2: Point;
overshootsBaseline: boolean;
overshootsResolved: boolean;
metrics: {
chordLength: number;
baseStartProjection: number;
baseEndProjection: number;
finalStartProjection: number;
finalEndProjection: number;
};
}>;
inference: {
overriddenPointIndices: number[];
};
};
const SIMPLE_ARROW_ADJUSTMENT_EPSILON = 1e-3;
const getSimpleArrowTangentOverrideStore = () => {
if (typeof window === "undefined") {
return null;
}
window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENT_OVERRIDES ??= {};
return window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENT_OVERRIDES;
};
export const setSimpleArrowTangentOverride = (
elementId: string,
pointIndex: number,
tangent: SimpleArrowVector,
) => {
const store = getSimpleArrowTangentOverrideStore();
if (!store) {
return;
}
store[elementId] = {
...(store[elementId] ?? {}),
[pointIndex]: [tangent[0], tangent[1]],
};
};
export const clearSimpleArrowTangentOverride = (
elementId: string,
pointIndex?: number,
) => {
const store = getSimpleArrowTangentOverrideStore();
if (!store?.[elementId]) {
return;
}
if (typeof pointIndex === "number") {
delete store[elementId][pointIndex];
if (Object.keys(store[elementId]).length === 0) {
delete store[elementId];
}
return;
}
delete store[elementId];
};
const getSimpleArrowTangentOverrides = (elementId?: string) => {
if (!elementId) {
return null;
}
return getSimpleArrowTangentOverrideStore()?.[elementId] ?? null;
};
const getSimpleArrowVectorLength = ([x, y]: SimpleArrowVector) =>
Math.hypot(x, y);
const normalizeSimpleArrowAngle = (angle: number) => {
let normalized = angle;
while (normalized <= -Math.PI) {
normalized += Math.PI * 2;
}
while (normalized > Math.PI) {
normalized -= Math.PI * 2;
}
return normalized;
};
const getSimpleArrowBezierValue = (
p0: number,
p1: number,
p2: number,
p3: number,
t: number,
) => {
const mt = 1 - t;
return (
mt * mt * mt * p0 +
3 * mt * mt * t * p1 +
3 * mt * t * t * p2 +
t * t * t * p3
);
};
const doesSimpleArrowSegmentOvershoot = (
startProjection: number,
endProjection: number,
segmentLength: number,
) => {
const a = -3 * startProjection + 3 * endProjection + segmentLength;
const b = 2 * (segmentLength - 2 * endProjection + startProjection);
const c = startProjection;
const candidateTs = [0, 1];
if (Math.abs(a) < 1e-8) {
if (Math.abs(b) >= 1e-8) {
candidateTs.push(-c / b);
}
} else {
const discriminant = b * b - 4 * a * c;
if (discriminant >= 0) {
const discriminantRoot = Math.sqrt(discriminant);
candidateTs.push((-b + discriminantRoot) / (2 * a));
candidateTs.push((-b - discriminantRoot) / (2 * a));
}
}
let minProjection = Infinity;
let maxProjection = -Infinity;
for (const t of candidateTs) {
if (t < 0 || t > 1) {
continue;
}
const projection = getSimpleArrowBezierValue(
0,
startProjection,
endProjection,
segmentLength,
t,
);
minProjection = Math.min(minProjection, projection);
maxProjection = Math.max(maxProjection, projection);
}
return (
minProjection < -SIMPLE_ARROW_OVERSHOOT_EPSILON ||
maxProjection > segmentLength + SIMPLE_ARROW_OVERSHOOT_EPSILON
);
};
const getSimpleArrowBaseTangents = <Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
tension: number,
): [Float64Array, Float64Array] => {
const tx = new Float64Array(points.length);
const ty = new Float64Array(points.length);
for (let i = 0; i < points.length; i++) {
if (i === 0) {
const pbx = 3 * points[0][0] - 3 * points[1][0] + points[2][0];
const pby = 3 * points[0][1] - 3 * points[1][1] + points[2][1];
tx[i] = tension * (points[1][0] - pbx);
ty[i] = tension * (points[1][1] - pby);
} else if (i === points.length - 1) {
const pax =
3 * points[points.length - 1][0] -
3 * points[points.length - 2][0] +
points[points.length - 3][0];
const pay =
3 * points[points.length - 1][1] -
3 * points[points.length - 2][1] +
points[points.length - 3][1];
tx[i] = tension * (pax - points[points.length - 2][0]);
ty[i] = tension * (pay - points[points.length - 2][1]);
} else {
tx[i] = tension * (points[i + 1][0] - points[i - 1][0]);
ty[i] = tension * (points[i + 1][1] - points[i - 1][1]);
}
}
return [tx, ty];
};
const getSimpleArrowSegmentProjections = <
Point extends GlobalPoint | LocalPoint,
>(
points: readonly Point[],
tangentX: Float64Array,
tangentY: Float64Array,
scales: Float64Array | undefined,
segmentIndex: number,
segmentScale = 1,
) => {
const start = points[segmentIndex];
const end = points[segmentIndex + 1];
const segmentDx = end[0] - start[0];
const segmentDy = end[1] - start[1];
const segmentLength = Math.hypot(segmentDx, segmentDy);
if (!segmentLength) {
return {
segmentLength,
startProjection: 0,
endProjection: 0,
};
}
const segmentUx = segmentDx / segmentLength;
const segmentUy = segmentDy / segmentLength;
const startScale = scales?.[segmentIndex] ?? 1;
const endScale = scales?.[segmentIndex + 1] ?? 1;
const startProjection =
startScale *
segmentScale *
((tangentX[segmentIndex] * segmentUx + tangentY[segmentIndex] * segmentUy) /
3);
const endProjection =
segmentLength -
endScale *
segmentScale *
((tangentX[segmentIndex + 1] * segmentUx +
tangentY[segmentIndex + 1] * segmentUy) /
3);
return {
segmentLength,
startProjection,
endProjection,
};
};
const isSimpleArrowSegmentOvershooting = <
Point extends GlobalPoint | LocalPoint,
>(
points: readonly Point[],
tangentX: Float64Array,
tangentY: Float64Array,
scales: Float64Array | undefined,
segmentIndex: number,
segmentScale = 1,
) => {
const { segmentLength, startProjection, endProjection } =
getSimpleArrowSegmentProjections(
points,
tangentX,
tangentY,
scales,
segmentIndex,
segmentScale,
);
if (!segmentLength) {
return false;
}
return doesSimpleArrowSegmentOvershoot(
startProjection,
endProjection,
segmentLength,
);
};
const getSimpleArrowSegmentScale = <Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
tx: Float64Array,
ty: Float64Array,
scales: Float64Array,
segmentIndex: number,
) => {
if (
!isSimpleArrowSegmentOvershooting(points, tx, ty, scales, segmentIndex, 1)
) {
return 1;
}
let low = 0;
let high = 1;
for (let i = 0; i < SIMPLE_ARROW_SCALE_SEARCH_STEPS; i++) {
const mid = (low + high) / 2;
if (
isSimpleArrowSegmentOvershooting(
points,
tx,
ty,
scales,
segmentIndex,
mid,
)
) {
high = mid;
} else {
low = mid;
}
}
return low;
};
const getSimpleArrowTangentScales = <Point extends GlobalPoint | LocalPoint>(
points: readonly Point[],
tx: Float64Array,
ty: Float64Array,
) => {
const scales = new Float64Array(points.length);
scales.fill(1);
for (let pass = 0; pass < SIMPLE_ARROW_SCALE_PASSES; pass++) {
const nextScales = new Float64Array(scales);
let didChange = false;
for (
let segmentIndex = 0;
segmentIndex < points.length - 1;
segmentIndex++
) {
if (
!isSimpleArrowSegmentOvershooting(points, tx, ty, scales, segmentIndex)
) {
continue;
}
const segmentScale = getSimpleArrowSegmentScale(
points,
tx,
ty,
scales,
segmentIndex,
);
const nextStartScale = scales[segmentIndex] * segmentScale;
const nextEndScale = scales[segmentIndex + 1] * segmentScale;
if (
nextStartScale <
nextScales[segmentIndex] - SIMPLE_ARROW_SCALE_EPSILON
) {
nextScales[segmentIndex] = nextStartScale;
didChange = true;
}
if (
nextEndScale <
nextScales[segmentIndex + 1] - SIMPLE_ARROW_SCALE_EPSILON
) {
nextScales[segmentIndex + 1] = nextEndScale;
didChange = true;
}
}
if (!didChange) {
return scales;
}
scales.set(nextScales);
}
return scales;
};
const getSimpleArrowFinalTangents = (
tx: Float64Array,
ty: Float64Array,
scales: Float64Array,
elementId?: string,
) => {
const finalX = new Float64Array(tx.length);
const finalY = new Float64Array(ty.length);
for (let i = 0; i < tx.length; i++) {
finalX[i] = tx[i] * scales[i];
finalY[i] = ty[i] * scales[i];
}
const overrides = getSimpleArrowTangentOverrides(elementId);
if (!overrides) {
return {
finalX,
finalY,
overriddenPointIndices: [] as number[],
};
}
const overriddenPointIndices: number[] = [];
for (const [indexKey, tangent] of Object.entries(overrides)) {
const index = Number(indexKey);
if (!Number.isInteger(index) || index < 0 || index >= finalX.length) {
continue;
}
finalX[index] = tangent[0];
finalY[index] = tangent[1];
overriddenPointIndices.push(index);
}
overriddenPointIndices.sort((a, b) => a - b);
return {
finalX,
finalY,
overriddenPointIndices,
};
};
export const getSimpleArrowCurveDebugData = <
Point extends GlobalPoint | LocalPoint,
>(
points: readonly Point[],
tension = 0.5,
options?: SimpleArrowCurveDebugDataOptions,
): SimpleArrowCurveDebugData<Point> => {
if (points.length < 2) {
return {
elementId: options?.elementId,
tangents: [],
segments: [],
inference: {
overriddenPointIndices: [],
},
};
}
if (points.length === 2) {
return {
elementId: options?.elementId,
tangents: points.map((point) => ({
point,
base: [0, 0],
autoScaled: [0, 0],
scale: 1,
autoScale: 1,
scaled: [0, 0],
isAdjusted: false,
isOverridden: false,
normalized: {
baseLength: 0,
autoLength: 0,
finalLength: 0,
prevSegmentLength: null,
nextSegmentLength: null,
minNeighborLength: null,
finalLengthVsMinNeighbor: null,
autoLengthVsMinNeighbor: null,
angleDelta: 0,
turnAngle: null,
},
})),
segments: [],
inference: {
overriddenPointIndices: [],
},
};
}
const [tx, ty] = getSimpleArrowBaseTangents(points, tension);
const scales = getSimpleArrowTangentScales(points, tx, ty);
const baselineScales = new Float64Array(points.length);
baselineScales.fill(1);
const { finalX, finalY, overriddenPointIndices } =
getSimpleArrowFinalTangents(tx, ty, scales, options?.elementId);
return {
elementId: options?.elementId,
tangents: points.map((point, index) => ({
point,
base: [tx[index], ty[index]],
autoScaled: [tx[index] * scales[index], ty[index] * scales[index]],
scale:
getSimpleArrowVectorLength([tx[index], ty[index]]) > 0
? getSimpleArrowVectorLength([finalX[index], finalY[index]]) /
getSimpleArrowVectorLength([tx[index], ty[index]])
: 1,
autoScale: scales[index],
scaled: [finalX[index], finalY[index]],
isAdjusted:
Math.abs(scales[index] - 1) > SIMPLE_ARROW_ADJUSTMENT_EPSILON ||
Math.abs(
normalizeSimpleArrowAngle(
Math.atan2(finalY[index], finalX[index]) -
Math.atan2(ty[index], tx[index]),
),
) > SIMPLE_ARROW_ADJUSTMENT_EPSILON,
isOverridden: overriddenPointIndices.includes(index),
normalized: (() => {
const base = [tx[index], ty[index]] as SimpleArrowVector;
const autoScaled = [
tx[index] * scales[index],
ty[index] * scales[index],
] as SimpleArrowVector;
const scaled = [finalX[index], finalY[index]] as SimpleArrowVector;
const baseLength = getSimpleArrowVectorLength(base);
const autoLength = getSimpleArrowVectorLength(autoScaled);
const finalLength = getSimpleArrowVectorLength(scaled);
const prevSegmentLength =
index > 0 ? pointDistance(points[index - 1], point) : null;
const nextSegmentLength =
index < points.length - 1
? pointDistance(point, points[index + 1])
: null;
const minNeighborLength =
prevSegmentLength === null
? nextSegmentLength
: nextSegmentLength === null
? prevSegmentLength
: Math.min(prevSegmentLength, nextSegmentLength);
const turnAngle =
prevSegmentLength !== null && nextSegmentLength !== null
? normalizeSimpleArrowAngle(
Math.atan2(
points[index + 1][1] - point[1],
points[index + 1][0] - point[0],
) -
Math.atan2(
point[1] - points[index - 1][1],
point[0] - points[index - 1][0],
),
)
: null;
return {
baseLength,
autoLength,
finalLength,
prevSegmentLength,
nextSegmentLength,
minNeighborLength,
finalLengthVsMinNeighbor:
minNeighborLength && minNeighborLength > 0
? finalLength / minNeighborLength
: null,
autoLengthVsMinNeighbor:
minNeighborLength && minNeighborLength > 0
? autoLength / minNeighborLength
: null,
angleDelta: normalizeSimpleArrowAngle(
Math.atan2(finalY[index], finalX[index]) -
Math.atan2(ty[index], tx[index]),
),
turnAngle,
};
})(),
})),
segments: points.slice(0, -1).map((start, index) => {
const end = points[index + 1];
const {
segmentLength: chordLength,
startProjection: baseStartProjection,
endProjection: baseEndProjection,
} = getSimpleArrowSegmentProjections(
points,
tx,
ty,
baselineScales,
index,
);
const {
startProjection: finalStartProjection,
endProjection: finalEndProjection,
} = getSimpleArrowSegmentProjections(
points,
finalX,
finalY,
undefined,
index,
);
const baseCp1 = pointFrom<Point>(
start[0] + tx[index] / 3,
start[1] + ty[index] / 3,
);
const baseCp2 = pointFrom<Point>(
end[0] - tx[index + 1] / 3,
end[1] - ty[index + 1] / 3,
);
const cp1 = pointFrom<Point>(
start[0] + finalX[index] / 3,
start[1] + finalY[index] / 3,
);
const cp2 = pointFrom<Point>(
end[0] - finalX[index + 1] / 3,
end[1] - finalY[index + 1] / 3,
);
return {
start,
end,
baseCp1,
baseCp2,
cp1,
cp2,
overshootsBaseline: isSimpleArrowSegmentOvershooting(
points,
tx,
ty,
baselineScales,
index,
),
overshootsResolved: isSimpleArrowSegmentOvershooting(
points,
finalX,
finalY,
undefined,
index,
),
metrics: {
chordLength,
baseStartProjection,
baseEndProjection,
finalStartProjection,
finalEndProjection,
},
};
}),
inference: {
overriddenPointIndices,
},
};
};
const generateSimpleArrowPathOps = (
points: readonly LocalPoint[],
tension = 0.5,
elementId?: string,
): SimpleArrowPathOp[] => {
if (points.length < 2) {
return [];
}
const ops: SimpleArrowPathOp[] = [
{
op: "move",
data: pointFrom<LocalPoint>(points[0][0], points[0][1]),
},
];
if (points.length === 2) {
ops.push({
op: "lineTo",
data: pointFrom<LocalPoint>(points[1][0], points[1][1]),
});
return ops;
}
const debugData = getSimpleArrowCurveDebugData(points, tension, {
elementId,
});
for (const segment of debugData.segments) {
const { cp1, cp2, end } = segment;
ops.push({
op: "bcurveTo",
data: [cp1[0], cp1[1], cp2[0], cp2[1], end[0], end[1]],
});
}
return ops;
};
const generateSimpleArrowShape = (
points: readonly LocalPoint[],
tension = 0.5,
elementId?: string,
): string => {
return generateSimpleArrowPathOps(points, tension, elementId)
.map((op) => {
if (op.op === "bcurveTo") {
return `C ${op.data[0]} ${op.data[1]} ${op.data[2]} ${op.data[3]} ${op.data[4]} ${op.data[5]}`;
}
return `${op.op === "move" ? "M" : "L"} ${op.data[0]} ${op.data[1]}`;
})
.join(" ");
};
const generateElbowArrowShape = ( const generateElbowArrowShape = (
points: readonly LocalPoint[], points: readonly LocalPoint[],
radius: number, radius: number,
) => { ): string => {
const subpoints = [] as [number, number][]; const subpoints = [] as [number, number][];
for (let i = 1; i < points.length - 1; i += 1) { for (let i = 1; i < points.length - 1; i += 1) {
const prev = points[i - 1]; const prev = points[i - 1];
+2 -1
View File
@@ -441,7 +441,8 @@ const VALID_CONTAINER_TYPES = new Set([
export const isValidTextContainer = (element: { export const isValidTextContainer = (element: {
type: ExcalidrawElementType; type: ExcalidrawElementType;
}) => VALID_CONTAINER_TYPES.has(element.type); }): element is ExcalidrawTextContainer =>
VALID_CONTAINER_TYPES.has(element.type);
export const computeContainerDimensionForBoundText = ( export const computeContainerDimensionForBoundText = (
dimension: number, dimension: number,
+207 -37
View File
@@ -4,6 +4,22 @@ import { charWidth, getLineWidth } from "./textMeasurements";
import type { FontString } from "./types"; import type { FontString } from "./types";
/**
* This module approximates browser-like soft wrapping for Excalidraw text.
*
* The flow is:
* 1. `parseTokens()` splits a hard line into breakable tokens using a unicode-aware regex.
* 2. `getWrappedTextLines()` reflows each hard line into one or more visual lines and
* records where each visual line came from in the source text.
* 3. `wrapLine()` assembles tokens into lines, and `wrapWord()` handles a single token
* that is wider than the available width.
* 4. `trimLine()` / `trimLineEndAtSoftBreak()` mirror browser behavior around trailing
* whitespace so the rendered text stays consistent with what users see on canvas.
*
* Mostly, you'll want to use wrapText(). getWrappedTextLines() is for callers
* that need metadata such as mapping visual lines back to `originalText`
* for caret placement or future editor features.
*/
let cachedCjkRegex: RegExp | undefined; let cachedCjkRegex: RegExp | undefined;
let cachedLineBreakRegex: RegExp | undefined; let cachedLineBreakRegex: RegExp | undefined;
let cachedEmojiRegex: RegExp | undefined; let cachedEmojiRegex: RegExp | undefined;
@@ -358,6 +374,10 @@ const Break = {
/** /**
* Breaks the line into the tokens based on the found line break opporutnities. * Breaks the line into the tokens based on the found line break opporutnities.
*
* Note: tokenization normalizes to NFC first so decomposed graphemes are treated as
* their composed variants for wrapping. Any code that needs exact source offsets should
* keep in mind that this assumes the input text is already NFC-normalized.
*/ */
export const parseTokens = (line: string) => { export const parseTokens = (line: string) => {
const breakLineRegex = getLineBreakRegex(); const breakLineRegex = getLineBreakRegex();
@@ -370,56 +390,120 @@ export const parseTokens = (line: string) => {
/** /**
* Wraps the original text into the lines based on the given width. * Wraps the original text into the lines based on the given width.
*
* This is a convenience adapter over `getWrappedTextLines()` for call sites
* that only need the rendered wrapped string and not the source offsets.
*/ */
export const wrapText = ( export const wrapText = (
text: string, text: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string => { ): string => {
return getWrappedTextLines(text, font, maxWidth)
.map((line) => line.text)
.join("\n");
};
/**
* A single rendered visual line produced from the original text.
*
* `start` and `end` are end-exclusive code-unit offsets into the original text, and do
* not include synthetic soft line breaks inserted by this module. If trailing whitespace
* was trimmed away at a wrap boundary, `end` points to the last rendered character.
*/
export type WrappedTextLine = {
text: string;
start: number;
end: number;
};
/**
* Splits only on existing hard line breaks and preserves original offsets.
*/
const getHardLineBreaks = (text: string): WrappedTextLine[] => {
let offset = 0;
return text.split("\n").map((line) => {
const start = offset;
const end = start + line.length;
offset = end + 1;
return {
text: line,
start,
end,
};
});
};
/**
* Returns the rendered visual lines together with their source offsets.
*
* This is the source-of-truth wrapping pipeline for callers that need more than the
* final wrapped string, for example caret placement or future editor/rich-text mapping.
*/
export const getWrappedTextLines = (
text: string,
font: FontString,
maxWidth: number,
): WrappedTextLine[] => {
// if maxWidth is not finite or NaN which can happen in case of bugs in // if maxWidth is not finite or NaN which can happen in case of bugs in
// computation, we need to make sure we don't continue as we'll end up // computation, we need to make sure we don't continue as we'll end up
// in an infinite loop // in an infinite loop
if (!Number.isFinite(maxWidth) || maxWidth < 0) { if (!Number.isFinite(maxWidth) || maxWidth < 0) {
return text; return getHardLineBreaks(text);
} }
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const originalLines = text.split("\n"); let offset = 0;
for (const originalLine of originalLines) { for (const originalLine of text.split("\n")) {
const currentLineWidth = getLineWidth(originalLine, font); const originalLineWidth = getLineWidth(originalLine, font);
if (currentLineWidth <= maxWidth) { if (originalLineWidth <= maxWidth) {
lines.push(originalLine); lines.push({
continue; text: originalLine,
start: offset,
end: offset + originalLine.length,
});
} else {
lines.push(...wrapLine(originalLine, font, maxWidth, offset));
} }
const wrappedLine = wrapLine(originalLine, font, maxWidth); offset += originalLine.length + 1;
lines.push(...wrappedLine);
} }
return lines.join("\n"); return lines;
}; };
/** /**
* Wraps the original line into the lines based on the given width. * Wraps a single hard line into one or more visual lines.
*
* The line-local offsets are tracked in original-text code units so
* we can map the visual line back to the source.
*/ */
const wrapLine = ( const wrapLine = (
line: string, line: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): string[] => { lineStart: number,
const lines: Array<string> = []; ): WrappedTextLine[] => {
const lines: WrappedTextLine[] = [];
const tokens = parseTokens(line); const tokens = parseTokens(line);
const tokenIterator = tokens[Symbol.iterator]();
let currentLine = ""; let currentLine = "";
let currentLineStart = lineStart;
let currentLineEnd = lineStart;
let currentLineWidth = 0; let currentLineWidth = 0;
// Tracks the next token's code-unit position in the original source string.
let tokenOffset = lineStart;
let tokenIndex = 0;
let iterator = tokenIterator.next(); while (tokenIndex < tokens.length) {
const token = tokens[tokenIndex];
while (!iterator.done) { const tokenStart = tokenOffset;
const token = iterator.value; const tokenEnd = tokenStart + token.length;
const testLine = currentLine + token; const testLine = currentLine + token;
// cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here // cache single codepoint whitespace, CJK or emoji width calc. as kerning should not apply here
@@ -429,37 +513,59 @@ const wrapLine = (
// build up the current line, skipping length check for possibly trailing whitespaces // build up the current line, skipping length check for possibly trailing whitespaces
if (/\s/.test(token) || testLineWidth <= maxWidth) { if (/\s/.test(token) || testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = tokenStart;
}
currentLine = testLine; currentLine = testLine;
currentLineEnd = tokenEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
iterator = tokenIterator.next(); tokenOffset = tokenEnd;
tokenIndex++;
continue; continue;
} }
// current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped // current line is empty => just the token (word) is longer than `maxWidth` and needs to be wrapped
if (!currentLine) { if (!currentLine) {
const wrappedWord = wrapWord(token, font, maxWidth); const wrappedWord = wrapWord(token, font, maxWidth, tokenStart);
const trailingLine = wrappedWord[wrappedWord.length - 1] ?? ""; const trailingLine = wrappedWord[wrappedWord.length - 1] ?? {
text: "",
start: tokenStart,
end: tokenStart,
};
const precedingLines = wrappedWord.slice(0, -1); const precedingLines = wrappedWord.slice(0, -1);
lines.push(...precedingLines); lines.push(...precedingLines);
// trailing line of the wrapped word might still be joined with next token/s // trailing line of the wrapped word might still be joined with next token/s
currentLine = trailingLine; currentLine = trailingLine.text;
currentLineWidth = getLineWidth(trailingLine, font); currentLineStart = trailingLine.start;
iterator = tokenIterator.next(); currentLineEnd = trailingLine.end;
currentLineWidth = getLineWidth(trailingLine.text, font);
tokenOffset = tokenEnd;
tokenIndex++;
} else { } else {
// push & reset, but don't iterate on the next token, as we didn't use it yet! // push & reset, but don't iterate on the next token, as we didn't use it yet!
lines.push(currentLine.trimEnd()); lines.push(
trimLineEndAtSoftBreak(currentLine, currentLineStart, currentLineEnd),
);
// purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above // purposefully not iterating and not setting `currentLine` to `token`, so that we could use a simple !currentLine check above
currentLine = ""; currentLine = "";
currentLineStart = tokenStart;
currentLineEnd = tokenStart;
currentLineWidth = 0; currentLineWidth = 0;
} }
} }
// iterator done, push the trailing line if exists // iterator done, push the trailing line if exists
if (currentLine) { if (currentLine) {
const trailingLine = trimLine(currentLine, font, maxWidth); const trailingLine = trimLine(
currentLine,
currentLineStart,
currentLineEnd,
font,
maxWidth,
);
lines.push(trailingLine); lines.push(trailingLine);
} }
@@ -467,59 +573,100 @@ const wrapLine = (
}; };
/** /**
* Wraps the word into the lines based on the given width. * Wraps a single word that could not be placed on an empty line as-is.
*/ */
const wrapWord = ( const wrapWord = (
word: string, word: string,
font: FontString, font: FontString,
maxWidth: number, maxWidth: number,
): Array<string> => { wordStart: number,
): WrappedTextLine[] => {
// multi-codepoint emojis are already broken apart and shouldn't be broken further // multi-codepoint emojis are already broken apart and shouldn't be broken further
if (getEmojiRegex().test(word)) { if (getEmojiRegex().test(word)) {
return [word]; return [
{
text: word,
start: wordStart,
end: wordStart + word.length,
},
];
} }
satisfiesWordInvariant(word); satisfiesWordInvariant(word);
const lines: Array<string> = []; const lines: WrappedTextLine[] = [];
const chars = Array.from(word); const chars = Array.from(word);
let currentLine = ""; let currentLine = "";
let currentLineStart = wordStart;
let currentLineEnd = wordStart;
let currentLineWidth = 0; let currentLineWidth = 0;
let offset = wordStart;
for (const char of chars) { for (const char of chars) {
const charStart = offset;
const charEnd = charStart + char.length;
const _charWidth = charWidth.calculate(char, font); const _charWidth = charWidth.calculate(char, font);
const testLineWidth = currentLineWidth + _charWidth; const testLineWidth = currentLineWidth + _charWidth;
if (testLineWidth <= maxWidth) { if (testLineWidth <= maxWidth) {
if (!currentLine) {
currentLineStart = charStart;
}
currentLine = currentLine + char; currentLine = currentLine + char;
currentLineEnd = charEnd;
currentLineWidth = testLineWidth; currentLineWidth = testLineWidth;
offset = charEnd;
continue; continue;
} }
if (currentLine) { if (currentLine) {
lines.push(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
currentLine = char; currentLine = char;
currentLineStart = charStart;
currentLineEnd = charEnd;
currentLineWidth = _charWidth; currentLineWidth = _charWidth;
offset = charEnd;
} }
if (currentLine) { if (currentLine) {
lines.push(currentLine); lines.push({
text: currentLine,
start: currentLineStart,
end: currentLineEnd,
});
} }
return lines; return lines;
}; };
/** /**
* Similarly to browsers, does not trim all trailing whitespaces, but only those exceeding the `maxWidth`. * Trims trailing whitespace that is exceeding the `maxWidth`.
*
* Used for the trailing visual line of a hard line, where some trailing
* whitespace may still be visible if it fits into the available width.
*/ */
const trimLine = (line: string, font: FontString, maxWidth: number) => { const trimLine = (
line: string,
start: number,
end: number,
font: FontString,
maxWidth: number,
): WrappedTextLine => {
const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth; const shouldTrimWhitespaces = getLineWidth(line, font) > maxWidth;
if (!shouldTrimWhitespaces) { if (!shouldTrimWhitespaces) {
return line; return {
text: line,
start,
end,
};
} }
// defensively default to `trimeEnd` in case the regex does not match // defensively default to `trimeEnd` in case the regex does not match
@@ -543,7 +690,30 @@ const trimLine = (line: string, font: FontString, maxWidth: number) => {
trimmedLineWidth = testLineWidth; trimmedLineWidth = testLineWidth;
} }
return trimmedLine; return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
};
/**
* Used for internal soft-wrap boundaries, where trailing whitespace should not
* survive into the rendered line even though it still exists in the original
* text.
*/
const trimLineEndAtSoftBreak = (
line: string,
start: number,
end: number,
): WrappedTextLine => {
const trimmedLine = line.trimEnd();
return {
text: trimmedLine,
start,
end: end - (line.length - trimmedLine.length),
};
}; };
/** /**
+4 -4
View File
@@ -135,9 +135,9 @@ describe("getElementBounds", () => {
} as ExcalidrawLinearElement; } as ExcalidrawLinearElement;
const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element])); const [x1, y1, x2, y2] = getElementBounds(element, arrayToMap([element]));
expect(x1).toEqual(360.9291017525165); expect(x1).toEqual(360.3176068760539);
expect(y1).toEqual(185.24770129343722); expect(y1).toEqual(185.90654264413516);
expect(x2).toEqual(481.4815539037601); expect(x2).toEqual(486.6924560404731);
expect(y2).toEqual(319.8162855827246); expect(y2).toEqual(320.391865303557);
}); });
}); });
@@ -434,12 +434,12 @@ describe("Test Linear Elements", () => {
expect(midPointsWithRoundEdge).toMatchInlineSnapshot(` expect(midPointsWithRoundEdge).toMatchInlineSnapshot(`
[ [
[ [
"54.27552", "53.63967",
"46.16120", "47.15774",
], ],
[ [
"76.95494", "78.65236",
"44.56052", "44.31886",
], ],
] ]
`); `);
@@ -499,12 +499,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"104.27552", "103.63967",
"66.16120", "67.15774",
], ],
[ [
"126.95494", "128.65236",
"64.56052", "64.31886",
], ],
] ]
`); `);
@@ -815,12 +815,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"29.28349", "28.64089",
"20.91105", "21.69997",
], ],
[ [
"78.86048", "82.34322",
"46.12277", "47.57759",
], ],
] ]
`); `);
@@ -904,12 +904,12 @@ describe("Test Linear Elements", () => {
expect(newMidPoints).toMatchInlineSnapshot(` expect(newMidPoints).toMatchInlineSnapshot(`
[ [
[ [
"54.27552", "53.63967",
"46.16120", "47.15774",
], ],
[ [
"76.95494", "78.65236",
"44.56052", "44.31886",
], ],
] ]
`); `);
@@ -1191,7 +1191,7 @@ describe("Test Linear Elements", () => {
20, 20,
105, 105,
80, 80,
"55.45894", "56.00000",
45, 45,
] ]
`); `);
@@ -1202,7 +1202,7 @@ describe("Test Linear Elements", () => {
.toMatchInlineSnapshot(` .toMatchInlineSnapshot(`
{ {
"height": 130, "height": 130,
"width": "366.11716", "width": "367.18528",
} }
`); `);
@@ -1214,7 +1214,7 @@ describe("Test Linear Elements", () => {
), ),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
{ {
"x": "271.11716", "x": "272.18528",
"y": 45, "y": 45,
} }
`); `);
@@ -1231,9 +1231,9 @@ describe("Test Linear Elements", () => {
[ [
20, 20,
35, 35,
"501.11716", "502.18528",
95, 95,
"205.45894", "208.69244",
"52.50000", "52.50000",
] ]
`); `);
+70 -1
View File
@@ -1,4 +1,8 @@
import { wrapText, parseTokens } from "../src/textWrapping"; import {
getWrappedTextLines,
parseTokens,
wrapText,
} from "../src/textWrapping";
import type { FontString } from "../src/types"; import type { FontString } from "../src/types";
@@ -102,6 +106,71 @@ describe("Test wrapText", () => {
expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`); expect(res2).toBe(`\tA)\none\ntab\n- two\ntabs\n- 8\nspace\ns`);
}); });
it("should retain original text offsets for wrapped lines", () => {
expect(getWrappedTextLines("Hello World!", font, 60)).toEqual([
{
text: "Hello",
start: 0,
end: 5,
},
{
text: "World!",
start: 6,
end: 12,
},
]);
});
it("should exclude whitespace trimmed away at soft-wrap boundaries from line offsets", () => {
expect(getWrappedTextLines(" Hello World", font, 90)).toEqual([
{
text: " Hello",
start: 0,
end: 7,
},
{
text: "World",
start: 9,
end: 14,
},
]);
});
it("should retain offsets when wrapping a single long token", () => {
expect(getWrappedTextLines("Excalidraw", font, 50)).toEqual([
{
text: "Excal",
start: 0,
end: 5,
},
{
text: "idraw",
start: 5,
end: 10,
},
]);
});
it("should preserve empty hard lines in metadata", () => {
expect(getWrappedTextLines("A\n\nB", font, 100)).toEqual([
{
text: "A",
start: 0,
end: 1,
},
{
text: "",
start: 2,
end: 2,
},
{
text: "B",
start: 3,
end: 4,
},
]);
});
describe("When text is CJK", () => { describe("When text is CJK", () => {
it("should break each CJK character when width is very small", () => { it("should break each CJK character when width is very small", () => {
// "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi" // "안녕하세요" (Hangul) + "こんにちは世界" (Hiragana, Kanji) + "コンニチハ" (Katakana) + "你好" (Han) = "Hello Hello World Hello Hi"
@@ -191,7 +191,7 @@ export const getFormValue = function <T extends Primitive>(
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
app: AppClassProperties, app: AppClassProperties,
getAttribute: (element: ExcalidrawElement) => T, getAttribute: (element: ExcalidrawElement) => T,
isRelevantElement: true | ((element: ExcalidrawElement) => boolean), elementPredicate: true | ((element: ExcalidrawElement) => boolean),
defaultValue: T | ((isSomeElementSelected: boolean) => T), defaultValue: T | ((isSomeElementSelected: boolean) => T),
): T { ): T {
const editingTextElement = app.state.editingTextElement; const editingTextElement = app.state.editingTextElement;
@@ -209,9 +209,9 @@ export const getFormValue = function <T extends Primitive>(
if (hasSelection) { if (hasSelection) {
const selectedElements = app.scene.getSelectedElements(app.state); const selectedElements = app.scene.getSelectedElements(app.state);
const targetElements = const targetElements =
isRelevantElement === true elementPredicate === true
? selectedElements ? selectedElements
: selectedElements.filter((el) => isRelevantElement(el)); : selectedElements.filter((el) => elementPredicate(el));
ret = ret =
reduceToCommonValue(targetElements, getAttribute) ?? reduceToCommonValue(targetElements, getAttribute) ??
@@ -730,9 +730,28 @@ export const actionChangeOpacity = register<ExcalidrawElement["opacity"]>({
captureUpdate: CaptureUpdateAction.IMMEDIATELY, captureUpdate: CaptureUpdateAction.IMMEDIATELY,
}; };
}, },
PanelComponent: ({ app, updateData }) => ( PanelComponent: ({ elements, appState, app, updateData }) => {
<Range updateData={updateData} app={app} testId="opacity" /> const opacity = getFormValue(
), elements,
app,
(element) => element.opacity,
true,
(hasSelection) => (hasSelection ? null : appState.currentItemOpacity),
);
return (
<Range
label={t("labels.opacity")}
value={opacity ?? appState.currentItemOpacity}
hasCommonValue={opacity !== null}
onChange={updateData}
min={0}
max={100}
step={10}
testId="opacity"
/>
);
},
}); });
export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>( export const actionChangeFontSize = register<ExcalidrawTextElement["fontSize"]>(
@@ -1,24 +1,24 @@
import { getFontString } from "@excalidraw/common"; import { getFontString } from "@excalidraw/common";
import { newElementWith } from "@excalidraw/element"; import { isExcalidrawElement, newElementWith } from "@excalidraw/element";
import { measureText } from "@excalidraw/element"; import { measureText } from "@excalidraw/element";
import { isTextElement } from "@excalidraw/element"; import { isTextElement } from "@excalidraw/element";
import { CaptureUpdateAction } from "@excalidraw/element"; import { CaptureUpdateAction } from "@excalidraw/element";
import type { ExcalidrawElement } from "@excalidraw/element/types";
import { getSelectedElements } from "../scene"; import { getSelectedElements } from "../scene";
import { register } from "./register"; import { register } from "./register";
import type { AppClassProperties } from "../types";
export const actionTextAutoResize = register({ export const actionTextAutoResize = register({
name: "autoResize", name: "autoResize",
label: "labels.autoResize", label: "labels.autoResize",
icon: null, icon: null,
trackEvent: { category: "element" }, trackEvent: { category: "element" },
predicate: (elements, appState, _: unknown, app: AppClassProperties) => { predicate: (elements, appState, _: unknown) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
return ( return (
selectedElements.length === 1 && selectedElements.length === 1 &&
@@ -26,13 +26,18 @@ export const actionTextAutoResize = register({
!selectedElements[0].autoResize !selectedElements[0].autoResize
); );
}, },
perform: (elements, appState, _, app) => { perform: (elements, appState, targetElement) => {
const selectedElements = getSelectedElements(elements, appState); const selectedElements = getSelectedElements(elements, appState);
const targetTextElement =
isExcalidrawElement(targetElement) && isTextElement(targetElement)
? targetElement
: (selectedElements[0] as ExcalidrawElement | undefined);
return { return {
appState, appState,
elements: elements.map((element) => { elements: elements.map((element) => {
if (element.id === selectedElements[0].id && isTextElement(element)) { if (element.id === targetTextElement?.id && isTextElement(element)) {
const metrics = measureText( const metrics = measureText(
element.originalText, element.originalText,
getFontString(element), getFontString(element),
+310 -23
View File
@@ -257,6 +257,7 @@ import {
handleFocusPointPointerUp, handleFocusPointPointerUp,
maybeHandleArrowPointlikeDrag, maybeHandleArrowPointlikeDrag,
getUncroppedWidthAndHeight, getUncroppedWidthAndHeight,
getActiveTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -416,6 +417,7 @@ import {
import { ElementCanvasButtons } from "../components/ElementCanvasButtons"; import { ElementCanvasButtons } from "../components/ElementCanvasButtons";
import { LaserTrails } from "../laser-trails"; import { LaserTrails } from "../laser-trails";
import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils"; import { withBatchedUpdates, withBatchedUpdatesThrottled } from "../reactUtils";
import { isPointHittingTextAutoResizeHandle } from "../textAutoResizeHandle";
import { textWysiwyg } from "../wysiwyg/textWysiwyg"; import { textWysiwyg } from "../wysiwyg/textWysiwyg";
import { isOverScrollBars } from "../scene/scrollbars"; import { isOverScrollBars } from "../scene/scrollbars";
@@ -684,9 +686,15 @@ class App extends React.Component<AppProps, AppState> {
lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null; lastPointerDownEvent: React.PointerEvent<HTMLElement> | null = null;
lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null = lastPointerUpEvent: React.PointerEvent<HTMLElement> | PointerEvent | null =
null; null;
// TODO this is a hack and we should ideally unify touch and pointer events
// and implement our own double click handling end-to-end (currently we're
// using a mix of native browser for click events and manual for touch -
// and browser doubleClick sucks to begin with)
lastPointerUpIsDoubleClick: boolean = false;
lastPointerMoveEvent: PointerEvent | null = null; lastPointerMoveEvent: PointerEvent | null = null;
/** current frame pointer cords */ /** current frame pointer cords */
lastPointerMoveCoords: { x: number; y: number } | null = null; lastPointerMoveCoords: { x: number; y: number } | null = null;
private lastCompletedCanvasClicks: { x: number; y: number }[] = [];
/** previous frame pointer coords */ /** previous frame pointer coords */
previousPointerMoveCoords: { x: number; y: number } | null = null; previousPointerMoveCoords: { x: number; y: number } | null = null;
lastViewportPosition = { x: 0, y: 0 }; lastViewportPosition = { x: 0, y: 0 };
@@ -1248,6 +1256,26 @@ class App extends React.Component<AppProps, AppState> {
) as NullableGridSize; ) as NullableGridSize;
}; };
private getTextCreationGridPoint = (x: number, y: number) => {
const effectiveGridSize = this.getEffectiveGridSize();
if (effectiveGridSize === null) {
return null;
}
const getTextCreationGridCoordinate = (coordinate: number) => {
const topLeftGridPoint =
Math.floor(coordinate / effectiveGridSize) * effectiveGridSize;
return topLeftGridPoint;
};
return {
x: getTextCreationGridCoordinate(x),
y: getTextCreationGridCoordinate(y),
};
};
private getHTMLIFrameElement( private getHTMLIFrameElement(
element: ExcalidrawIframeLikeElement, element: ExcalidrawIframeLikeElement,
): HTMLIFrameElement | undefined { ): HTMLIFrameElement | undefined {
@@ -1438,6 +1466,21 @@ class App extends React.Component<AppProps, AppState> {
return true; return true;
} }
private isDoubleClick = (
lastPointerEvent:
| PointerEvent
| React.PointerEvent<HTMLElement>
| undefined
| null,
currentPointerEvent: PointerEvent | React.PointerEvent<HTMLElement>,
) => {
return (
lastPointerEvent != null &&
currentPointerEvent.timeStamp - lastPointerEvent.timeStamp <=
TAP_TWICE_TIMEOUT
);
};
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
el: ExcalidrawIframeLikeElement | null, el: ExcalidrawIframeLikeElement | null,
event: React.PointerEvent<HTMLElement> | PointerEvent, event: React.PointerEvent<HTMLElement> | PointerEvent,
@@ -2321,6 +2364,7 @@ class App extends React.Component<AppProps, AppState> {
} }
handleCanvasRef={this.handleInteractiveCanvasRef} handleCanvasRef={this.handleInteractiveCanvasRef}
onContextMenu={this.handleCanvasContextMenu} onContextMenu={this.handleCanvasContextMenu}
onClick={this.handleCanvasClick}
onPointerMove={this.handleCanvasPointerMove} onPointerMove={this.handleCanvasPointerMove}
onPointerUp={this.handleCanvasPointerUp} onPointerUp={this.handleCanvasPointerUp}
onPointerCancel={this.removePointer} onPointerCancel={this.removePointer}
@@ -3574,10 +3618,14 @@ class App extends React.Component<AppProps, AppState> {
this.lassoTrail.endPath(); this.lassoTrail.endPath();
this.deselectElements(); this.deselectElements();
// @ts-ignore
this.handleCanvasDoubleClick({ this.handleCanvasDoubleClick({
clientX: touch.clientX, clientX: touch.clientX,
clientY: touch.clientY, clientY: touch.clientY,
type: "touch",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
}); });
} }
didTapTwice = false; didTapTwice = false;
@@ -5617,8 +5665,14 @@ class App extends React.Component<AppProps, AppState> {
element: ExcalidrawTextElement, element: ExcalidrawTextElement,
{ {
isExistingElement = false, isExistingElement = false,
initialCaretSceneCoords = null,
}: { }: {
isExistingElement?: boolean; isExistingElement?: boolean;
/**
* supply null if no caret positioning is desired, and instead
* text should be auto-selected
*/
initialCaretSceneCoords?: { x: number; y: number } | null;
}, },
) { ) {
const elementsMap = this.scene.getElementsMapIncludingDeleted(); const elementsMap = this.scene.getElementsMapIncludingDeleted();
@@ -5721,6 +5775,7 @@ class App extends React.Component<AppProps, AppState> {
element, element,
excalidrawContainer: this.excalidrawContainerRef.current, excalidrawContainer: this.excalidrawContainerRef.current,
app: this, app: this,
initialCaretSceneCoords,
// when text is selected, it's hard (at least on iOS) to re-position the // when text is selected, it's hard (at least on iOS) to re-position the
// caret (i.e. deselect). There's not much use for always selecting // caret (i.e. deselect). There's not much use for always selecting
// the text on edit anyway (and users can select-all from contextmenu // the text on edit anyway (and users can select-all from contextmenu
@@ -5744,6 +5799,68 @@ class App extends React.Component<AppProps, AppState> {
}); });
} }
private getSelectedTextElement(
container?: ExcalidrawTextContainer | null,
): NonDeleted<ExcalidrawTextElement> | null {
const selectedElements = this.scene.getSelectedElements(this.state);
if (selectedElements.length !== 1) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return selectedElement;
}
if (!container) {
return null;
}
return getBoundTextElement(
selectedElement,
this.scene.getNonDeletedElementsMap(),
);
}
private getSelectedTextEditingContainerAtPosition(
hitElement: NonDeletedExcalidrawElement | null,
sceneCoords: { x: number; y: number },
): ExcalidrawTextContainer | null | undefined {
const selectedElements = this.scene.getSelectedElements(this.state);
if (
selectedElements.length !== 1 ||
!hitElement ||
hitElement.id !== selectedElements[0]!.id
) {
return null;
}
const selectedElement = selectedElements[0]!;
if (isTextElement(selectedElement)) {
return null;
}
if (!isValidTextContainer(selectedElement)) {
return undefined;
}
const textElement = this.getSelectedTextElement(selectedElement);
const hitTextElement = this.getTextElementAtPosition(
sceneCoords.x,
sceneCoords.y,
);
if (!textElement || hitTextElement?.id !== textElement.id) {
return undefined;
}
return selectedElement;
}
private getTextElementAtPosition( private getTextElementAtPosition(
x: number, x: number,
y: number, y: number,
@@ -5757,6 +5874,58 @@ class App extends React.Component<AppProps, AppState> {
return null; return null;
} }
private isHittingTextAutoResizeHandle = (
selectedElements: NonDeleted<ExcalidrawElement>[],
point: Readonly<{ x: number; y: number }>,
): boolean => {
const activeTextElement = getActiveTextElement(
selectedElements,
this.state,
);
if (
activeTextElement &&
!activeTextElement.isDeleted &&
!activeTextElement.autoResize &&
isPointHittingTextAutoResizeHandle(
point,
activeTextElement,
this.state.zoom.value,
this.editorInterface.formFactor,
)
) {
return true;
}
return false;
};
private handleTextAutoResizeHandlePointerDown = (
selectedElements: NonDeleted<ExcalidrawElement>[],
point: Readonly<{ x: number; y: number }>,
) => {
const activeTextElement = getActiveTextElement(
selectedElements,
this.state,
);
if (
!activeTextElement ||
!this.isHittingTextAutoResizeHandle(selectedElements, point)
) {
return false;
}
this.actionManager.executeAction(
actionTextAutoResize,
"ui",
// we need to pass down the element since it may already be deselected
// due to the pointerdown
activeTextElement,
);
this.resetCursor();
return true;
};
// NOTE: Hot path for hit testing, so avoid unnecessary computations // NOTE: Hot path for hit testing, so avoid unnecessary computations
private getElementAtPosition( private getElementAtPosition(
x: number, x: number,
@@ -5969,6 +6138,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter = true, insertAtParentCenter = true,
container, container,
autoEdit = true, autoEdit = true,
initialCaretSceneCoords,
}: { }: {
/** X position to insert text at */ /** X position to insert text at */
sceneX: number; sceneX: number;
@@ -5978,6 +6148,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter?: boolean; insertAtParentCenter?: boolean;
container?: ExcalidrawTextContainer | null; container?: ExcalidrawTextContainer | null;
autoEdit?: boolean; autoEdit?: boolean;
initialCaretSceneCoords?: { x: number; y: number };
}) => { }) => {
let shouldBindToContainer = false; let shouldBindToContainer = false;
@@ -5998,24 +6169,9 @@ class App extends React.Component<AppProps, AppState> {
shouldBindToContainer = true; shouldBindToContainer = true;
} }
} }
let existingTextElement: NonDeleted<ExcalidrawTextElement> | null = null; const existingTextElement =
this.getSelectedTextElement(container) ||
const selectedElements = this.scene.getSelectedElements(this.state); this.getTextElementAtPosition(sceneX, sceneY);
if (selectedElements.length === 1) {
if (isTextElement(selectedElements[0])) {
existingTextElement = selectedElements[0];
} else if (container) {
existingTextElement = getBoundTextElement(
selectedElements[0],
this.scene.getNonDeletedElementsMap(),
);
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
} else {
existingTextElement = this.getTextElementAtPosition(sceneX, sceneY);
}
const fontFamily = const fontFamily =
existingTextElement?.fontFamily || this.state.currentItemFontFamily; existingTextElement?.fontFamily || this.state.currentItemFontFamily;
@@ -6062,11 +6218,32 @@ class App extends React.Component<AppProps, AppState> {
y: sceneY, y: sceneY,
}); });
const textCreationGridPoint = this.getTextCreationGridPoint(sceneX, sceneY);
const newTextElementPosition = parentCenterPosition
? {
x: parentCenterPosition.elementCenterX,
y: parentCenterPosition.elementCenterY,
}
: !existingTextElement
? {
x: textCreationGridPoint?.x ?? sceneX,
y:
textCreationGridPoint === null
? // Free text starts from a point cursor, so center the first line box on it.
sceneY - getLineHeightInPx(fontSize, lineHeight) / 2
: textCreationGridPoint.y,
}
: {
x: sceneX,
y: sceneY,
};
const element = const element =
existingTextElement || existingTextElement ||
newTextElement({ newTextElement({
x: parentCenterPosition ? parentCenterPosition.elementCenterX : sceneX, x: newTextElementPosition.x,
y: parentCenterPosition ? parentCenterPosition.elementCenterY : sceneY, y: newTextElementPosition.y,
strokeColor: this.state.currentItemStrokeColor, strokeColor: this.state.currentItemStrokeColor,
backgroundColor: this.state.currentItemBackgroundColor, backgroundColor: this.state.currentItemBackgroundColor,
fillStyle: this.state.currentItemFillStyle, fillStyle: this.state.currentItemFillStyle,
@@ -6116,6 +6293,9 @@ class App extends React.Component<AppProps, AppState> {
if (autoEdit || existingTextElement || container) { if (autoEdit || existingTextElement || container) {
this.handleTextWysiwyg(element, { this.handleTextWysiwyg(element, {
isExistingElement: !!existingTextElement, isExistingElement: !!existingTextElement,
initialCaretSceneCoords: existingTextElement
? initialCaretSceneCoords
: null,
}); });
} else { } else {
this.setState({ this.setState({
@@ -6141,9 +6321,48 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private shouldHandleBrowserCanvasDoubleClick = (type: string) => {
// TODO remove this once we consolidate double-click logic and handle
// ourselves for all event types together
if (type === "touch") {
return true;
}
if (this.lastCompletedCanvasClicks.length === 0) {
return true;
}
if (this.lastCompletedCanvasClicks.length < 2) {
return false;
}
const [firstClick, secondClick] = this.lastCompletedCanvasClicks;
return (
pointDistance(
pointFrom(firstClick.x, firstClick.y),
pointFrom(secondClick.x, secondClick.y),
) <= DOUBLE_TAP_POSITION_THRESHOLD
);
};
private handleCanvasDoubleClick = ( private handleCanvasDoubleClick = (
event: React.MouseEvent<HTMLCanvasElement>, event: Pick<
React.MouseEvent<HTMLCanvasElement>,
| "type"
| "clientX"
| "clientY"
| "altKey"
| "ctrlKey"
| "metaKey"
| "shiftKey"
>,
) => { ) => {
if (
this.state.editingTextElement ||
!this.shouldHandleBrowserCanvasDoubleClick(event.type)
) {
return;
}
// case: double-clicking with arrow/line tool selected would both create // case: double-clicking with arrow/line tool selected would both create
// text and enter multiElement mode // text and enter multiElement mode
if (this.state.multiElement) { if (this.state.multiElement) {
@@ -6331,6 +6550,21 @@ class App extends React.Component<AppProps, AppState> {
} }
}; };
private handleCanvasClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
if (event.button !== POINTER_BUTTON.MAIN) {
this.lastCompletedCanvasClicks = [];
return;
}
this.lastCompletedCanvasClicks = [
...this.lastCompletedCanvasClicks.slice(-1),
{
x: event.clientX,
y: event.clientY,
},
];
};
private getElementLinkAtPosition = ( private getElementLinkAtPosition = (
scenePointer: Readonly<{ x: number; y: number }>, scenePointer: Readonly<{ x: number; y: number }>,
hitElementMightBeLocked: NonDeletedExcalidrawElement | null, hitElementMightBeLocked: NonDeletedExcalidrawElement | null,
@@ -6867,6 +7101,12 @@ class App extends React.Component<AppProps, AppState> {
const elements = this.scene.getNonDeletedElements(); const elements = this.scene.getNonDeletedElements();
const selectedElements = this.scene.getSelectedElements(this.state); const selectedElements = this.scene.getSelectedElements(this.state);
if (this.isHittingTextAutoResizeHandle(selectedElements, scenePointer)) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
return;
}
if ( if (
selectedElements.length === 1 && selectedElements.length === 1 &&
!isOverScrollBar && !isOverScrollBar &&
@@ -7011,7 +7251,9 @@ class App extends React.Component<AppProps, AppState> {
setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO); setCursor(this.interactiveCanvas, CURSOR_TYPE.AUTO);
} else if ( } else if (
// if using cmd/ctrl, we're not dragging // if using cmd/ctrl, we're not dragging
!event[KEYS.CTRL_OR_CMD] !event[KEYS.CTRL_OR_CMD] &&
// editing text -> don't show move cursor when hovering over its bbox
hitElement?.id !== this.state.editingTextElement?.id
) { ) {
if ( if (
(hitElement || (hitElement ||
@@ -7232,6 +7474,8 @@ class App extends React.Component<AppProps, AppState> {
private handleCanvasPointerDown = ( private handleCanvasPointerDown = (
event: React.PointerEvent<HTMLElement>, event: React.PointerEvent<HTMLElement>,
) => { ) => {
const selectedElements = this.scene.getSelectedElements(this.state);
// If Ctrl is not held, ensure isBindingEnabled reflects the user preference. // If Ctrl is not held, ensure isBindingEnabled reflects the user preference.
if (!event.ctrlKey) { if (!event.ctrlKey) {
const preferenceEnabled = this.state.bindingPreference === "enabled"; const preferenceEnabled = this.state.bindingPreference === "enabled";
@@ -7455,6 +7699,15 @@ class App extends React.Component<AppProps, AppState> {
selectedElementsAreBeingDragged: false, selectedElementsAreBeingDragged: false,
}); });
if (
this.handleTextAutoResizeHandlePointerDown(
selectedElements,
pointerDownState.origin,
)
) {
return;
}
if (this.handleDraggingScrollBar(event, pointerDownState)) { if (this.handleDraggingScrollBar(event, pointerDownState)) {
return; return;
} }
@@ -7680,6 +7933,10 @@ class App extends React.Component<AppProps, AppState> {
} }
this.removePointer(event); this.removePointer(event);
this.lastPointerUpIsDoubleClick = this.isDoubleClick(
this.lastPointerUpEvent,
event,
);
this.lastPointerUpEvent = event; this.lastPointerUpEvent = event;
if (!event.ctrlKey) { if (!event.ctrlKey) {
@@ -8516,6 +8773,7 @@ class App extends React.Component<AppProps, AppState> {
insertAtParentCenter: !event.altKey, insertAtParentCenter: !event.altKey,
container, container,
autoEdit: false, autoEdit: false,
initialCaretSceneCoords: { x: sceneX, y: sceneY },
}); });
resetCursor(this.interactiveCanvas); resetCursor(this.interactiveCanvas);
@@ -11001,6 +11259,35 @@ class App extends React.Component<AppProps, AppState> {
return; return;
} }
const selectedTextEditingContainer =
this.getSelectedTextEditingContainerAtPosition(hitElement, sceneCoords);
if (
activeTool.type === this.state.preferredSelectionTool.type &&
!this.state.editingTextElement &&
!pointerDownState.drag.hasOccurred &&
!pointerDownState.hit.wasAddedToSelection &&
!childEvent.shiftKey &&
!childEvent[KEYS.CTRL_OR_CMD] &&
!childEvent.altKey &&
childEvent.pointerType !== "touch" &&
hitElement &&
((isTextElement(hitElement) &&
this.state.selectedElementIds[hitElement.id] &&
this.scene.getSelectedElements(this.state).length === 1) ||
selectedTextEditingContainer)
) {
this.startTextEditing({
sceneX: sceneCoords.x,
sceneY: sceneCoords.y,
container: selectedTextEditingContainer,
initialCaretSceneCoords: this.lastPointerUpIsDoubleClick
? undefined
: sceneCoords,
});
return;
}
if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) { if (!activeTool.locked && activeTool.type !== "freedraw" && newElement) {
this.setState((prevState) => ({ this.setState((prevState) => ({
selectedElementIds: makeNextSelectedElementIds( selectedElementIds: makeNextSelectedElementIds(
+37 -33
View File
@@ -1,74 +1,78 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { t } from "../i18n";
import "./Range.scss"; import "./Range.scss";
import type { AppClassProperties } from "../types";
export type RangeProps = { export type RangeProps = {
updateData: (value: number) => void; label: React.ReactNode;
app: AppClassProperties; value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
minLabel?: React.ReactNode;
hasCommonValue?: boolean;
testId?: string; testId?: string;
}; };
export const Range = ({ updateData, app, testId }: RangeProps) => { export const Range = ({
label,
value,
onChange,
min = 0,
max = 100,
step = 10,
minLabel = min,
hasCommonValue = true,
testId,
}: RangeProps) => {
const rangeRef = React.useRef<HTMLInputElement>(null); const rangeRef = React.useRef<HTMLInputElement>(null);
const valueRef = React.useRef<HTMLDivElement>(null); const valueRef = React.useRef<HTMLDivElement>(null);
const selectedElements = app.scene.getSelectedElements(app.state);
let hasCommonOpacity = true;
const firstElement = selectedElements.at(0);
const leastCommonOpacity = selectedElements.reduce((acc, element) => {
if (acc != null && acc !== element.opacity) {
hasCommonOpacity = false;
}
if (acc == null || acc > element.opacity) {
return element.opacity;
}
return acc;
}, firstElement?.opacity ?? null);
const value = leastCommonOpacity ?? app.state.currentItemOpacity;
useEffect(() => { useEffect(() => {
if (rangeRef.current && valueRef.current) { if (rangeRef.current && valueRef.current) {
const rangeElement = rangeRef.current; const rangeElement = rangeRef.current;
const valueElement = valueRef.current; const valueElement = valueRef.current;
const inputWidth = rangeElement.offsetWidth; const inputWidth = rangeElement.offsetWidth;
const thumbWidth = 15; // 15 is the width of the thumb const thumbWidth =
parseFloat(
getComputedStyle(rangeElement).getPropertyValue(
"--slider-thumb-size",
),
) || 16;
const progress = ((value - min) / (max - min || 1)) * 100;
const position = const position =
(value / 100) * (inputWidth - thumbWidth) + thumbWidth / 2; (progress / 100) * (inputWidth - thumbWidth) + thumbWidth / 2;
valueElement.style.left = `${position}px`; valueElement.style.left = `${position}px`;
rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${value}%, var(--button-bg) ${value}%, var(--button-bg) 100%)`; rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${progress}%, var(--button-bg) ${progress}%, var(--button-bg) 100%)`;
} }
}, [value]); }, [max, min, value]);
return ( return (
<label className="control-label"> <label className="control-label">
{t("labels.opacity")} {label}
<div className="range-wrapper"> <div className="range-wrapper">
<input <input
style={{ style={{
["--color-slider-track" as string]: hasCommonOpacity ["--color-slider-track" as string]: hasCommonValue
? undefined ? undefined
: "var(--button-bg)", : "var(--button-bg)",
}} }}
ref={rangeRef} ref={rangeRef}
type="range" type="range"
min="0" min={min}
max="100" max={max}
step="10" step={step}
onChange={(event) => { onChange={(event) => {
updateData(+event.target.value); onChange(+event.target.value);
}} }}
value={value} value={value}
className="range-input" className="range-input"
data-testid={testId} data-testid={testId}
/> />
<div className="value-bubble" ref={valueRef}> <div className="value-bubble" ref={valueRef}>
{value !== 0 ? value : null} {value !== min ? value : null}
</div> </div>
<div className="zero-label">0</div> <div className="zero-label">{minLabel}</div>
</div> </div>
</label> </label>
); );
@@ -361,12 +361,10 @@ describe("stats for a non-generic element", () => {
mouse.clickAt(20, 30); mouse.clickAt(20, 30);
const editor = await getTextEditor(); const editor = await getTextEditor();
updateTextEditor(editor, "Hello!"); updateTextEditor(editor, "Hello!");
act(() => { Keyboard.exitTextEditor(editor);
editor.blur();
});
const text = h.elements[0] as ExcalidrawTextElement; const text = h.elements[0] as ExcalidrawTextElement;
mouse.clickOn(text); API.setSelectedElements([text]);
elementStats = stats?.querySelector("#elementStats"); elementStats = stats?.querySelector("#elementStats");
@@ -54,6 +54,7 @@ type InteractiveCanvasProps = {
DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"], DOMAttributes<HTMLCanvasElement | HTMLDivElement>["onContextMenu"],
undefined undefined
>; >;
onClick: Exclude<DOMAttributes<HTMLCanvasElement>["onClick"], undefined>;
onPointerMove: Exclude< onPointerMove: Exclude<
DOMAttributes<HTMLCanvasElement>["onPointerMove"], DOMAttributes<HTMLCanvasElement>["onPointerMove"],
undefined undefined
@@ -213,6 +214,7 @@ const InteractiveCanvas = (props: InteractiveCanvasProps) => {
height={props.appState.height * props.scale} height={props.appState.height * props.scale}
ref={props.handleCanvasRef} ref={props.handleCanvasRef}
onContextMenu={props.onContextMenu} onContextMenu={props.onContextMenu}
onClick={props.onClick}
onPointerMove={props.onPointerMove} onPointerMove={props.onPointerMove}
onPointerUp={props.onPointerUp} onPointerUp={props.onPointerUp}
onPointerCancel={props.onPointerCancel} onPointerCancel={props.onPointerCancel}
-4
View File
@@ -20,10 +20,6 @@ export const resaveAsImageWithScene = async (
) => { ) => {
const fileHandleType = getFileHandleType(fileHandle); const fileHandleType = getFileHandleType(fileHandle);
if (Math.random() < 1) {
throw new Error("OLALALALA");
}
if (!isImageFileHandleType(fileHandleType)) { if (!isImageFileHandleType(fileHandleType)) {
throw new Error( throw new Error(
"fileHandle should exist and should be of type svg or png when resaving", "fileHandle should exist and should be of type svg or png when resaving",
+2
View File
@@ -4,6 +4,8 @@ interface Window {
EXCALIDRAW_ASSET_PATH: string | string[] | undefined; EXCALIDRAW_ASSET_PATH: string | string[] | undefined;
EXCALIDRAW_THROTTLE_RENDER: boolean | undefined; EXCALIDRAW_THROTTLE_RENDER: boolean | undefined;
DEBUG_FRACTIONAL_INDICES: boolean | undefined; DEBUG_FRACTIONAL_INDICES: boolean | undefined;
EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS: boolean | undefined;
EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW: unknown;
EXCALIDRAW_EXPORT_SOURCE: string; EXCALIDRAW_EXPORT_SOURCE: string;
gtag: Function; gtag: Function;
sa_event: Function; sa_event: Function;
@@ -41,6 +41,8 @@ import {
maxBindingDistance_simple, maxBindingDistance_simple,
isTextElement, isTextElement,
LinearElementEditor, LinearElementEditor,
getActiveTextElement,
getSimpleArrowCurveDebugData,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { renderSelectionElement } from "@excalidraw/element"; import { renderSelectionElement } from "@excalidraw/element";
@@ -58,6 +60,8 @@ import {
isFocusPointVisible, isFocusPointVisible,
} from "@excalidraw/element"; } from "@excalidraw/element";
import type { EditorInterface } from "@excalidraw/common";
import type { import type {
TransformHandles, TransformHandles,
TransformHandleType, TransformHandleType,
@@ -86,6 +90,10 @@ import {
} from "../scene/scrollbars"; } from "../scene/scrollbars";
import { getClientColor, renderRemoteCursors } from "../clients"; import { getClientColor, renderRemoteCursors } from "../clients";
import {
getTextAutoResizeHandle,
getTextBoxPadding,
} from "../textAutoResizeHandle";
import { import {
bootstrapCanvas, bootstrapCanvas,
@@ -1194,6 +1202,105 @@ const renderLinearPointHandles = (
context.restore(); context.restore();
}; };
const isSimpleArrowTangentDebugEnabled = () =>
window.EXCALIDRAW_DEBUG_LINEAR_ARROW_TANGENTS === true;
const renderSimpleArrowTangentOverlay = (
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
element: NonDeleted<ExcalidrawArrowElement>,
elementsMap: RenderableElementsMap,
) => {
const points = LinearElementEditor.getPointsGlobalCoordinates(
element,
elementsMap,
);
const debugData = getSimpleArrowCurveDebugData(points);
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = debugData;
context.save();
context.translate(appState.scrollX, appState.scrollY);
context.lineWidth = 1 / appState.zoom.value;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeStyle = "rgba(134, 142, 150, 0.75)";
for (const segment of debugData.segments) {
context.beginPath();
context.moveTo(segment.start[0], segment.start[1]);
context.lineTo(segment.baseCp1[0], segment.baseCp1[1]);
context.lineTo(segment.baseCp2[0], segment.baseCp2[1]);
context.lineTo(segment.end[0], segment.end[1]);
context.stroke();
}
context.setLineDash([]);
for (const segment of debugData.segments) {
const strokeStyle = segment.overshootsBaseline
? "rgba(245, 159, 0, 0.9)"
: "rgba(94, 90, 216, 0.85)";
context.strokeStyle = strokeStyle;
context.fillStyle = "rgba(255, 255, 255, 0.95)";
context.beginPath();
context.moveTo(segment.start[0], segment.start[1]);
context.lineTo(segment.cp1[0], segment.cp1[1]);
context.lineTo(segment.cp2[0], segment.cp2[1]);
context.lineTo(segment.end[0], segment.end[1]);
context.stroke();
fillCircle(
context,
segment.cp1[0],
segment.cp1[1],
4 / appState.zoom.value,
true,
true,
);
fillCircle(
context,
segment.cp2[0],
segment.cp2[1],
4 / appState.zoom.value,
true,
true,
);
}
context.strokeStyle = "rgba(201, 42, 42, 0.85)";
context.fillStyle = "rgba(201, 42, 42, 0.95)";
for (const tangent of debugData.tangents) {
if (!tangent.isAdjusted) {
continue;
}
const handle = pointFrom<GlobalPoint>(
tangent.point[0] + tangent.scaled[0] / 3,
tangent.point[1] + tangent.scaled[1] / 3,
);
context.beginPath();
context.moveTo(tangent.point[0], tangent.point[1]);
context.lineTo(handle[0], handle[1]);
context.stroke();
fillCircle(
context,
tangent.point[0],
tangent.point[1],
3 / appState.zoom.value,
true,
true,
);
}
context.restore();
};
const renderFocusPointConnectionLine = ( const renderFocusPointConnectionLine = (
context: CanvasRenderingContext2D, context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState, appState: InteractiveCanvasAppState,
@@ -1489,21 +1596,58 @@ const renderTextBox = (
selectionColor: InteractiveCanvasRenderConfig["selectionColor"], selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
) => { ) => {
context.save(); context.save();
const padding = (DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / appState.zoom.value; const padding = getTextBoxPadding(appState.zoom.value);
const width = text.width + padding * 2; const width = text.width + padding * 2;
const height = text.height + padding * 2; const height = text.height + padding * 2;
const cx = text.x + width / 2; const cx = text.x + text.width / 2;
const cy = text.y + height / 2; const cy = text.y + text.height / 2;
const shiftX = -(width / 2 + padding); const shiftX = -(text.width / 2 + padding);
const shiftY = -(height / 2 + padding); const shiftY = -(text.height / 2 + padding);
context.translate(cx + appState.scrollX, cy + appState.scrollY); context.translate(cx + appState.scrollX, cy + appState.scrollY);
context.rotate(text.angle); context.rotate(text.angle);
context.lineWidth = 1 / appState.zoom.value; context.lineWidth = 1 / appState.zoom.value;
context.strokeStyle = selectionColor; context.strokeStyle = selectionColor;
context.globalAlpha = 0.5;
context.setLineDash([6 / appState.zoom.value, 4 / appState.zoom.value]);
context.strokeRect(shiftX, shiftY, width, height); context.strokeRect(shiftX, shiftY, width, height);
context.restore(); context.restore();
}; };
const renderResetAutoResizeHandle = (
text: NonDeleted<ExcalidrawTextElement>,
context: CanvasRenderingContext2D,
appState: InteractiveCanvasAppState,
selectionColor: InteractiveCanvasRenderConfig["selectionColor"],
formFactor: EditorInterface["formFactor"],
) => {
const autoResizeHandle = getTextAutoResizeHandle(
text,
appState.zoom.value,
formFactor,
);
if (!autoResizeHandle) {
return;
}
context.save();
context.globalAlpha = 0.5;
context.lineWidth = 1.5 / appState.zoom.value;
context.lineCap = "round";
context.strokeStyle = selectionColor;
context.beginPath();
context.moveTo(
autoResizeHandle.start[0] + appState.scrollX,
autoResizeHandle.start[1] + appState.scrollY,
);
context.lineTo(
autoResizeHandle.end[0] + appState.scrollX,
autoResizeHandle.end[1] + appState.scrollY,
);
context.stroke();
context.restore();
};
const _renderInteractiveScene = ({ const _renderInteractiveScene = ({
app, app,
canvas, canvas,
@@ -1584,10 +1728,19 @@ const _renderInteractiveScene = ({
} }
} }
if ( const activeTextElement = getActiveTextElement(selectedElements, appState);
appState.editingTextElement &&
isTextElement(appState.editingTextElement) if (activeTextElement && !activeTextElement.autoResize) {
) { renderResetAutoResizeHandle(
activeTextElement,
context,
appState,
renderConfig.selectionColor,
editorInterface.formFactor,
);
}
if (appState.editingTextElement) {
const textElement = allElementsMap.get(appState.editingTextElement.id) as const textElement = allElementsMap.get(appState.editingTextElement.id) as
| ExcalidrawTextElement | ExcalidrawTextElement
| undefined; | undefined;
@@ -1669,6 +1822,13 @@ const _renderInteractiveScene = ({
const selectedLinearElement = const selectedLinearElement =
linearState && linearState &&
LinearElementEditor.getElement(linearState.elementId, allElementsMap); LinearElementEditor.getElement(linearState.elementId, allElementsMap);
const selectedRoundedArrow =
selectedElements.length === 1 &&
isArrowElement(selectedElements[0]) &&
!isElbowArrow(selectedElements[0]) &&
!!selectedElements[0].roundness
? (selectedElements[0] as NonDeleted<ExcalidrawArrowElement>)
: null;
// Arrows have a different highlight behavior when // Arrows have a different highlight behavior when
// they are the only selected element // they are the only selected element
if (selectedLinearElement) { if (selectedLinearElement) {
@@ -1705,6 +1865,17 @@ const _renderInteractiveScene = ({
} }
} }
if (selectedRoundedArrow && isSimpleArrowTangentDebugEnabled()) {
renderSimpleArrowTangentOverlay(
context,
appState,
selectedRoundedArrow,
elementsMap,
);
} else {
window.EXCALIDRAW_DEBUG_SELECTED_LINEAR_ARROW = undefined;
}
// Paint selected elements // Paint selected elements
if ( if (
!appState.multiElement && !appState.multiElement &&
@@ -224,7 +224,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 29, "version": 28,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -350,7 +350,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 28, "version": 27,
"width": "88.00000", "width": "88.00000",
}, },
"inserted": { "inserted": {
@@ -381,7 +381,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 25, "version": 24,
"width": "88.00000", "width": "88.00000",
}, },
}, },
@@ -437,7 +437,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 29, "version": 28,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -462,7 +462,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 28, "version": 27,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "7.20923", "y": "7.20923",
@@ -1360,9 +1360,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 8, "version": 7,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "2.00947", "y": "2.00947",
} }
`; `;
@@ -1537,12 +1537,12 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 8, "version": 7,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 7, "version": 6,
}, },
}, },
}, },
@@ -1722,7 +1722,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"updated": 1, "updated": 1,
"version": 8, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "38.80379", "y": "38.80379",
} }
`; `;
@@ -1867,7 +1867,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"type": "arrow", "type": "arrow",
"version": 8, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": "6.00000",
"y": "38.80379", "y": "38.80379",
}, },
"inserted": { "inserted": {
@@ -2416,7 +2416,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 11,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -2581,7 +2581,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 12, "version": 11,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.39000", "y": "-5.39000",
@@ -16254,7 +16254,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -16307,7 +16307,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 10, "version": 11,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -16327,7 +16327,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 8, "version": 9,
}, },
}, },
}, },
@@ -16669,14 +16669,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 7, "version": 8,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 7,
}, },
}, },
}, },
@@ -17002,7 +17002,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -17307,14 +17307,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 8, "version": 9,
}, },
}, },
}, },
@@ -17648,7 +17648,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -17953,14 +17953,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 8, "version": 9,
}, },
}, },
}, },
@@ -18292,7 +18292,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -18361,7 +18361,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 10, "version": 11,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -18373,7 +18373,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"mode": "orbit", "mode": "orbit",
}, },
"startBinding": null, "startBinding": null,
"version": 8, "version": 9,
}, },
}, },
"id2": { "id2": {
@@ -18683,14 +18683,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 7, "version": 8,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 7,
}, },
}, },
}, },
@@ -19044,7 +19044,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 12,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.01000", "y": "0.01000",
@@ -19124,12 +19124,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 11, "version": 12,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 9, "version": 10,
}, },
}, },
}, },
@@ -19431,14 +19431,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 7, "version": 8,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00880", "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 6, "version": 7,
}, },
}, },
}, },
+12 -2
View File
@@ -4,6 +4,7 @@ import {
elementCenterPoint, elementCenterPoint,
getCommonBounds, getCommonBounds,
getElementPointsCoords, getElementPointsCoords,
getLineHeightInPx,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { cropElement } from "@excalidraw/element"; import { cropElement } from "@excalidraw/element";
import { import {
@@ -20,7 +21,7 @@ import {
isTextElement, isTextElement,
isFrameLikeElement, isFrameLikeElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { KEYS, arrayToMap } from "@excalidraw/common"; import { KEYS, arrayToMap, getLineHeight } from "@excalidraw/common";
import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math"; import type { GlobalPoint, LocalPoint, Radians } from "@excalidraw/math";
@@ -516,8 +517,17 @@ export class UI {
UI.clickTool(type); UI.clickTool(type);
if (type === "text") { if (type === "text") {
const clickY = h.state.gridModeEnabled
? y
: y +
getLineHeightInPx(
h.state.currentItemFontSize,
getLineHeight(h.state.currentItemFontFamily),
) /
2;
mouse.reset(); mouse.reset();
mouse.click(x, y); mouse.click(x, clickY);
} else if ((type === "line" || type === "arrow") && points.length > 2) { } else if ((type === "line" || type === "arrow") && points.length > 2) {
points.forEach((point) => { points.forEach((point) => {
mouse.reset(); mouse.reset();
@@ -0,0 +1,88 @@
import { DEFAULT_TRANSFORM_HANDLE_SPACING } from "@excalidraw/common";
import {
pointFrom,
pointRotateRads,
type GlobalPoint,
type Radians,
} from "@excalidraw/math";
import type { EditorInterface } from "@excalidraw/common";
import type { ExcalidrawTextElement } from "@excalidraw/element/types";
const TEXT_AUTO_RESIZE_HANDLE_GAP = 12;
const TEXT_AUTO_RESIZE_HANDLE_LENGTH = 16;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH = 10;
const TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT =
TEXT_AUTO_RESIZE_HANDLE_LENGTH + 2;
const MAX_HANDLE_HEIGHT_RATIO = 0.8;
export const getTextBoxPadding = (zoomValue: number) =>
(DEFAULT_TRANSFORM_HANDLE_SPACING * 2) / zoomValue;
export const getTextAutoResizeHandle = (
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
if (
formFactor !== "desktop" ||
TEXT_AUTO_RESIZE_HANDLE_LENGTH >
textElement.height * zoomValue * MAX_HANDLE_HEIGHT_RATIO
) {
return null;
}
const padding = getTextBoxPadding(zoomValue);
const gap = TEXT_AUTO_RESIZE_HANDLE_GAP / zoomValue;
const length = TEXT_AUTO_RESIZE_HANDLE_LENGTH / zoomValue;
const center = pointFrom(
textElement.x + textElement.width / 2,
textElement.y + textElement.height / 2,
);
const handleCenter = pointRotateRads(
pointFrom(center[0] + textElement.width / 2 + padding + gap, center[1]),
center,
textElement.angle,
);
return {
center: handleCenter,
start: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] - length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
end: pointRotateRads(
pointFrom(handleCenter[0], handleCenter[1] + length / 2),
handleCenter,
textElement.angle,
) as GlobalPoint,
hitboxWidth: TEXT_AUTO_RESIZE_HANDLE_HITBOX_WIDTH / zoomValue,
hitboxHeight: TEXT_AUTO_RESIZE_HANDLE_HITBOX_HEIGHT / zoomValue,
};
};
export const isPointHittingTextAutoResizeHandle = (
point: Readonly<{ x: number; y: number }>,
textElement: ExcalidrawTextElement,
zoomValue: number,
formFactor: EditorInterface["formFactor"],
) => {
const handle = getTextAutoResizeHandle(textElement, zoomValue, formFactor);
if (!handle) {
return false;
}
const unrotatedPoint = pointRotateRads(
pointFrom(point.x, point.y),
handle.center,
-textElement.angle as Radians,
);
return (
Math.abs(unrotatedPoint[0] - handle.center[0]) <= handle.hitboxWidth / 2 &&
Math.abs(unrotatedPoint[1] - handle.center[1]) <= handle.hitboxHeight / 2
);
};
+4 -4
View File
@@ -32,6 +32,7 @@ import type {
OrderedExcalidrawElement, OrderedExcalidrawElement,
ExcalidrawNonSelectionElement, ExcalidrawNonSelectionElement,
BindMode, BindMode,
ExcalidrawTextElement,
} from "@excalidraw/element/types"; } from "@excalidraw/element/types";
import type { import type {
@@ -327,7 +328,7 @@ export interface AppState {
/** /**
* set when a new text is created or when an existing text is being edited * set when a new text is created or when an existing text is being edited
*/ */
editingTextElement: NonDeletedExcalidrawElement | null; editingTextElement: ExcalidrawTextElement | null;
activeTool: { activeTool: {
/** /**
* indicates a previous tool we should revert back to if we deselect the * indicates a previous tool we should revert back to if we deselect the
@@ -876,9 +877,8 @@ export type PointerDownState = Readonly<{
// by default same as PointerDownState.origin. On alt-duplication, reset // by default same as PointerDownState.origin. On alt-duplication, reset
// to current pointer position at time of duplication. // to current pointer position at time of duplication.
origin: { x: number; y: number }; origin: { x: number; y: number };
// Whether to block drag after lasso selection // explicit flag for specific scenarios such as:
// this is meant to be used to block dragging after lasso selection on PCs // - after lasso selection until the next pointer down
// until the next pointer down
blockDragging: boolean; blockDragging: boolean;
}; };
// We need to have these in the state so that we can unsubscribe them // We need to have these in the state so that we can unsubscribe them
@@ -1,7 +1,10 @@
import { queryByText } from "@testing-library/react"; import { queryByText } from "@testing-library/react";
import { pointFrom } from "@excalidraw/math"; import { pointFrom } from "@excalidraw/math";
import { getOriginalContainerHeightFromCache } from "@excalidraw/element"; import {
getLineHeightInPx,
getOriginalContainerHeightFromCache,
} from "@excalidraw/element";
import { import {
CODES, CODES,
@@ -210,6 +213,42 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
it("should vertically center newly created text on the cursor when clicked with text tool", async () => {
API.setAppState({
currentItemFontFamily: FONT_FAMILY.Cascadia,
currentItemFontSize: 40,
});
UI.clickTool("text");
mouse.clickAt(120, 80);
const editor = await getTextEditor();
const text = h.elements[0] as ExcalidrawTextElement;
const lineHeightPx = getLineHeightInPx(text.fontSize, text.lineHeight);
expect(editor).not.toBe(null);
expect(text.y + lineHeightPx / 2).toBe(80);
});
it("should snap newly created text top-left to the current grid cell when clicked with text tool in grid mode", async () => {
API.setAppState({
currentItemFontFamily: FONT_FAMILY.Cascadia,
currentItemFontSize: 40,
gridModeEnabled: true,
gridSize: 24,
});
UI.clickTool("text");
mouse.clickAt(113, 86);
const editor = await getTextEditor();
const text = h.elements[0] as ExcalidrawTextElement;
expect(editor).not.toBe(null);
expect(text.x).toBe(96);
expect(text.y).toBe(72);
});
it("should edit text under cursor when double-clicked with selection tool", async () => { it("should edit text under cursor when double-clicked with selection tool", async () => {
const text = API.createElement({ const text = API.createElement({
type: "text", type: "text",
@@ -232,6 +271,67 @@ describe("textWysiwyg", () => {
expect(h.elements.length).toBe(1); expect(h.elements.length).toBe(1);
}); });
it("should edit selected bound text on single click", async () => {
const container = API.createElement({
type: "rectangle",
width: 160,
height: 70,
boundElements: [],
});
const text = API.createElement({
type: "text",
text: "Hello World!",
x: container.x + 20,
y: container.y + 20,
width: 120,
height: 25,
containerId: container.id,
});
API.setElements([container, text]);
API.updateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setSelectedElements([container]);
UI.clickTool("selection");
mouse.clickAt(text.x + 26, text.y + 10);
const editor = await getTextEditor();
expect(editor).not.toBe(null);
});
it("should not edit selected bound text container when only the container was single-clicked", async () => {
const container = API.createElement({
type: "rectangle",
width: 160,
height: 70,
boundElements: [],
});
const text = API.createElement({
type: "text",
text: "Hello World!",
x: container.x + 20,
y: container.y + 20,
width: 120,
height: 25,
containerId: container.id,
});
API.setElements([container, text]);
API.updateElement(container, {
boundElements: [{ type: "text", id: text.id }],
});
API.setSelectedElements([container]);
UI.clickTool("selection");
mouse.clickAt(container.x + 5, container.y + 10);
expect(h.state.editingTextElement).toBe(null);
expect(await getTextEditor({ waitForEditor: false })).toBe(null);
});
// FIXME too flaky. No one knows why. // FIXME too flaky. No one knows why.
it.skip("should bump the version of a labeled arrow when the label is updated", async () => { it.skip("should bump the version of a labeled arrow when the label is updated", async () => {
const arrow = UI.createElement("arrow", { const arrow = UI.createElement("arrow", {
@@ -1511,7 +1611,7 @@ describe("textWysiwyg", () => {
version: 2, version: 2,
width: 610, width: 610,
x: 15, x: 15,
y: 25, y: 12.5,
}), }),
); );
expect(h.elements[2] as ExcalidrawTextElement).toEqual( expect(h.elements[2] as ExcalidrawTextElement).toEqual(
+203 -2
View File
@@ -10,7 +10,9 @@ import {
isTestEnv, isTestEnv,
MIME_TYPES, MIME_TYPES,
applyDarkModeFilter, applyDarkModeFilter,
isRTL,
} from "@excalidraw/common"; } from "@excalidraw/common";
import { pointFrom, pointRotateRads, type Radians } from "@excalidraw/math";
import { import {
getTextFromElements, getTextFromElements,
@@ -33,8 +35,11 @@ import {
getBoundTextElement, getBoundTextElement,
} from "@excalidraw/element"; } from "@excalidraw/element";
import { getTextWidth } from "@excalidraw/element"; import { getTextWidth } from "@excalidraw/element";
import { getLineHeightInPx } from "@excalidraw/element";
import { getLineWidth } from "@excalidraw/element";
import { normalizeText } from "@excalidraw/element"; import { normalizeText } from "@excalidraw/element";
import { wrapText } from "@excalidraw/element"; import { wrapText } from "@excalidraw/element";
import { getWrappedTextLines } from "@excalidraw/element";
import { import {
isArrowElement, isArrowElement,
isBoundToContainer, isBoundToContainer,
@@ -91,6 +96,103 @@ const getTransform = (
return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`; return `translate(${translateX}px, ${translateY}px) scale(${zoom.value}) rotate(${degree}deg)`;
}; };
const getLineDirection = (text: string, offset: number) => {
const hardLineStart = text.lastIndexOf("\n", Math.max(0, offset - 1)) + 1;
const hardLineEnd = text.indexOf("\n", offset);
const hardLineText = text.slice(
hardLineStart,
hardLineEnd === -1 ? text.length : hardLineEnd,
);
return isRTL(hardLineText) ? "rtl" : "ltr";
};
const getCaretBoundaryOffsets = (text: string) => {
const offsets = [0];
let offset = 0;
for (const char of Array.from(text)) {
offset += char.length;
offsets.push(offset);
}
return offsets;
};
const getLineCaretOffsetFromNativeLayout = ({
text,
font,
lineHeightPx,
direction,
targetX,
}: {
text: string;
font: ReturnType<typeof getFontString>;
lineHeightPx: number;
direction: "ltr" | "rtl";
targetX: number;
}) => {
if (!text || !document.body || typeof document.createRange !== "function") {
return null;
}
const offsets = getCaretBoundaryOffsets(text);
const mirror = document.createElement("div");
const textNode = document.createTextNode(text);
const range = document.createRange();
const positions: number[] = [];
mirror.dir = direction;
Object.assign(mirror.style, {
position: "fixed",
top: "0",
left: "0",
margin: 0,
padding: 0,
border: 0,
opacity: "0",
pointerEvents: "none",
whiteSpace: "pre",
font,
lineHeight: `${lineHeightPx}px`,
});
mirror.append(textNode);
document.body.append(mirror);
try {
for (const offset of offsets) {
range.setStart(textNode, offset);
range.setEnd(textNode, offset);
const caretRect = range.getBoundingClientRect();
if (!Number.isFinite(caretRect.left)) {
return null;
}
positions.push(caretRect.left);
}
} catch {
return null;
} finally {
mirror.remove();
}
const leftEdge = Math.min(...positions);
let closestOffset = offsets[0];
let closestDistance = Infinity;
for (let index = 0; index < offsets.length; index++) {
const distance = Math.abs(positions[index] - leftEdge - targetX);
if (distance < closestDistance) {
closestDistance = distance;
closestOffset = offsets[index];
}
}
return closestOffset;
};
type SubmitHandler = () => void; type SubmitHandler = () => void;
export const textWysiwyg = ({ export const textWysiwyg = ({
@@ -103,6 +205,7 @@ export const textWysiwyg = ({
excalidrawContainer, excalidrawContainer,
app, app,
autoSelect = true, autoSelect = true,
initialCaretSceneCoords = null,
}: { }: {
id: ExcalidrawElement["id"]; id: ExcalidrawElement["id"];
/** /**
@@ -119,7 +222,19 @@ export const textWysiwyg = ({
excalidrawContainer: HTMLDivElement | null; excalidrawContainer: HTMLDivElement | null;
app: App; app: App;
autoSelect?: boolean; autoSelect?: boolean;
initialCaretSceneCoords?: { x: number; y: number } | null;
}): SubmitHandler => { }): SubmitHandler => {
let currentTextLayout: {
angle: Radians;
font: ReturnType<typeof getFontString>;
height: number;
lineHeightPx: number;
textAlign: ExcalidrawTextElement["textAlign"];
width: number;
x: number;
y: number;
} | null = null;
const textPropertiesUpdated = ( const textPropertiesUpdated = (
updatedTextElement: ExcalidrawTextElement, updatedTextElement: ExcalidrawTextElement,
editable: HTMLTextAreaElement, editable: HTMLTextAreaElement,
@@ -254,6 +369,7 @@ export const textWysiwyg = ({
height *= 1.05; height *= 1.05;
const font = getFontString(updatedTextElement); const font = getFontString(updatedTextElement);
const angle = getTextElementAngle(updatedTextElement, container);
// Make sure text editor height doesn't go beyond viewport // Make sure text editor height doesn't go beyond viewport
const editorMaxHeight = const editorMaxHeight =
@@ -269,7 +385,7 @@ export const textWysiwyg = ({
transform: getTransform( transform: getTransform(
width, width,
height, height,
getTextElementAngle(updatedTextElement, container), angle,
appState, appState,
maxWidth, maxWidth,
editorMaxHeight, editorMaxHeight,
@@ -283,6 +399,19 @@ export const textWysiwyg = ({
opacity: updatedTextElement.opacity / 100, opacity: updatedTextElement.opacity / 100,
maxHeight: `${editorMaxHeight}px`, maxHeight: `${editorMaxHeight}px`,
}); });
currentTextLayout = {
angle: angle as Radians,
font,
height: updatedTextElement.height,
lineHeightPx: getLineHeightInPx(
updatedTextElement.fontSize,
updatedTextElement.lineHeight,
),
textAlign,
width: updatedTextElement.width,
x: coordX,
y: coordY,
};
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
@@ -333,6 +462,71 @@ export const textWysiwyg = ({
editable.value = element.originalText; editable.value = element.originalText;
updateWysiwygStyle(); updateWysiwygStyle();
const getCaretIndexFromInitialSceneCoords = () => {
if (!initialCaretSceneCoords || !currentTextLayout) {
return null;
}
const layout = currentTextLayout;
const center = pointFrom(
layout.x + layout.width / 2,
layout.y + layout.height / 2,
);
const [unrotatedX, unrotatedY] = pointRotateRads(
pointFrom(initialCaretSceneCoords.x, initialCaretSceneCoords.y),
center,
-layout.angle as Radians,
);
const localX = unrotatedX - layout.x;
const localY = unrotatedY - layout.y;
const lines = getWrappedTextLines(
editable.value,
layout.font,
whiteSpace === "pre-wrap" ? layout.width : Infinity,
);
const lineIndex = Math.max(
0,
Math.min(lines.length - 1, Math.floor(localY / layout.lineHeightPx)),
);
const line = lines[lineIndex];
const direction = getLineDirection(editable.value, line.start);
const lineWidth = getLineWidth(line.text, layout.font);
const lineStartX =
layout.textAlign === "center"
? (layout.width - lineWidth) / 2
: layout.textAlign === "right"
? layout.width - lineWidth
: 0;
const relativeX = localX - lineStartX;
if (!line.text) {
return line.start;
}
const lineCaretOffset = getLineCaretOffsetFromNativeLayout({
text: line.text,
font: layout.font,
lineHeightPx: layout.lineHeightPx,
direction,
targetX: relativeX,
});
return line.start + (lineCaretOffset || 0);
};
let pendingInitialSelection = (() => {
const caretIndex = getCaretIndexFromInitialSceneCoords();
if (caretIndex === null) {
return null;
}
return {
start: caretIndex,
end: caretIndex,
};
})();
if (onChange) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
// we need to synchronously get the MIME types so we can preventDefault() // we need to synchronously get the MIME types so we can preventDefault()
@@ -696,6 +890,13 @@ export const textWysiwyg = ({
// Otherwise, re-enable submit on blur and refocus the editor. // Otherwise, re-enable submit on blur and refocus the editor.
editable.onblur = handleSubmit; editable.onblur = handleSubmit;
editable.focus(); editable.focus();
if (pendingInitialSelection) {
editable.setSelectionRange(
pendingInitialSelection.start,
pendingInitialSelection.end,
);
pendingInitialSelection = null;
}
}); });
}; };
@@ -786,7 +987,7 @@ export const textWysiwyg = ({
let isDestroyed = false; let isDestroyed = false;
if (autoSelect) { if (autoSelect && !pendingInitialSelection) {
// select on init (focusing is done separately inside the bindBlurEvent() // select on init (focusing is done separately inside the bindBlurEvent()
// because we need it to happen *after* the blur event from `pointerdown`) // because we need it to happen *after* the blur event from `pointerdown`)
editable.select(); editable.select();
+7 -2
View File
@@ -123,10 +123,15 @@ export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
* @returns The rotated point * @returns The rotated point
*/ */
export function pointRotateRads<Point extends GlobalPoint | LocalPoint>( export function pointRotateRads<Point extends GlobalPoint | LocalPoint>(
[x, y]: Point, point: Point,
[cx, cy]: Point, center: Point,
angle: Radians, angle: Radians,
): Point { ): Point {
if (!angle) {
return point;
}
const [x, y] = point;
const [cx, cy] = center;
return pointFrom( return pointFrom(
(x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx, (x - cx) * Math.cos(angle) - (y - cy) * Math.sin(angle) + cx,
(x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy, (x - cx) * Math.sin(angle) + (y - cy) * Math.cos(angle) + cy,
+13 -9
View File
@@ -196,7 +196,7 @@ export const getEllipseShape = <Point extends GlobalPoint | LocalPoint>(
export const getCurvePathOps = (shape: Drawable): Op[] => { export const getCurvePathOps = (shape: Drawable): Op[] => {
// NOTE (mtolmacs): Temporary fix for extremely large elements // NOTE (mtolmacs): Temporary fix for extremely large elements
if (!shape) { if (!shape || shape.sets.length === 0) {
return []; return [];
} }
@@ -205,6 +205,7 @@ export const getCurvePathOps = (shape: Drawable): Op[] => {
return set.ops; return set.ops;
} }
} }
return shape.sets[0].ops; return shape.sets[0].ops;
}; };
@@ -316,26 +317,29 @@ export const getClosedCurveShape = <Point extends GlobalPoint | LocalPoint>(
}; };
} }
const ops = getCurvePathOps(roughShape); // Prefer the fillPath set
const fillPathSet = roughShape.sets.find((s) => s.type === "fillPath");
const ops = fillPathSet ? fillPathSet.ops : getCurvePathOps(roughShape);
const points: Point[] = []; const points: Point[] = [];
let odd = false; let odd = false;
for (const operation of ops) { for (const operation of ops) {
if (operation.op === "move") { if (operation.op === "move") {
odd = !odd; if (fillPathSet) {
if (odd) { // fillPath is always a single run — no odd/even skipping needed
points.push(pointFrom(operation.data[0], operation.data[1])); points.push(pointFrom(operation.data[0], operation.data[1]));
} else {
odd = !odd;
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
} }
} else if (operation.op === "bcurveTo") { } else if (operation.op === "bcurveTo") {
if (odd) { if (fillPathSet || odd) {
points.push(pointFrom(operation.data[0], operation.data[1])); points.push(pointFrom(operation.data[0], operation.data[1]));
points.push(pointFrom(operation.data[2], operation.data[3])); points.push(pointFrom(operation.data[2], operation.data[3]));
points.push(pointFrom(operation.data[4], operation.data[5])); points.push(pointFrom(operation.data[4], operation.data[5]));
} }
} else if (operation.op === "lineTo") {
if (odd) {
points.push(pointFrom(operation.data[0], operation.data[1]));
}
} }
} }