Compare commits

...
Author SHA1 Message Date
dwelle 4060682c57 dedupe & handle embeddables 2026-02-18 22:57:55 +01:00
5852d0d410 fix: Arrow overlap arrow behavior (#10732)
* fix(arrow): Overlap arrow behavior

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat(editor): reduce binding gap (#10739)

* feat(editor): reduce binding gap to 7px

* feat(editor): reduce binding gap to 5px

* feat(editor): reduce binding gap to 3px

* go back to 5px

* update tests

* feat: Simplified update bind points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove non-needed export

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix. Possessed arrows #1

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Focus point projection stabilization

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Remove arrow stability hack

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Unbound other endpoint

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat(editor): visualize binding midpoints + support for simple arrows (#10611)

* feat: Force exact center focus point

When the projected point is close to center snap it to the exact center.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snap to center around side mid point.

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* Trigger CI

* fix: Midpoint outline focus point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Dragging existing arrow reset focus point on outline

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Midpoint indicator

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Rotated mid points

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: No hole

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Cache hits and scene lookups

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove debug

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Consider hit threshold and inside override too

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Increase outline midpoint sticky distance

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Don't show midpoint indicator when no snapping is possible

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Indicate lock-in

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove Map caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: incorrect threshold

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: threshold setting

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Hit caching

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Simple arrow mid point selection inconsistency

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: cache override

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Precise know dragging with midpoint refactor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fear: Frame support

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Crossing arrow won't trigger mid point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Arrow creation point highlight

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Restore types & tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Restore restore.ts

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: restore.ts

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Elbow arrows reliably highlight center point

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Highlight point ordering

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* feat: Bind with focus point across shape

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Lint

* fix: Midpoint and binding alignment

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Indicator color

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: More knob tuning

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Radius

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* simplify point indicators

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com>

* fix: Tests

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Snapshots

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Target point selection

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Remove non-needed change

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* chore: Try again removing non-needed modification

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Inside-inside binding arrow endpoint drag trigger focus point editor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Area based edge case

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

* fix: Overlapping new arrow jump

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>

---------

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com>
2026-02-17 14:10:05 +01:00
Christopher TangonanandGitHub c1e00c44f5 fix: convert ArrowheadNoneIcon to component matching arrowhead icon pattern (#10789) 2026-02-17 07:57:09 +01:00
Márk TolmácsandGitHub ffcb67b21f fix: Inside-inside bound arrow endpoint drag trigger focus point editor (#10771)
fix: Inside-inside binding arrow endpoint drag trigger focus point editor

Signed-off-by: Mark Tolmacs <mark@lazycat.hu>
2026-02-16 22:25:28 +01:00
David LuzarandGitHub 46ddd60948 feat(editor): support embedding google drive videos (#10788) 2026-02-16 22:19:11 +01:00
14 changed files with 762 additions and 404 deletions
-1
View File
@@ -43,7 +43,6 @@ export const alignElements = (
// update bound elements // update bound elements
updateBoundElements(element, scene, { updateBoundElements(element, scene, {
simultaneouslyUpdated: group, simultaneouslyUpdated: group,
indirectArrowUpdate: true,
}); });
return updatedEle; return updatedEle;
}); });
+24 -7
View File
@@ -42,6 +42,7 @@ export const isFocusPointVisible = (
isBindingEnabled: AppState["isBindingEnabled"]; isBindingEnabled: AppState["isBindingEnabled"];
zoom: AppState["zoom"]; zoom: AppState["zoom"];
}, },
startOrEnd: "start" | "end",
ignoreOverlap = false, ignoreOverlap = false,
): boolean => { ): boolean => {
// No focus point management for elbow arrows, because elbow arrows // No focus point management for elbow arrows, because elbow arrows
@@ -76,14 +77,25 @@ export const isFocusPointVisible = (
} }
} }
// Check if the focus point is within the element's shape bounds const arrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
return hitElementItself({ arrow,
element: bindableElement, startOrEnd === "end" ? arrow.points.length - 1 : 0,
elementsMap, elementsMap,
point: focusPoint, );
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true, // Check if the focus point is within the element's shape bounds
}); // Endpoint dragging takes precedence
return (
pointDistance(focusPoint, arrowPoint) >=
(FOCUS_POINT_SIZE * 1.5) / appState.zoom.value &&
hitElementItself({
element: bindableElement,
elementsMap,
point: focusPoint,
threshold: getBindingGap(bindableElement, arrow),
overrideShouldTestInside: true,
})
);
}; };
// Updates the arrow endpoints in "orbit" configuration // Updates the arrow endpoints in "orbit" configuration
@@ -129,6 +141,7 @@ const focusPointUpdate = (
currentBinding, currentBinding,
bindableElement, bindableElement,
elementsMap, elementsMap,
true,
); );
if (newPoint) { if (newPoint) {
@@ -353,6 +366,7 @@ export const handleFocusPointPointerDown = (
bindableElement, bindableElement,
elementsMap, elementsMap,
appState, appState,
"start",
) && ) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold pointDistance(pointerPos, focusPoint) <= hitThreshold
) { ) {
@@ -387,6 +401,7 @@ export const handleFocusPointPointerDown = (
bindableElement, bindableElement,
elementsMap, elementsMap,
appState, appState,
"end",
) && ) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold pointDistance(pointerPos, focusPoint) <= hitThreshold
) { ) {
@@ -501,6 +516,7 @@ export const handleFocusPointHover = (
bindableElement, bindableElement,
elementsMap, elementsMap,
appState, appState,
"start",
) && ) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold pointDistance(pointerPos, focusPoint) <= hitThreshold
) { ) {
@@ -529,6 +545,7 @@ export const handleFocusPointHover = (
bindableElement, bindableElement,
elementsMap, elementsMap,
appState, appState,
"end",
) && ) &&
pointDistance(pointerPos, focusPoint) <= hitThreshold pointDistance(pointerPos, focusPoint) <= hitThreshold
) { ) {
+165 -149
View File
@@ -27,11 +27,7 @@ import type { AppState } from "@excalidraw/excalidraw/types";
import type { MapEntry, Mutable } from "@excalidraw/common/utility-types"; import type { MapEntry, Mutable } from "@excalidraw/common/utility-types";
import type { Bounds } from "@excalidraw/common"; import type { Bounds } from "@excalidraw/common";
import { import { getCenterForBounds } from "./bounds";
doBoundsIntersect,
getCenterForBounds,
getElementBounds,
} from "./bounds";
import { import {
getAllHoveredElementAtPoint, getAllHoveredElementAtPoint,
getHoveredElementForBinding, getHoveredElementForBinding,
@@ -116,6 +112,7 @@ export type BindingStrategy =
*/ */
export const BASE_BINDING_GAP = 5; export const BASE_BINDING_GAP = 5;
export const BASE_BINDING_GAP_ELBOW = 5; export const BASE_BINDING_GAP_ELBOW = 5;
export const BASE_ARROW_MIN_LENGTH = 10;
export const FOCUS_POINT_SIZE = 10 / 1.5; export const FOCUS_POINT_SIZE = 10 / 1.5;
export const getBindingGap = ( export const getBindingGap = (
@@ -810,13 +807,23 @@ const getBindingStrategyForDraggingBindingElementEndpoints_simple = (
startDragged ? -1 : 0, startDragged ? -1 : 0,
elementsMap, elementsMap,
); );
const pointIsCloseToOtherElement =
otherFocusPoint &&
otherBindableElement &&
hitElementItself({
point: globalPoint,
element: otherBindableElement,
elementsMap,
threshold: maxBindingDistance_simple(appState.zoom),
overrideShouldTestInside: true,
});
const otherNeverOverride = opts?.newArrow const otherNeverOverride = opts?.newArrow
? appState.selectedLinearElement?.initialState.arrowStartIsInside ? appState.selectedLinearElement?.initialState.arrowStartIsInside
: otherBinding?.mode === "inside"; : otherBinding?.mode === "inside";
const other: BindingStrategy = !otherNeverOverride const other: BindingStrategy = !otherNeverOverride
? otherBindableElement && ? otherBindableElement &&
!otherFocusPointIsInElement && !otherFocusPointIsInElement &&
!pointIsCloseToOtherElement &&
appState.selectedLinearElement?.initialState.altFocusPoint appState.selectedLinearElement?.initialState.altFocusPoint
? { ? {
mode: "orbit", mode: "orbit",
@@ -1083,7 +1090,6 @@ export const updateBoundElements = (
options?: { options?: {
simultaneouslyUpdated?: readonly ExcalidrawElement[]; simultaneouslyUpdated?: readonly ExcalidrawElement[];
changedElements?: Map<string, ExcalidrawElement>; changedElements?: Map<string, ExcalidrawElement>;
indirectArrowUpdate?: boolean;
}, },
) => { ) => {
if (!isBindableElement(changedElement)) { if (!isBindableElement(changedElement)) {
@@ -1178,11 +1184,6 @@ export const updateBoundElements = (
}; };
boundElementsVisitor(elementsMap, changedElement, visitor); boundElementsVisitor(elementsMap, changedElement, visitor);
if (options?.indirectArrowUpdate) {
boundElementsVisitor(elementsMap, changedElement, visitor);
boundElementsVisitor(elementsMap, changedElement, visitor);
}
}; };
const updateArrowBindings = ( const updateArrowBindings = (
@@ -1692,10 +1693,41 @@ export const snapToMid = (
return undefined; return undefined;
}; };
const compareElementArea = ( const extractBinding = (
a: ExcalidrawBindableElement, arrow: ExcalidrawArrowElement,
b: ExcalidrawBindableElement, startOrEnd: "startBinding" | "endBinding",
) => b.width ** 2 + b.height ** 2 - (a.width ** 2 + a.height ** 2); elementsMap: ElementsMap,
) => {
const binding = arrow[startOrEnd];
if (!binding) {
return {
element: null,
fixedPoint: null,
focusPoint: null,
binding,
mode: null,
};
}
const element = elementsMap.get(
binding.elementId,
) as ExcalidrawBindableElement;
return {
element,
fixedPoint: binding.fixedPoint,
focusPoint: getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint),
element,
elementsMap,
),
binding,
mode: binding.mode,
};
};
const elementArea = (element: ExcalidrawBindableElement) =>
element.width * element.height;
export const updateBoundPoint = ( export const updateBoundPoint = (
arrow: NonDeleted<ExcalidrawArrowElement>, arrow: NonDeleted<ExcalidrawArrowElement>,
@@ -1703,9 +1735,7 @@ export const updateBoundPoint = (
binding: FixedPointBinding | null | undefined, binding: FixedPointBinding | null | undefined,
bindableElement: ExcalidrawBindableElement, bindableElement: ExcalidrawBindableElement,
elementsMap: ElementsMap, elementsMap: ElementsMap,
opts?: { dragging?: boolean,
customIntersector?: LineSegment<GlobalPoint>;
},
): LocalPoint | null => { ): LocalPoint | null => {
if ( if (
binding == null || binding == null ||
@@ -1720,150 +1750,136 @@ export const updateBoundPoint = (
return null; return null;
} }
const global = getGlobalFixedPointForBindableElement( const focusPoint = getGlobalFixedPointForBindableElement(
normalizeFixedPoint(binding.fixedPoint), normalizeFixedPoint(binding.fixedPoint),
bindableElement, bindableElement,
elementsMap, elementsMap,
); );
const pointIndex =
startOrEnd === "startBinding" ? 0 : arrow.points.length - 1;
const elbowed = isElbowArrow(arrow);
const otherBinding =
startOrEnd === "startBinding" ? arrow.endBinding : arrow.startBinding;
const otherBindableElement =
otherBinding &&
(elementsMap.get(otherBinding.elementId)! as ExcalidrawBindableElement);
const bounds = getElementBounds(bindableElement, elementsMap);
const otherBounds =
otherBindableElement && getElementBounds(otherBindableElement, elementsMap);
const isLargerThanOther =
otherBindableElement &&
compareElementArea(bindableElement, otherBindableElement) <
// if both shapes the same size, pretend the other is larger
(startOrEnd === "endBinding" ? 1 : 0);
const isOverlapping = otherBounds && doBoundsIntersect(bounds, otherBounds);
// GOAL: If the arrow becomes too short, we want to jump the arrow endpoints // 0. Short-circuit for inside binding as it doesn't require any
// to the exact focus points on the elements. // calculations and is not affected by other bindings
// INTUITION: We're not interested in the exacts length of the arrow (which if (binding.mode === "inside") {
// will change if we change where we route it), we want to know the length of return LinearElementEditor.createPointAt(
// the part which lies outside of both shapes and consider that as a trigger
// to change where we point the arrow. Avoids jumping the arrow in and out
// at every frame.
let arrowTooShort = false;
if (
!isOverlapping &&
!elbowed &&
arrow.startBinding &&
arrow.endBinding &&
otherBindableElement &&
arrow.points.length === 2
) {
const startFocusPoint = getGlobalFixedPointForBindableElement(
arrow.startBinding.fixedPoint,
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const endFocusPoint = getGlobalFixedPointForBindableElement(
arrow.endBinding.fixedPoint,
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
);
const segment = lineSegment(startFocusPoint, endFocusPoint);
const startIntersection = intersectElementWithLineSegment(
startOrEnd === "endBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
const endIntersection = intersectElementWithLineSegment(
startOrEnd === "startBinding" ? bindableElement : otherBindableElement,
elementsMap,
segment,
0,
true,
);
if (startIntersection.length > 0 && endIntersection.length > 0) {
const len = pointDistance(startIntersection[0], endIntersection[0]);
arrowTooShort = len < 40;
}
}
const isNested = (arrowTooShort || isOverlapping) && isLargerThanOther;
let _customIntersector = opts?.customIntersector;
if (!elbowed && !_customIntersector) {
const [x1, y1, x2, y2] = LinearElementEditor.getElementAbsoluteCoords(
arrow, arrow,
elementsMap, elementsMap,
); focusPoint[0],
const center = pointFrom<GlobalPoint>((x1 + x2) / 2, (y1 + y2) / 2); focusPoint[1],
const edgePoint = global; null,
const adjacentPoint = pointRotateRads(
pointFrom<GlobalPoint>(
arrow.x +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][0],
arrow.y +
arrow.points[pointIndex === 0 ? 1 : arrow.points.length - 2][1],
),
center,
arrow.angle as Radians,
);
const bindingGap = getBindingGap(bindableElement, arrow);
const halfVector = vectorScale(
vectorNormalize(vectorFromPoint(edgePoint, adjacentPoint)),
pointDistance(edgePoint, adjacentPoint) +
Math.max(bindableElement.width, bindableElement.height) +
bindingGap * 2,
);
_customIntersector = lineSegment(
pointFromVector(halfVector, adjacentPoint),
pointFromVector(vectorScale(halfVector, -1), adjacentPoint),
); );
} }
const maybeOutlineGlobal = const { element: otherBindable, focusPoint: otherFocusPoint } =
binding.mode === "orbit" && bindableElement extractBinding(
? isNested arrow,
? global startOrEnd === "startBinding" ? "endBinding" : "startBinding",
: bindPointToSnapToElementOutline( elementsMap,
{ );
...arrow, const otherArrowPoint = LinearElementEditor.getPointAtIndexGlobalCoordinates(
points: [ arrow,
pointIndex === 0 startOrEnd === "startBinding" ? -1 : 0,
? LinearElementEditor.createPointAt( elementsMap,
arrow, );
elementsMap, const otherFocusPointOrArrowPoint = otherFocusPoint || otherArrowPoint;
global[0], const intersector =
global[1], otherFocusPointOrArrowPoint &&
null, lineSegment(focusPoint, otherFocusPointOrArrowPoint);
) const otherOutlinePoint =
: arrow.points[0], otherBindable &&
...arrow.points.slice(1, -1), intersector &&
pointIndex === arrow.points.length - 1 intersectElementWithLineSegment(
? LinearElementEditor.createPointAt( otherBindable,
arrow, elementsMap,
elementsMap, intersector,
global[0], getBindingGap(otherBindable, arrow),
global[1], ).sort(
null, (a, b) => pointDistanceSq(a, focusPoint) - pointDistanceSq(b, focusPoint),
) )[0];
: arrow.points[arrow.points.length - 1], const outlinePoint =
], intersector &&
}, intersectElementWithLineSegment(
bindableElement, bindableElement,
pointIndex === 0 ? "start" : "end", elementsMap,
elementsMap, intersector,
_customIntersector, getBindingGap(bindableElement, arrow),
) ).sort(
: global; (a, b) =>
pointDistanceSq(a, otherFocusPointOrArrowPoint) -
pointDistanceSq(b, otherFocusPointOrArrowPoint),
)[0];
const startHasArrowhead = arrow.startArrowhead !== null;
const endHasArrowhead = arrow.endArrowhead !== null;
const resolvedTarget =
(!startHasArrowhead && !endHasArrowhead) ||
(startOrEnd === "startBinding" && startHasArrowhead) ||
(startOrEnd === "endBinding" && endHasArrowhead)
? focusPoint
: outlinePoint || focusPoint;
// 1. Handle case when the outline point (or focus point) is inside
// the other shape by short-circuiting to the focus point, otherwise
// the arrow would invert
if (
otherBindable &&
outlinePoint &&
!dragging &&
// Arbitrary threshold to handle wireframing use cases
elementArea(otherBindable) < elementArea(bindableElement) * 2 &&
hitElementItself({
element: otherBindable,
point: outlinePoint,
elementsMap,
threshold: getBindingGap(otherBindable, arrow),
overrideShouldTestInside: true,
})
) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
resolvedTarget[0],
resolvedTarget[1],
null,
);
}
const otherTargetPoint = otherBindable
? otherOutlinePoint || otherFocusPoint || otherArrowPoint
: otherArrowPoint;
const arrowTooShort =
pointDistance(otherTargetPoint, outlinePoint || focusPoint) <=
BASE_ARROW_MIN_LENGTH;
// 2. If the arrow is unconnected at the other end, just check arrow size
// and short-circuit to the focus point if the arrow is too short to
// avoid inversion
if (!otherBindable) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
arrowTooShort ? focusPoint[0] : outlinePoint?.[0] ?? focusPoint[0],
arrowTooShort ? focusPoint[1] : outlinePoint?.[1] ?? focusPoint[1],
null,
);
}
// 3. If the arrow is too short while connected on both ends and
// the other arrow endpoint will not be inside the bindable, just
// check the arrow size and make a decision based on that
if (arrowTooShort) {
return LinearElementEditor.createPointAt(
arrow,
elementsMap,
resolvedTarget?.[0] || focusPoint[0],
resolvedTarget?.[1] || focusPoint[1],
null,
);
}
// 4. In the general case, snap to the outline if possible
return LinearElementEditor.createPointAt( return LinearElementEditor.createPointAt(
arrow, arrow,
elementsMap, elementsMap,
maybeOutlineGlobal[0], outlinePoint?.[0] || focusPoint[0],
maybeOutlineGlobal[1], outlinePoint?.[1] || focusPoint[1],
null, null,
); );
}; };
+79 -2
View File
@@ -56,7 +56,7 @@ const RE_REDDIT =
const RE_REDDIT_EMBED = const RE_REDDIT_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:www\.)?reddit\.com\/[^"']*)/i;
const parseYouTubeTimestamp = (url: string): number => { const parseYouTubeLikeTimestamp = (url: string): number => {
let timeParam: string | null | undefined; let timeParam: string | null | undefined;
try { try {
@@ -85,11 +85,57 @@ const parseYouTubeTimestamp = (url: string): number => {
return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds); return parseInt(hours) * 3600 + parseInt(minutes) * 60 + parseInt(seconds);
}; };
const parseGoogleDriveVideoLink = (
url: string,
): { fileId: string; resourceKey?: string; timestamp?: number } | null => {
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`);
const hostname = urlObj.hostname.replace(/^www\./, "");
if (hostname !== "drive.google.com") {
return null;
}
let fileId: string | null = null;
const pathMatch = urlObj.pathname.match(/^\/file\/d\/([^/]+)(?:\/|$)/);
if (pathMatch?.[1]) {
fileId = pathMatch[1];
} else if (urlObj.pathname === "/open" || urlObj.pathname === "/uc") {
// Shared Drive links can be emitted as:
// - /open?id=<fileId> (common "open in Drive" format)
// - /uc?...&id=<fileId> (download/export endpoint often seen in copied links)
fileId = urlObj.searchParams.get("id");
}
if (!fileId || !/^[a-zA-Z0-9_-]+$/.test(fileId)) {
return null;
}
// Some Drive share links include `resourcekey` for access to link-shared
// files; preserve it in the preview URL so embeds keep working.
const resourceKey = urlObj.searchParams.get("resourcekey");
const timestamp = parseYouTubeLikeTimestamp(urlObj.toString());
return {
fileId,
resourceKey:
resourceKey && /^[a-zA-Z0-9_-]+$/.test(resourceKey)
? resourceKey
: undefined,
// Drive accepts YouTube-like `t` formats (e.g. `t=90`, `t=1m30s`);
// normalize to seconds for a stable preview URL.
timestamp: timestamp > 0 ? timestamp : undefined,
};
} catch (error) {
return null;
}
};
const ALLOWED_DOMAINS = new Set([ const ALLOWED_DOMAINS = new Set([
"youtube.com", "youtube.com",
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"link.excalidraw.com", "link.excalidraw.com",
"gist.github.com", "gist.github.com",
@@ -108,6 +154,7 @@ const ALLOW_SAME_ORIGIN = new Set([
"youtu.be", "youtu.be",
"vimeo.com", "vimeo.com",
"player.vimeo.com", "player.vimeo.com",
"drive.google.com",
"figma.com", "figma.com",
"twitter.com", "twitter.com",
"x.com", "x.com",
@@ -142,7 +189,7 @@ export const getEmbedLink = (
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
if (ytLink?.[2]) { if (ytLink?.[2]) {
const startTime = parseYouTubeTimestamp(originalLink); const startTime = parseYouTubeLikeTimestamp(originalLink);
const time = startTime > 0 ? `&start=${startTime}` : ``; const time = startTime > 0 ? `&start=${startTime}` : ``;
const isPortrait = link.includes("shorts"); const isPortrait = link.includes("shorts");
type = "video"; type = "video";
@@ -201,6 +248,36 @@ export const getEmbedLink = (
}; };
} }
const googleDriveVideo = parseGoogleDriveVideoLink(link);
if (googleDriveVideo) {
type = "video";
const searchParams = new URLSearchParams();
if (googleDriveVideo.resourceKey) {
searchParams.set("resourcekey", googleDriveVideo.resourceKey);
}
if (googleDriveVideo.timestamp) {
searchParams.set("t", `${googleDriveVideo.timestamp}`);
}
const search = searchParams.toString();
link = `https://drive.google.com/file/d/${googleDriveVideo.fileId}/preview${
search ? `?${search}` : ""
}`;
aspectRatio = { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
intrinsicSize: aspectRatio,
type,
sandbox: { allowSameOrigin },
};
}
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
if (figmaLink) { if (figmaLink) {
type = "generic"; type = "generic";
+2 -18
View File
@@ -9,7 +9,6 @@ import {
vectorFromPoint, vectorFromPoint,
curveLength, curveLength,
curvePointAtLength, curvePointAtLength,
lineSegment,
} from "@excalidraw/math"; } from "@excalidraw/math";
import { getCurvePathOps } from "@excalidraw/utils/shape"; import { getCurvePathOps } from "@excalidraw/utils/shape";
@@ -2339,19 +2338,6 @@ const pointDraggingUpdates = (
: updates.endBinding, : updates.endBinding,
}; };
// We need to use a custom intersector to ensure that if there is a big "jump"
// in the arrow's position, we can position it with outline avoidance
// pixel-perfectly and avoid "dancing" arrows.
// NOTE: Direction matters here, so we create two intersectors
const startCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(start.focusPoint, end.focusPoint)
: undefined;
const endCustomIntersector =
start.focusPoint && end.focusPoint
? lineSegment(end.focusPoint, start.focusPoint)
: undefined;
// Needed to handle a special case where an existing arrow is dragged over // Needed to handle a special case where an existing arrow is dragged over
// the same element it is bound to on the other side // the same element it is bound to on the other side
const startIsDraggingOverEndElement = const startIsDraggingOverEndElement =
@@ -2387,9 +2373,7 @@ const pointDraggingUpdates = (
nextArrow.endBinding, nextArrow.endBinding,
endBindable, endBindable,
elementsMap, elementsMap,
{ endIsDragged,
customIntersector: endCustomIntersector,
},
) || nextArrow.points[nextArrow.points.length - 1] ) || nextArrow.points[nextArrow.points.length - 1]
: nextArrow.points[nextArrow.points.length - 1]; : nextArrow.points[nextArrow.points.length - 1];
@@ -2420,7 +2404,7 @@ const pointDraggingUpdates = (
nextArrow.startBinding, nextArrow.startBinding,
startBindable, startBindable,
elementsMap, elementsMap,
{ customIntersector: startCustomIntersector }, startIsDragged,
) || nextArrow.points[0] ) || nextArrow.points[0]
: nextArrow.points[0]; : nextArrow.points[0];
+30 -1
View File
@@ -43,6 +43,11 @@ import { LinearElementEditor } from "./linearElementEditor";
import { isRectangularElement } from "./typeChecks"; import { isRectangularElement } from "./typeChecks";
import { maxBindingDistance_simple } from "./binding"; import { maxBindingDistance_simple } from "./binding";
import {
getGlobalFixedPointForBindableElement,
normalizeFixedPoint,
} from "./binding";
import type { import type {
ElementsMap, ElementsMap,
ExcalidrawArrowElement, ExcalidrawArrowElement,
@@ -677,11 +682,35 @@ export const projectFixedPointOntoDiagonal = (
elementsMap, elementsMap,
); );
const a = LinearElementEditor.getPointAtIndexGlobalCoordinates( // To avoid working with stale arrow state, we use the opposite focus point
// of the current endpoint, which will always be unchanged during moving of
// the endpoint. This is only needed when the arrow has only two points.
let a = LinearElementEditor.getPointAtIndexGlobalCoordinates(
arrow, arrow,
startOrEnd === "start" ? 1 : arrow.points.length - 2, startOrEnd === "start" ? 1 : arrow.points.length - 2,
elementsMap, elementsMap,
); );
if (arrow.points.length === 2) {
const otherBinding =
startOrEnd === "start" ? arrow.endBinding : arrow.startBinding;
const otherBindable =
otherBinding &&
(elementsMap.get(otherBinding.elementId) as
| ExcalidrawBindableElement
| undefined);
const otherFocusPoint =
otherBinding &&
otherBindable &&
getGlobalFixedPointForBindableElement(
normalizeFixedPoint(otherBinding.fixedPoint),
otherBindable,
elementsMap,
);
if (otherFocusPoint) {
a = otherFocusPoint;
}
}
const b = pointFromVector<GlobalPoint>( const b = pointFromVector<GlobalPoint>(
vectorScale( vectorScale(
vectorFromPoint(point, a), vectorFromPoint(point, a),
+81 -1
View File
@@ -1,4 +1,4 @@
import { getEmbedLink } from "../src/embeddable"; import { embeddableURLValidator, getEmbedLink } from "../src/embeddable";
describe("YouTube timestamp parsing", () => { describe("YouTube timestamp parsing", () => {
it("should parse YouTube URLs with timestamp in seconds", () => { it("should parse YouTube URLs with timestamp in seconds", () => {
@@ -151,3 +151,83 @@ describe("YouTube timestamp parsing", () => {
} }
}); });
}); });
describe("Google Drive video embedding", () => {
it.each([
{
url: "https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?usp=sharing",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/open?id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
{
url: "https://drive.google.com/uc?export=download&id=1AbCdEfGhIjKlMnOpQrStUvWxYz123456",
expectedLink:
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview",
},
])("should normalize Google Drive link: $url", ({ url, expectedLink }) => {
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(expectedLink);
}
expect(result?.intrinsicSize).toEqual({ w: 560, h: 315 });
});
it("should preserve resourcekey when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456",
);
}
});
it("should preserve timestamp when available", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?t=9",
);
}
});
it("should preserve resourcekey and timestamp together", () => {
const url =
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view?resourcekey=0-abcdef123456&t=9";
const result = getEmbedLink(url);
expect(result).toBeTruthy();
expect(result?.type).toBe("video");
if (result?.type === "video" || result?.type === "generic") {
expect(result.link).toBe(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/preview?resourcekey=0-abcdef123456&t=9",
);
}
});
it("should validate Google Drive domain by default", () => {
expect(
embeddableURLValidator(
"https://drive.google.com/file/d/1AbCdEfGhIjKlMnOpQrStUvWxYz123456/view",
undefined,
),
).toBe(true);
});
});
@@ -1555,7 +1555,7 @@ const getArrowheadOptions = (flip: boolean) => {
value: null, value: null,
text: t("labels.arrowhead_none"), text: t("labels.arrowhead_none"),
keyBinding: "q", keyBinding: "q",
icon: ArrowheadNoneIcon, icon: <ArrowheadNoneIcon flip={flip} />,
}, },
{ {
value: "arrow", value: "arrow",
+132 -94
View File
@@ -442,10 +442,7 @@ import { searchItemInFocusAtom } from "./SearchMenu";
import { isSidebarDockedAtom } from "./Sidebar/Sidebar"; import { isSidebarDockedAtom } from "./Sidebar/Sidebar";
import { StaticCanvas, InteractiveCanvas } from "./canvases"; import { StaticCanvas, InteractiveCanvas } from "./canvases";
import NewElementCanvas from "./canvases/NewElementCanvas"; import NewElementCanvas from "./canvases/NewElementCanvas";
import { import { isPointHittingLink } from "./hyperlink/helpers";
isPointHittingLink,
isPointHittingLinkIcon,
} from "./hyperlink/helpers";
import { MagicIcon, copyIcon, fullscreenIcon } from "./icons"; import { MagicIcon, copyIcon, fullscreenIcon } from "./icons";
import { Toast } from "./Toast"; import { Toast } from "./Toast";
@@ -1210,12 +1207,99 @@ class App extends React.Component<AppProps, AppState> {
return this.iFrameRefs.get(element.id); return this.iFrameRefs.get(element.id);
} }
private handleEmbeddableCenterClick(element: ExcalidrawIframeLikeElement) { private handleIframeLikeElementHover = ({
hitElement,
scenePointer,
moveEvent,
}: {
hitElement: NonDeleted<ExcalidrawElement> | null;
scenePointer: { x: number; y: number };
moveEvent: React.PointerEvent<HTMLCanvasElement>;
}): boolean => {
if ( if (
this.state.activeEmbeddable?.element === element && hitElement &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
moveEvent,
scenePointer.x,
scenePointer.y,
)
) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER);
this.setState({
activeEmbeddable: { element: hitElement, state: "hover" },
});
return true;
} else if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
return false;
};
/** @returns true if iframe-like element click handled */
private handleIframeLikeCenterClick(): boolean {
if (!this.lastPointerDownEvent || !this.lastPointerUpEvent) {
return false;
}
const scenePointerStart = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerDownEvent.clientX,
clientY: this.lastPointerDownEvent.clientY,
},
this.state,
);
const scenePointerEnd = viewportCoordsToSceneCoords(
{
clientX: this.lastPointerUpEvent.clientX,
clientY: this.lastPointerUpEvent.clientY,
},
this.state,
);
const hitElementStart = this.getElementAtPosition(
scenePointerStart.x,
scenePointerStart.y,
);
const hitElementEnd = this.getElementAtPosition(
scenePointerEnd.x,
scenePointerEnd.y,
);
if (
!hitElementStart ||
!hitElementEnd ||
hitElementStart !== hitElementEnd ||
this.lastPointerUpEvent.timeStamp - this.lastPointerDownEvent.timeStamp >
300 ||
gesture.pointers.size > 1 ||
!isIframeLikeElement(hitElementStart) ||
!isIframeLikeElement(hitElementEnd) ||
!this.isIframeLikeElementCenter(
hitElementStart,
this.lastPointerUpEvent,
scenePointerStart.x,
scenePointerStart.y,
) ||
!this.isIframeLikeElementCenter(
hitElementEnd,
this.lastPointerUpEvent,
scenePointerEnd.x,
scenePointerEnd.y,
)
) {
return false;
}
const iframeLikeElement = hitElementEnd;
if (
this.state.activeEmbeddable?.element === iframeLikeElement &&
this.state.activeEmbeddable?.state === "active" this.state.activeEmbeddable?.state === "active"
) { ) {
return; return true;
} }
// The delay serves two purposes // The delay serves two purposes
@@ -1226,31 +1310,34 @@ class App extends React.Component<AppProps, AppState> {
// in fullscreen mode // in fullscreen mode
setTimeout(() => { setTimeout(() => {
this.setState({ this.setState({
activeEmbeddable: { element, state: "active" }, activeEmbeddable: { element: iframeLikeElement, state: "active" },
selectedElementIds: { [element.id]: true }, selectedElementIds: { [iframeLikeElement.id]: true },
newElement: null, newElement: null,
selectionElement: null, selectionElement: null,
}); });
}, 100); }, 100);
if (isIframeElement(element)) { if (isIframeElement(iframeLikeElement)) {
return; return true;
} }
const iframe = this.getHTMLIFrameElement(element); const iframe = this.getHTMLIFrameElement(iframeLikeElement);
if (!iframe?.contentWindow) { if (!iframe?.contentWindow) {
return; return true;
} }
if (iframe.src.includes("youtube")) { if (iframe.src.includes("youtube")) {
const state = YOUTUBE_VIDEO_STATES.get(element.id); const state = YOUTUBE_VIDEO_STATES.get(iframeLikeElement.id);
if (!state) { if (!state) {
YOUTUBE_VIDEO_STATES.set(element.id, YOUTUBE_STATES.UNSTARTED); YOUTUBE_VIDEO_STATES.set(
iframeLikeElement.id,
YOUTUBE_STATES.UNSTARTED,
);
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
JSON.stringify({ JSON.stringify({
event: "listening", event: "listening",
id: element.id, id: iframeLikeElement.id,
}), }),
"*", "*",
); );
@@ -1287,6 +1374,8 @@ class App extends React.Component<AppProps, AppState> {
"*", "*",
); );
} }
return true;
} }
private isIframeLikeElementCenter( private isIframeLikeElementCenter(
@@ -6622,10 +6711,14 @@ class App extends React.Component<AppProps, AppState> {
} }
} }
const hasDeselectedButton = Boolean(event.buttons); const isPressingAnyButton = Boolean(event.buttons);
const isLaserTool = this.state.activeTool.type === "laser";
if ( if (
hasDeselectedButton || isPressingAnyButton ||
(this.state.activeTool.type !== "selection" && // checking against laser so that if you mouseover with a laser tool
// over a link/embeddable, we change the cursor
(!isLaserTool &&
this.state.activeTool.type !== "selection" &&
this.state.activeTool.type !== "lasso" && this.state.activeTool.type !== "lasso" &&
this.state.activeTool.type !== "text" && this.state.activeTool.type !== "text" &&
this.state.activeTool.type !== "eraser") this.state.activeTool.type !== "eraser")
@@ -6745,6 +6838,14 @@ class App extends React.Component<AppProps, AppState> {
); );
} else { } else {
hideHyperlinkToolip(); hideHyperlinkToolip();
if (isLaserTool) {
this.handleIframeLikeElementHover({
hitElement,
scenePointer,
moveEvent: event,
});
return;
}
if ( if (
hitElement && hitElement &&
(hitElement.link || isEmbeddableElement(hitElement)) && (hitElement.link || isEmbeddableElement(hitElement)) &&
@@ -6777,24 +6878,15 @@ class App extends React.Component<AppProps, AppState> {
!hitElement?.locked !hitElement?.locked
) { ) {
if ( if (
hitElement && !this.handleIframeLikeElementHover({
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement, hitElement,
event, scenePointer,
scenePointerX, moveEvent: event,
scenePointerY, }) &&
) (!hitElement ||
) { // Elbow arrows can only be moved when unconnected
setCursor(this.interactiveCanvas, CURSOR_TYPE.POINTER); !isElbowArrow(hitElement) ||
this.setState({ !(hitElement.startBinding || hitElement.endBinding))
activeEmbeddable: { element: hitElement, state: "hover" },
});
} else if (
!hitElement ||
// Elbow arrows can only be moved when unconnected
!isElbowArrow(hitElement) ||
!(hitElement.startBinding || hitElement.endBinding)
) { ) {
if ( if (
this.state.activeTool.type !== "lasso" || this.state.activeTool.type !== "lasso" ||
@@ -6802,9 +6894,6 @@ class App extends React.Component<AppProps, AppState> {
) { ) {
setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE); setCursor(this.interactiveCanvas, CURSOR_TYPE.MOVE);
} }
if (this.state.activeEmbeddable?.state === "hover") {
this.setState({ activeEmbeddable: null });
}
} }
} }
} else { } else {
@@ -7456,26 +7545,9 @@ class App extends React.Component<AppProps, AppState> {
x: scenePointerX, x: scenePointerX,
y: scenePointerY, y: scenePointerY,
}; };
const clicklength =
event.timeStamp - (this.lastPointerDownEvent?.timeStamp ?? 0);
if (this.editorInterface.formFactor === "phone" && clicklength < 300) { if (this.handleIframeLikeCenterClick()) {
const hitElement = this.getElementAtPosition( return;
scenePointer.x,
scenePointer.y,
);
if (
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
event,
scenePointer.x,
scenePointer.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
return;
}
} }
if (this.editorInterface.isTouchScreen) { if (this.editorInterface.isTouchScreen) {
@@ -7496,20 +7568,7 @@ class App extends React.Component<AppProps, AppState> {
this.hitLinkElement && this.hitLinkElement &&
!this.state.selectedElementIds[this.hitLinkElement.id] !this.state.selectedElementIds[this.hitLinkElement.id]
) { ) {
if ( this.redirectToLink(event, this.editorInterface.isTouchScreen);
clicklength < 300 &&
isIframeLikeElement(this.hitLinkElement) &&
!isPointHittingLinkIcon(
this.hitLinkElement,
this.scene.getNonDeletedElementsMap(),
this.state,
pointFrom(scenePointer.x, scenePointer.y),
)
) {
this.handleEmbeddableCenterClick(this.hitLinkElement);
} else {
this.redirectToLink(event, this.editorInterface.isTouchScreen);
}
} else if (this.state.viewModeEnabled) { } else if (this.state.viewModeEnabled) {
this.setState({ this.setState({
activeEmbeddable: null, activeEmbeddable: null,
@@ -9623,9 +9682,7 @@ class App extends React.Component<AppProps, AppState> {
isBindableElement(element) && isBindableElement(element) &&
element.boundElements?.some((other) => other.type === "arrow") element.boundElements?.some((other) => other.type === "arrow")
) { ) {
updateBoundElements(element, this.scene, { updateBoundElements(element, this.scene);
indirectArrowUpdate: true,
});
} }
}); });
@@ -10854,25 +10911,6 @@ class App extends React.Component<AppProps, AppState> {
suggestedBinding: null, suggestedBinding: null,
}); });
} }
if (
hitElement &&
this.lastPointerUpEvent &&
this.lastPointerDownEvent &&
this.lastPointerUpEvent.timeStamp -
this.lastPointerDownEvent.timeStamp <
300 &&
gesture.pointers.size <= 1 &&
isIframeLikeElement(hitElement) &&
this.isIframeLikeElementCenter(
hitElement,
this.lastPointerUpEvent,
pointerDownState.origin.x,
pointerDownState.origin.y,
)
) {
this.handleEmbeddableCenterClick(hitElement);
}
}); });
} }
+15 -7
View File
@@ -1287,13 +1287,21 @@ export const EdgeRoundIcon = createIcon(
tablerIconProps, tablerIconProps,
); );
export const ArrowheadNoneIcon = createIcon( export const ArrowheadNoneIcon = React.memo(
<g stroke="currentColor" opacity={0.3} strokeWidth={2}> ({ flip = false }: { flip?: boolean }) =>
<path d="M12 12l9 0" /> createIcon(
<path d="M3 9l6 6" /> <g
<path d="M3 15l6 -6" /> transform={flip ? "translate(24, 0) scale(-1, 1)" : ""}
</g>, stroke="currentColor"
tablerIconProps, opacity={0.3}
strokeWidth={2}
>
<path d="M12 12l-9 0" />
<path d="M21 9l-6 6" />
<path d="M21 15l-6 -6" />
</g>,
tablerIconProps,
),
); );
export const ArrowheadArrowIcon = React.memo( export const ArrowheadArrowIcon = React.memo(
@@ -1269,6 +1269,7 @@ const renderFocusPointIndicator = ({
bindableElement, bindableElement,
elementsMap, elementsMap,
appState, appState,
type,
) )
) { ) {
return; return;
@@ -227,7 +227,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": 33, "version": 29,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
@@ -334,15 +334,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"height": "105.58873", "height": "105.58874",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
88, "88.00000",
"105.58873", "105.58874",
], ],
], ],
"startBinding": { "startBinding": {
@@ -353,8 +353,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 32, "version": 28,
"width": 88, "width": "88.00000",
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -365,7 +365,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"height": "0.00000", "height": 0,
"points": [ "points": [
[ [
0, 0,
@@ -373,7 +373,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
[ [
"88.00000", "88.00000",
"0.00000", 0,
], ],
], ],
"startBinding": { "startBinding": {
@@ -384,7 +384,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 29, "version": 25,
"width": "88.00000", "width": "88.00000",
}, },
}, },
@@ -440,21 +440,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 33, "version": 29,
"width": "94.00000", "width": "94.00000",
"x": 0, "x": 0,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
"height": "105.58873", "height": "105.58874",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
88, "88.00000",
"105.58873", "105.58874",
], ],
], ],
"startBinding": { "startBinding": {
@@ -465,10 +465,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 32, "version": 28,
"width": 88, "width": "88.00000",
"x": 6, "x": 6,
"y": "7.09000", "y": "7.20923",
}, },
}, },
}, },
@@ -854,7 +854,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": 30, "version": 25,
"width": 100, "width": 100,
"x": 150, "x": 150,
"y": 0, "y": 0,
@@ -904,15 +904,15 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"id4": { "id4": {
"deleted": { "deleted": {
"endBinding": null, "endBinding": null,
"height": "0.00293", "height": "0.01000",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"-44.00000", -44,
"-0.00293", "-0.01000",
], ],
], ],
"startBinding": { "startBinding": {
@@ -923,9 +923,9 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 29, "version": 24,
"width": "44.00000", "width": 44,
"y": "0.00293", "y": "0.01000",
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -943,7 +943,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"6.00000", -100,
0, 0,
], ],
], ],
@@ -955,8 +955,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 27, "version": 23,
"width": "6.00000", "width": 100,
"y": "0.01000", "y": "0.01000",
}, },
}, },
@@ -1004,21 +1004,21 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
], ],
"startBinding": null, "startBinding": null,
"version": 30, "version": 25,
"width": 100, "width": 100,
"x": 150, "x": 150,
"y": 0, "y": 0,
}, },
"inserted": { "inserted": {
"height": "0.00293", "height": "0.01000",
"points": [ "points": [
[ [
0, 0,
0, 0,
], ],
[ [
"-44.00000", -44,
"-0.00293", "-0.01000",
], ],
], ],
"startBinding": { "startBinding": {
@@ -1029,10 +1029,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 29, "version": 24,
"width": "44.00000", "width": 44,
"x": 144, "x": 250,
"y": "0.00293", "y": "0.01000",
}, },
}, },
}, },
@@ -1335,7 +1335,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "29.36414", "height": "29.32551",
"id": "id4", "id": "id4",
"index": "Zz", "index": "Zz",
"isDeleted": false, "isDeleted": false,
@@ -1350,7 +1350,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
[ [
88, 88,
"29.36414", "29.32551",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -1369,10 +1369,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 10, "version": 8,
"width": 88, "width": 88,
"x": 6, "x": 6,
"y": "2.00946", "y": "2.00947",
} }
`; `;
@@ -1546,7 +1546,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 10, "version": 8,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
@@ -1698,7 +1698,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "14.91372", "height": "17.59718",
"id": "id5", "id": "id5",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
@@ -1712,8 +1712,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"88.00000", 88,
"-14.91372", "-17.59718",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -1732,10 +1732,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 11, "version": 8,
"width": "88.00000", "width": 88,
"x": 6, "x": 6,
"y": "37.05219", "y": "38.80379",
} }
`; `;
@@ -1846,7 +1846,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "14.91372", "height": "17.59718",
"index": "a0", "index": "a0",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -1858,8 +1858,8 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
0, 0,
], ],
[ [
"88.00000", 88,
"-14.91372", "-17.59718",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -1877,14 +1877,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 11, "version": 8,
"width": "88.00000", "width": 88,
"x": 6, "x": 6,
"y": "37.05219", "y": "38.80379",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 8, "version": 7,
}, },
}, },
}, },
@@ -2398,7 +2398,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "439.13521", "height": "439.20000",
"id": "id4", "id": "id4",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -2413,7 +2413,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
[ [
488, 488,
"-439.13521", "-439.20000",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -2434,10 +2434,10 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 13, "version": 12,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.38920", "y": "-5.39000",
} }
`; `;
@@ -2566,7 +2566,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "439.13521", "height": "439.20000",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -2579,7 +2579,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
], ],
[ [
488, 488,
"-439.13521", "-439.20000",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -2599,14 +2599,14 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 13, "version": 12,
"width": 488, "width": 488,
"x": 6, "x": 6,
"y": "-5.38920", "y": "-5.39000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 10, "version": 9,
}, },
}, },
}, },
@@ -16383,7 +16383,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"id": "id13", "id": "id13",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
@@ -16398,7 +16398,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -16419,10 +16419,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
} }
`; `;
@@ -16472,7 +16472,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 12, "version": 10,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -16492,7 +16492,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 9, "version": 8,
}, },
}, },
}, },
@@ -16801,7 +16801,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00936", "height": "0.00120",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -16813,8 +16813,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
88, "88.00000",
"0.00936", "0.00120",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -16834,14 +16834,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 8, "version": 7,
"width": 88, "width": "88.00000",
"x": 6, "x": 6,
"y": 0, "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 7, "version": 6,
}, },
}, },
}, },
@@ -17134,7 +17134,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"id": "id13", "id": "id13",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
@@ -17149,7 +17149,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -17170,10 +17170,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
} }
`; `;
@@ -17442,7 +17442,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -17455,7 +17455,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -17475,14 +17475,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 12, "version": 10,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 9, "version": 8,
}, },
}, },
}, },
@@ -17783,7 +17783,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"id": "id13", "id": "id13",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
@@ -17798,7 +17798,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -17819,10 +17819,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
} }
`; `;
@@ -18091,7 +18091,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -18104,7 +18104,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -18124,14 +18124,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 12, "version": 10,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 9, "version": 8,
}, },
}, },
}, },
@@ -18430,7 +18430,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"id": "id13", "id": "id13",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
@@ -18445,7 +18445,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -18466,10 +18466,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 12, "version": 10,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
} }
`; `;
@@ -18535,7 +18535,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 12, "version": 10,
}, },
"inserted": { "inserted": {
"endBinding": { "endBinding": {
@@ -18547,7 +18547,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"mode": "orbit", "mode": "orbit",
}, },
"startBinding": null, "startBinding": null,
"version": 9, "version": 8,
}, },
}, },
"id2": { "id2": {
@@ -18824,7 +18824,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00936", "height": "0.00120",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -18836,8 +18836,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
88, "88.00000",
"0.00936", "0.00120",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -18857,14 +18857,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 8, "version": 7,
"width": 88, "width": "88.00000",
"x": 6, "x": 6,
"y": 0, "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 7, "version": 6,
}, },
}, },
}, },
@@ -19185,7 +19185,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00004", "height": 0,
"id": "id13", "id": "id13",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
@@ -19200,7 +19200,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
[ [
"88.00000", "88.00000",
"0.00004", 0,
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -19221,10 +19221,10 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"updated": 1, "updated": 1,
"version": 13, "version": 11,
"width": "88.00000", "width": "88.00000",
"x": 6, "x": 6,
"y": "0.00996", "y": "0.01000",
} }
`; `;
@@ -19301,12 +19301,12 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
], ],
"mode": "orbit", "mode": "orbit",
}, },
"version": 13, "version": 11,
}, },
"inserted": { "inserted": {
"endBinding": null, "endBinding": null,
"startBinding": null, "startBinding": null,
"version": 10, "version": 9,
}, },
}, },
}, },
@@ -19575,7 +19575,7 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "0.00936", "height": "0.00120",
"index": "a3", "index": "a3",
"isDeleted": false, "isDeleted": false,
"link": null, "link": null,
@@ -19587,8 +19587,8 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
0, 0,
], ],
[ [
88, "88.00000",
"0.00936", "0.00120",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -19608,14 +19608,14 @@ exports[`history > singleplayer undo/redo > should support bidirectional binding
"strokeStyle": "solid", "strokeStyle": "solid",
"strokeWidth": 2, "strokeWidth": 2,
"type": "arrow", "type": "arrow",
"version": 8, "version": 7,
"width": 88, "width": "88.00000",
"x": 6, "x": 6,
"y": 0, "y": "0.00880",
}, },
"inserted": { "inserted": {
"isDeleted": true, "isDeleted": true,
"version": 7, "version": 6,
}, },
}, },
}, },
@@ -182,14 +182,14 @@ exports[`move element > rectangles with binding arrow 7`] = `
"elementId": "id3", "elementId": "id3",
"fixedPoint": [ "fixedPoint": [
"-0.02000", "-0.02000",
"0.47904", "0.48010",
], ],
"mode": "orbit", "mode": "orbit",
}, },
"fillStyle": "solid", "fillStyle": "solid",
"frameId": null, "frameId": null,
"groupIds": [], "groupIds": [],
"height": "90.02554", "height": "90.01760",
"id": "id6", "id": "id6",
"index": "a2", "index": "a2",
"isDeleted": false, "isDeleted": false,
@@ -204,7 +204,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
], ],
[ [
89, 89,
"90.02554", "90.01760",
], ],
], ],
"roughness": 1, "roughness": 1,
@@ -217,7 +217,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
"elementId": "id0", "elementId": "id0",
"fixedPoint": [ "fixedPoint": [
"1.06000", "1.06000",
"0.55687", "0.56011",
], ],
"mode": "orbit", "mode": "orbit",
}, },
@@ -230,6 +230,6 @@ exports[`move element > rectangles with binding arrow 7`] = `
"versionNonce": 271613161, "versionNonce": 271613161,
"width": 89, "width": 89,
"x": 106, "x": 106,
"y": "55.68677", "y": "56.01120",
} }
`; `;
+109
View File
@@ -0,0 +1,109 @@
import { vi } from "vitest";
import { CURSOR_TYPE } from "@excalidraw/common";
import { getElementAbsoluteCoords } from "@excalidraw/element";
import { Excalidraw } from "../index";
import { getLinkHandleFromCoords } from "../components/hyperlink/helpers";
import { API } from "./helpers/api";
import { Pointer } from "./helpers/ui";
import { act, GlobalTestState, render, waitFor } from "./test-utils";
import type { ExcalidrawProps } from "../types";
describe("laser tool interactions", () => {
const h = window.h;
const mouse = new Pointer("mouse");
it("opens links while using the laser tool", async () => {
const onLinkOpenSpy = vi.fn();
const onLinkOpen: NonNullable<ExcalidrawProps["onLinkOpen"]> = (
...args
) => {
onLinkOpenSpy(...args);
args[1].preventDefault();
};
await render(<Excalidraw onLinkOpen={onLinkOpen} />);
const linkedRect = API.createElement({
type: "rectangle",
x: 20,
y: 20,
width: 120,
height: 90,
});
API.setElements([linkedRect]);
API.updateElement(linkedRect, {
link: "https://example.com",
});
act(() => {
h.app.setActiveTool({ type: "laser" });
});
const elementsMap = h.app.scene.getNonDeletedElementsMap();
const currentRect = API.getElement(linkedRect);
const [x1, y1, x2, y2] = getElementAbsoluteCoords(currentRect, elementsMap);
const [linkX, linkY, linkWidth, linkHeight] = getLinkHandleFromCoords(
[x1, y1, x2, y2],
currentRect.angle,
h.state,
);
const iconCenterX = linkX + linkWidth / 2;
const iconCenterY = linkY + linkHeight / 2;
mouse.moveTo(iconCenterX, iconCenterY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
mouse.clickAt(iconCenterX, iconCenterY);
expect(onLinkOpenSpy).toHaveBeenCalledTimes(1);
});
it("activates embeddables on center click while using the laser tool", async () => {
await render(<Excalidraw />);
const embeddable = API.createElement({
type: "embeddable",
x: 40,
y: 40,
width: 300,
height: 180,
});
API.setElements([embeddable]);
API.updateElement(embeddable, {
link: "https://www.youtube.com/watch?v=gkGMXY0wekg",
});
act(() => {
h.app.setActiveTool({ type: "laser" });
});
const handleIframeLikeCenterClickSpy = vi.spyOn(
h.app as unknown as {
handleIframeLikeCenterClick: () => void;
},
"handleIframeLikeCenterClick",
);
const centerX = embeddable.x + embeddable.width / 2;
const centerY = embeddable.y + embeddable.height / 2;
mouse.moveTo(centerX, centerY);
expect(GlobalTestState.interactiveCanvas.style.cursor).toBe(
CURSOR_TYPE.POINTER,
);
mouse.clickAt(centerX, centerY);
expect(handleIframeLikeCenterClickSpy).toHaveBeenCalled();
await waitFor(() => {
expect(h.state.activeEmbeddable?.element.id).toBe(embeddable.id);
expect(h.state.activeEmbeddable?.state).toBe("active");
});
handleIframeLikeCenterClickSpy.mockRestore();
});
});