mirror of
https://github.com/excalidraw/excalidraw
synced 2025-07-25 13:58:22 +08:00
Compare commits
12 Commits
fix-better
...
ryan-di/ro
Author | SHA1 | Date | |
---|---|---|---|
baf68fe663 | |||
08a39e2034 | |||
f71c200106 | |||
ed63af1ad8 | |||
ca5c34ac48 | |||
97cc331530 | |||
23175654b8 | |||
48ec3716ca | |||
7cad3645a0 | |||
5921ebc416 | |||
864353be5f | |||
db2911c6c4 |
@ -23,6 +23,7 @@ export enum WS_SUBTYPES {
|
||||
INVALID_RESPONSE = "INVALID_RESPONSE",
|
||||
INIT = "SCENE_INIT",
|
||||
UPDATE = "SCENE_UPDATE",
|
||||
DELETE = "SCENE_DELETE",
|
||||
MOUSE_LOCATION = "MOUSE_LOCATION",
|
||||
IDLE_STATUS = "IDLE_STATUS",
|
||||
USER_VISIBLE_SCENE_BOUNDS = "USER_VISIBLE_SCENE_BOUNDS",
|
||||
|
@ -72,6 +72,7 @@ import {
|
||||
} from "../data/FileManager";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
deleteRoomFromFirebase,
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
loadFromFirebase,
|
||||
@ -83,6 +84,7 @@ import {
|
||||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { roomManager } from "../data/roomManager";
|
||||
|
||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||
import Portal from "./Portal";
|
||||
@ -114,6 +116,7 @@ export interface CollabAPI {
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
startCollaboration: CollabInstance["startCollaboration"];
|
||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
deleteRoom: CollabInstance["deleteRoom"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
@ -227,6 +230,7 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
isCollaborating: this.isCollaborating,
|
||||
onPointerUpdate: this.onPointerUpdate,
|
||||
startCollaboration: this.startCollaboration,
|
||||
deleteRoom: this.deleteRoom,
|
||||
syncElements: this.syncElements,
|
||||
fetchImageFilesFromFirebase: this.fetchImageFilesFromFirebase,
|
||||
stopCollaboration: this.stopCollaboration,
|
||||
@ -547,6 +551,25 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
});
|
||||
|
||||
this.saveCollabRoomToFirebase(getSyncableElements(elements));
|
||||
|
||||
// Save room data to local room manager for new rooms
|
||||
try {
|
||||
await roomManager.addRoom(
|
||||
roomId,
|
||||
roomKey,
|
||||
getCollaborationLink({ roomId, roomKey }),
|
||||
"", // User can edit this later
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save room to local storage:", error);
|
||||
}
|
||||
} else {
|
||||
// Update access time for existing rooms
|
||||
try {
|
||||
await roomManager.updateRoomAccess(existingRoomLinkData.roomId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to update room access time:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback in case you're not alone in the room but still don't receive
|
||||
@ -655,6 +678,18 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
break;
|
||||
}
|
||||
|
||||
case WS_SUBTYPES.DELETE: {
|
||||
const { roomId } = decryptedData.payload;
|
||||
if (this.portal.roomId === roomId) {
|
||||
this.destroySocketClient({ isUnload: true });
|
||||
this.setIsCollaborating(false);
|
||||
this.setActiveRoomLink(null);
|
||||
this.setErrorDialog(t("alerts.collabRoomDeleted"));
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
assertNever(decryptedData, null);
|
||||
}
|
||||
@ -874,6 +909,42 @@ class Collab extends PureComponent<CollabProps, CollabState> {
|
||||
});
|
||||
};
|
||||
|
||||
deleteRoom = async (): Promise<void> => {
|
||||
if (!this.portal.socket || !this.portal.roomId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { roomId, roomKey } = this.portal;
|
||||
if (!roomId || !roomKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const link = this.getActiveRoomLink();
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the room belongs to the current user
|
||||
const isOwner = await roomManager.isRoomOwnedByUser(link);
|
||||
|
||||
if (!isOwner) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.portal.broadcastRoomDeletion();
|
||||
await deleteRoomFromFirebase(roomId, roomKey);
|
||||
await roomManager.deleteRoom(roomId);
|
||||
this.stopCollaboration(false);
|
||||
this.setActiveRoomLink(null);
|
||||
window.history.pushState({}, APP_NAME, window.location.origin);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete room:", error);
|
||||
this.setErrorDialog(t("errors.roomDeletionFailed"));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
public setLastBroadcastedOrReceivedSceneVersion = (version: number) => {
|
||||
this.lastBroadcastedOrReceivedSceneVersion = version;
|
||||
};
|
||||
|
@ -252,6 +252,20 @@ class Portal {
|
||||
this.socket.emit(WS_EVENTS.USER_FOLLOW_CHANGE, payload);
|
||||
}
|
||||
};
|
||||
|
||||
broadcastRoomDeletion = async () => {
|
||||
if (this.socket?.id) {
|
||||
const data: SocketUpdateDataSource["ROOM_DELETED"] = {
|
||||
type: WS_SUBTYPES.DELETE,
|
||||
payload: {
|
||||
socketId: this.socket.id as SocketId,
|
||||
roomId: this.roomId!,
|
||||
},
|
||||
};
|
||||
|
||||
this._broadcastSocketData(data as SocketUpdateData);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
|
@ -315,3 +315,10 @@ export const loadFilesFromFirebase = async (
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
};
|
||||
|
||||
export const deleteRoomFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
): Promise<void> => {
|
||||
// TODO: delete the room...
|
||||
};
|
||||
|
@ -119,6 +119,13 @@ export type SocketUpdateDataSource = {
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
ROOM_DELETED: {
|
||||
type: WS_SUBTYPES.DELETE;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
roomId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SocketUpdateDataIncoming =
|
||||
@ -310,7 +317,7 @@ export const exportToBackend = async (
|
||||
|
||||
const response = await fetch(BACKEND_V2_POST, {
|
||||
method: "POST",
|
||||
body: payload.buffer,
|
||||
body: new Uint8Array(payload.buffer),
|
||||
});
|
||||
const json = await response.json();
|
||||
if (json.id) {
|
||||
|
218
excalidraw-app/data/roomManager.ts
Normal file
218
excalidraw-app/data/roomManager.ts
Normal file
@ -0,0 +1,218 @@
|
||||
import {
|
||||
generateEncryptionKey,
|
||||
encryptData,
|
||||
decryptData,
|
||||
} from "@excalidraw/excalidraw/data/encryption";
|
||||
|
||||
export interface CollabRoom {
|
||||
id: string;
|
||||
roomId: string;
|
||||
roomKey: string;
|
||||
createdAt: number;
|
||||
lastAccessed: number;
|
||||
url: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface EncryptedRoomData {
|
||||
rooms: CollabRoom[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
const ROOM_STORAGE_KEY = "excalidraw-user-rooms";
|
||||
const ROOM_STORAGE_VERSION = 1;
|
||||
|
||||
class RoomManager {
|
||||
private userKey: string | null = null;
|
||||
|
||||
private async getUserKey(): Promise<string> {
|
||||
if (this.userKey) {
|
||||
return this.userKey;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(`${ROOM_STORAGE_KEY}-key`);
|
||||
if (stored) {
|
||||
this.userKey = stored;
|
||||
return this.userKey;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to load user key from localStorage:", error);
|
||||
}
|
||||
|
||||
this.userKey = await generateEncryptionKey();
|
||||
|
||||
try {
|
||||
localStorage.setItem(`${ROOM_STORAGE_KEY}-key`, this.userKey);
|
||||
} catch (error) {
|
||||
console.warn("Failed to save user key to localStorage:", error);
|
||||
}
|
||||
|
||||
return this.userKey;
|
||||
}
|
||||
|
||||
private async encryptRoomData(
|
||||
data: EncryptedRoomData,
|
||||
): Promise<{ data: ArrayBuffer; iv: Uint8Array }> {
|
||||
const userKey = await this.getUserKey();
|
||||
const jsonData = JSON.stringify(data);
|
||||
const { encryptedBuffer, iv } = await encryptData(userKey, jsonData);
|
||||
return { data: encryptedBuffer, iv };
|
||||
}
|
||||
|
||||
private async decryptRoomData(
|
||||
encryptedData: ArrayBuffer,
|
||||
iv: Uint8Array,
|
||||
): Promise<EncryptedRoomData | null> {
|
||||
try {
|
||||
const userKey = await this.getUserKey();
|
||||
const decryptedBuffer = await decryptData(iv, encryptedData, userKey);
|
||||
const jsonString = new TextDecoder().decode(decryptedBuffer);
|
||||
const data = JSON.parse(jsonString) as EncryptedRoomData;
|
||||
|
||||
if (data.version === ROOM_STORAGE_VERSION && Array.isArray(data.rooms)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn("Failed to decrypt room data:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadRooms(): Promise<CollabRoom[]> {
|
||||
try {
|
||||
const storedData = localStorage.getItem(ROOM_STORAGE_KEY);
|
||||
if (!storedData) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { data, iv } = JSON.parse(storedData);
|
||||
const dataBuffer = new Uint8Array(data).buffer;
|
||||
const ivArray = new Uint8Array(iv);
|
||||
|
||||
const decryptedData = await this.decryptRoomData(dataBuffer, ivArray);
|
||||
return decryptedData?.rooms || [];
|
||||
} catch (error) {
|
||||
console.warn("Failed to load rooms:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async saveRooms(rooms: CollabRoom[]): Promise<void> {
|
||||
try {
|
||||
const data: EncryptedRoomData = {
|
||||
rooms,
|
||||
version: ROOM_STORAGE_VERSION,
|
||||
};
|
||||
|
||||
const { data: encryptedData, iv } = await this.encryptRoomData(data);
|
||||
|
||||
const storageData = {
|
||||
data: Array.from(new Uint8Array(encryptedData)),
|
||||
iv: Array.from(iv),
|
||||
};
|
||||
|
||||
localStorage.setItem(ROOM_STORAGE_KEY, JSON.stringify(storageData));
|
||||
} catch (error) {
|
||||
console.warn("Failed to save rooms:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async addRoom(
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
url: string,
|
||||
name?: string,
|
||||
): Promise<void> {
|
||||
const rooms = await this.loadRooms();
|
||||
|
||||
const filteredRooms = rooms.filter((room) => room.roomId !== roomId);
|
||||
|
||||
const newRoom: CollabRoom = {
|
||||
id: crypto.randomUUID(),
|
||||
roomId,
|
||||
roomKey,
|
||||
createdAt: Date.now(),
|
||||
lastAccessed: Date.now(),
|
||||
url,
|
||||
name,
|
||||
};
|
||||
|
||||
filteredRooms.unshift(newRoom);
|
||||
|
||||
await this.saveRooms(filteredRooms);
|
||||
}
|
||||
|
||||
async getRooms(): Promise<CollabRoom[]> {
|
||||
const rooms = await this.loadRooms();
|
||||
return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed);
|
||||
}
|
||||
|
||||
async updateRoomAccess(roomId: string): Promise<void> {
|
||||
const rooms = await this.loadRooms();
|
||||
const room = rooms.find((r) => r.roomId === roomId);
|
||||
|
||||
if (room) {
|
||||
room.lastAccessed = Date.now();
|
||||
await this.saveRooms(rooms);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
const rooms = await this.loadRooms();
|
||||
const filteredRooms = rooms.filter((room) => room.roomId !== roomId);
|
||||
await this.saveRooms(filteredRooms);
|
||||
}
|
||||
|
||||
async updateRoomName(roomId: string, name: string): Promise<void> {
|
||||
const rooms = await this.loadRooms();
|
||||
const room = rooms.find((r) => r.roomId === roomId);
|
||||
|
||||
if (room) {
|
||||
room.name = name;
|
||||
await this.saveRooms(rooms);
|
||||
}
|
||||
}
|
||||
|
||||
async isRoomOwnedByUser(url: string): Promise<boolean> {
|
||||
try {
|
||||
const rooms = await this.loadRooms();
|
||||
const _url = new URL(url);
|
||||
const match = _url.hash.match(/room=([^,]+),([^&]+)/);
|
||||
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roomId = match[1];
|
||||
return rooms.some((room) => room.roomId === roomId);
|
||||
} catch (error) {
|
||||
console.warn("Failed to check room ownership:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentRoom(): Promise<CollabRoom | null> {
|
||||
const rooms = await this.loadRooms();
|
||||
if (rooms.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return the most recently accessed room
|
||||
return rooms.sort((a, b) => b.lastAccessed - a.lastAccessed)[0];
|
||||
}
|
||||
|
||||
async clearAllRooms(): Promise<void> {
|
||||
try {
|
||||
localStorage.removeItem(ROOM_STORAGE_KEY);
|
||||
localStorage.removeItem(`${ROOM_STORAGE_KEY}-key`);
|
||||
this.userKey = null;
|
||||
} catch (error) {
|
||||
console.warn("Failed to clear rooms:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const roomManager = new RoomManager();
|
@ -106,6 +106,10 @@
|
||||
|
||||
color: var(--text-primary-color);
|
||||
|
||||
&__text {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
& strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
@ -155,11 +159,16 @@
|
||||
& p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
& h3 {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
share,
|
||||
shareIOS,
|
||||
shareWindows,
|
||||
TrashIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
import { useUIAppState } from "@excalidraw/excalidraw/context/ui-appState";
|
||||
import { useCopyStatus } from "@excalidraw/excalidraw/hooks/useCopiedIndicator";
|
||||
@ -18,6 +19,8 @@ import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
import { KEYS, getFrame } from "@excalidraw/common";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { roomManager } from "excalidraw-app/data/roomManager";
|
||||
|
||||
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||
|
||||
@ -69,6 +72,19 @@ const ActiveRoomDialog = ({
|
||||
const isShareSupported = "share" in navigator;
|
||||
const { onCopy, copyStatus } = useCopyStatus();
|
||||
|
||||
const [isRoomOwner, setIsRoomOwner] = useState(false);
|
||||
useEffect(() => {
|
||||
roomManager
|
||||
.isRoomOwnedByUser(activeRoomLink)
|
||||
.then((isOwned) => {
|
||||
setIsRoomOwner(isOwned);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("Failed to check room ownership:", error);
|
||||
setIsRoomOwner(false);
|
||||
});
|
||||
}, [activeRoomLink]);
|
||||
|
||||
const copyRoomLink = async () => {
|
||||
try {
|
||||
await copyTextToSystemClipboard(activeRoomLink);
|
||||
@ -153,7 +169,10 @@ const ActiveRoomDialog = ({
|
||||
</span>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</p>
|
||||
<h3>Stop Session</h3>
|
||||
<p>{t("roomDialog.desc_exitSession")}</p>
|
||||
{isRoomOwner && <h3>Delete Session</h3>}
|
||||
{isRoomOwner && <p>{t("roomDialog.desc_deleteSession")}</p>}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__active__actions">
|
||||
@ -171,6 +190,21 @@ const ActiveRoomDialog = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isRoomOwner && (
|
||||
<FilledButton
|
||||
size="large"
|
||||
label={t("roomDialog.button_deleteSession")}
|
||||
icon={TrashIcon}
|
||||
color="danger"
|
||||
onClick={() => {
|
||||
trackEvent("share", "room deleted");
|
||||
collabAPI.deleteRoom();
|
||||
if (!collabAPI.isCollaborating()) {
|
||||
handleClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@ -180,7 +214,6 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { collabAPI } = props;
|
||||
|
||||
const startCollabJSX = collabAPI ? (
|
||||
<>
|
||||
<div className="ShareDialog__picker__header">
|
||||
@ -188,8 +221,15 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__description">
|
||||
<div style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
<div className="ShareDialog__picker__description__text">
|
||||
{t("roomDialog.desc_intro")}
|
||||
</div>
|
||||
<div className="ShareDialog__picker__description__text">
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
<div className="ShareDialog__picker__description__text">
|
||||
{t("roomDialog.desc_warning")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__button">
|
||||
@ -204,6 +244,14 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "var(--color-border)",
|
||||
width: "100%",
|
||||
}}
|
||||
></div>
|
||||
|
||||
{props.type === "share" && (
|
||||
<div className="ShareDialog__separator">
|
||||
<span>{t("shareDialog.or")}</span>
|
||||
|
@ -149,6 +149,7 @@ export class LinearElementEditor {
|
||||
public readonly hoverPointIndex: number;
|
||||
public readonly segmentMidPointHoveredCoords: GlobalPoint | null;
|
||||
public readonly elbowed: boolean;
|
||||
public readonly customLineAngle: number | null;
|
||||
|
||||
constructor(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
@ -186,6 +187,7 @@ export class LinearElementEditor {
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -289,6 +291,7 @@ export class LinearElementEditor {
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
let customLineAngle = linearElementEditor.customLineAngle;
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
@ -329,6 +332,12 @@ export class LinearElementEditor {
|
||||
const selectedIndex = selectedPointsIndices[0];
|
||||
const referencePoint =
|
||||
element.points[selectedIndex === 0 ? 1 : selectedIndex - 1];
|
||||
customLineAngle =
|
||||
linearElementEditor.customLineAngle ??
|
||||
Math.atan2(
|
||||
element.points[selectedIndex][1] - referencePoint[1],
|
||||
element.points[selectedIndex][0] - referencePoint[0],
|
||||
);
|
||||
|
||||
const [width, height] = LinearElementEditor._getShiftLockedDelta(
|
||||
element,
|
||||
@ -336,6 +345,7 @@ export class LinearElementEditor {
|
||||
referencePoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
@ -457,6 +467,7 @@ export class LinearElementEditor {
|
||||
? lastClickedPoint
|
||||
: -1,
|
||||
isDragging: true,
|
||||
customLineAngle,
|
||||
};
|
||||
}
|
||||
|
||||
@ -551,6 +562,8 @@ export class LinearElementEditor {
|
||||
return {
|
||||
...editingLinearElement,
|
||||
...bindings,
|
||||
segmentMidPointHoveredCoords: null,
|
||||
hoverPointIndex: -1,
|
||||
// if clicking without previously dragging a point(s), and not holding
|
||||
// shift, deselect all points except the one clicked. If holding shift,
|
||||
// toggle the point.
|
||||
@ -572,6 +585,7 @@ export class LinearElementEditor {
|
||||
: selectedPointsIndices,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
customLineAngle: null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1593,6 +1607,7 @@ export class LinearElementEditor {
|
||||
referencePoint: LocalPoint,
|
||||
scenePointer: GlobalPoint,
|
||||
gridSize: NullableGridSize,
|
||||
customLineAngle?: number,
|
||||
) {
|
||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
@ -1618,6 +1633,7 @@ export class LinearElementEditor {
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
return pointRotateRads(
|
||||
|
@ -2,6 +2,12 @@ import {
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
normalizeRadians,
|
||||
radiansBetweenAngles,
|
||||
radiansDifference,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
@ -152,13 +158,42 @@ export const getLockedLinearCursorAlignSize = (
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
customAngle?: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
const angle = Math.atan2(height, width) as Radians;
|
||||
let lockedAngle = (Math.round(angle / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE) as Radians;
|
||||
|
||||
if (customAngle) {
|
||||
// If custom angle is provided, we check if the angle is close to the
|
||||
// custom angle, snap to that if close engough, otherwise snap to the
|
||||
// higher or lower angle depending on the current angle vs custom angle.
|
||||
const lower = (Math.floor(customAngle / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE) as Radians;
|
||||
if (
|
||||
radiansBetweenAngles(
|
||||
angle,
|
||||
lower,
|
||||
(lower + SHIFT_LOCKING_ANGLE) as Radians,
|
||||
)
|
||||
) {
|
||||
if (
|
||||
radiansDifference(angle, customAngle as Radians) <
|
||||
SHIFT_LOCKING_ANGLE / 6
|
||||
) {
|
||||
lockedAngle = customAngle as Radians;
|
||||
} else if (
|
||||
normalizeRadians(angle) > normalizeRadians(customAngle as Radians)
|
||||
) {
|
||||
lockedAngle = (lower + SHIFT_LOCKING_ANGLE) as Radians;
|
||||
} else {
|
||||
lockedAngle = lower;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
|
@ -292,7 +292,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
expect(line.points.length).toEqual(3);
|
||||
expect(line.points).toMatchInlineSnapshot(`
|
||||
@ -333,7 +333,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`9`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const midPointsWithRoundEdge = LinearElementEditor.getEditorMidPoints(
|
||||
h.elements[0] as ExcalidrawLinearElement,
|
||||
@ -394,7 +394,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
expect([line.x, line.y]).toEqual([
|
||||
points[0][0] + deltaX,
|
||||
@ -462,7 +462,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
@ -513,7 +513,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@ -554,7 +554,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@ -602,7 +602,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`18`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
|
||||
const newMidPoints = LinearElementEditor.getEditorMidPoints(
|
||||
line,
|
||||
@ -660,7 +660,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`16`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`8`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(line.points.length).toEqual(5);
|
||||
|
||||
expect((h.elements[0] as ExcalidrawLinearElement).points)
|
||||
@ -758,7 +758,7 @@ describe("Test Linear Elements", () => {
|
||||
expect(renderInteractiveScene.mock.calls.length).toMatchInlineSnapshot(
|
||||
`12`,
|
||||
);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`7`);
|
||||
expect(renderStaticScene.mock.calls.length).toMatchInlineSnapshot(`6`);
|
||||
|
||||
const newPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
@ -1411,5 +1411,55 @@ describe("Test Linear Elements", () => {
|
||||
expect(line.points[line.points.length - 1][0]).toBe(20);
|
||||
expect(line.points[line.points.length - 1][1]).toBe(-20);
|
||||
});
|
||||
|
||||
it("should preserve original angle when dragging endpoint with SHIFT key", () => {
|
||||
createTwoPointerLinearElement("line");
|
||||
const line = h.elements[0] as ExcalidrawLinearElement;
|
||||
enterLineEditingMode(line);
|
||||
|
||||
const elementsMap = arrayToMap(h.elements);
|
||||
const points = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Calculate original angle between first and last point
|
||||
const originalAngle = Math.atan2(
|
||||
points[1][1] - points[0][1],
|
||||
points[1][0] - points[0][0],
|
||||
);
|
||||
|
||||
// Drag the second point (endpoint) with SHIFT key pressed
|
||||
const startPoint = pointFrom<GlobalPoint>(points[1][0], points[1][1]);
|
||||
const endPoint = pointFrom<GlobalPoint>(
|
||||
startPoint[0] + 4,
|
||||
startPoint[1] + 4,
|
||||
);
|
||||
|
||||
// Perform drag with SHIFT key modifier
|
||||
Keyboard.withModifierKeys({ shift: true }, () => {
|
||||
mouse.downAt(startPoint[0], startPoint[1]);
|
||||
mouse.moveTo(endPoint[0], endPoint[1]);
|
||||
mouse.upAt(endPoint[0], endPoint[1]);
|
||||
});
|
||||
|
||||
// Get updated points after drag
|
||||
const updatedPoints = LinearElementEditor.getPointsGlobalCoordinates(
|
||||
line,
|
||||
elementsMap,
|
||||
);
|
||||
|
||||
// Calculate new angle
|
||||
const newAngle = Math.atan2(
|
||||
updatedPoints[1][1] - updatedPoints[0][1],
|
||||
updatedPoints[1][0] - updatedPoints[0][0],
|
||||
);
|
||||
|
||||
// The angle should be preserved (within a small tolerance for floating point precision)
|
||||
const angleDifference = Math.abs(newAngle - originalAngle);
|
||||
const tolerance = 0.01; // Small tolerance for floating point precision
|
||||
|
||||
expect(angleDifference).toBeLessThan(tolerance);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -9118,7 +9118,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (selectedELements.length > 1) {
|
||||
this.setState({ selectedLinearElement: null });
|
||||
}
|
||||
} else {
|
||||
} else if (this.state.selectedLinearElement.isDragging) {
|
||||
this.actionManager.executeAction(actionFinalize, "ui", {
|
||||
event: childEvent,
|
||||
});
|
||||
|
@ -253,7 +253,8 @@
|
||||
"resetLibrary": "This will clear your library. Are you sure?",
|
||||
"removeItemsFromsLibrary": "Delete {{count}} item(s) from library?",
|
||||
"invalidEncryptionKey": "Encryption key must be of 22 characters. Live collaboration is disabled.",
|
||||
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!"
|
||||
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!",
|
||||
"collabRoomDeleted": "This collab room has been deleted by its owner."
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Unsupported file type.",
|
||||
@ -280,7 +281,8 @@
|
||||
},
|
||||
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
|
||||
"asyncPasteFailedOnParse": "Couldn't paste.",
|
||||
"copyToSystemClipboardFailed": "Couldn't copy to clipboard."
|
||||
"copyToSystemClipboardFailed": "Couldn't copy to clipboard.",
|
||||
"roomDeletionFailed": "Couldn't delete the collaboration room."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selection",
|
||||
@ -376,11 +378,14 @@
|
||||
"roomDialog": {
|
||||
"desc_intro": "Invite people to collaborate on your drawing.",
|
||||
"desc_privacy": "Don't worry, the session is end-to-end encrypted, and fully private. Not even our server can see what you draw.",
|
||||
"desc_warning": "Starting a new session will automatically delete your last active session. Please make sure to save your work from the last session before starting a new one.",
|
||||
"button_startSession": "Start session",
|
||||
"button_stopSession": "Stop session",
|
||||
"button_deleteSession": "Delete session",
|
||||
"desc_inProgressIntro": "Live-collaboration session is now in progress.",
|
||||
"desc_shareLink": "Share this link with anyone you want to collaborate with:",
|
||||
"desc_exitSession": "Stopping the session will disconnect you from the room, but you'll be able to continue working with the scene, locally. Note that this won't affect other people, and they'll still be able to collaborate on their version.",
|
||||
"desc_deleteSession": "You're the creator of this session, so you can delete it if you wish to stop collaborating with others. Deleting a session is permanent and will make the scene inaccessible to everyone (including you). Please be sure to save anything important before deleting.",
|
||||
"shareTitle": "Join a live collaboration session on Excalidraw"
|
||||
},
|
||||
"errorDialog": {
|
||||
|
@ -8630,6 +8630,7 @@ exports[`regression tests > key 5 selects arrow tool > [end of test] appState 1`
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
"selectedLinearElement": LinearElementEditor {
|
||||
"customLineAngle": null,
|
||||
"elbowed": false,
|
||||
"elementId": "id0",
|
||||
"endBindingElement": "keep",
|
||||
@ -8853,6 +8854,7 @@ exports[`regression tests > key 6 selects line tool > [end of test] appState 1`]
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
"selectedLinearElement": LinearElementEditor {
|
||||
"customLineAngle": null,
|
||||
"elbowed": false,
|
||||
"elementId": "id0",
|
||||
"endBindingElement": "keep",
|
||||
@ -9270,6 +9272,7 @@ exports[`regression tests > key a selects arrow tool > [end of test] appState 1`
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
"selectedLinearElement": LinearElementEditor {
|
||||
"customLineAngle": null,
|
||||
"elbowed": false,
|
||||
"elementId": "id0",
|
||||
"endBindingElement": "keep",
|
||||
@ -9673,6 +9676,7 @@ exports[`regression tests > key l selects line tool > [end of test] appState 1`]
|
||||
"selectedElementsAreBeingDragged": false,
|
||||
"selectedGroupIds": {},
|
||||
"selectedLinearElement": LinearElementEditor {
|
||||
"customLineAngle": null,
|
||||
"elbowed": false,
|
||||
"elementId": "id0",
|
||||
"endBindingElement": "keep",
|
||||
|
@ -426,7 +426,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
@ -470,7 +470,7 @@ describe("select single element on the scene", () => {
|
||||
fireEvent.pointerUp(canvas);
|
||||
|
||||
expect(renderInteractiveScene).toHaveBeenCalledTimes(8);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(7);
|
||||
expect(renderStaticScene).toHaveBeenCalledTimes(6);
|
||||
expect(h.state.selectionElement).toBeNull();
|
||||
expect(h.elements.length).toEqual(1);
|
||||
expect(h.state.selectedElementIds[h.elements[0].id]).toBeTruthy();
|
||||
|
@ -8,16 +8,10 @@ import type {
|
||||
Radians,
|
||||
} from "./types";
|
||||
|
||||
// TODO: Simplify with modulo and fix for angles beyond 4*Math.PI and - 4*Math.PI
|
||||
export const normalizeRadians = (angle: Radians): Radians => {
|
||||
if (angle < 0) {
|
||||
return (angle + 2 * Math.PI) as Radians;
|
||||
}
|
||||
if (angle >= 2 * Math.PI) {
|
||||
return (angle - 2 * Math.PI) as Radians;
|
||||
}
|
||||
return angle;
|
||||
};
|
||||
export const normalizeRadians = (angle: Radians): Radians =>
|
||||
angle < 0
|
||||
? (((angle % (2 * Math.PI)) + 2 * Math.PI) as Radians)
|
||||
: ((angle % (2 * Math.PI)) as Radians);
|
||||
|
||||
/**
|
||||
* Return the polar coordinates for the given cartesian point represented by
|
||||
@ -49,3 +43,35 @@ export function radiansToDegrees(degrees: Radians): Degrees {
|
||||
export function isRightAngleRads(rads: Radians): boolean {
|
||||
return Math.abs(Math.sin(2 * rads)) < PRECISION;
|
||||
}
|
||||
|
||||
export function radiansBetweenAngles(
|
||||
a: Radians,
|
||||
min: Radians,
|
||||
max: Radians,
|
||||
): boolean {
|
||||
a = normalizeRadians(a);
|
||||
min = normalizeRadians(min);
|
||||
max = normalizeRadians(max);
|
||||
|
||||
if (min < max) {
|
||||
return a >= min && a <= max;
|
||||
}
|
||||
|
||||
// The range wraps around the 0 angle
|
||||
return a >= min || a <= max;
|
||||
}
|
||||
|
||||
export function radiansDifference(a: Radians, b: Radians): Radians {
|
||||
a = normalizeRadians(a);
|
||||
b = normalizeRadians(b);
|
||||
|
||||
let diff = a - b;
|
||||
|
||||
if (diff < -Math.PI) {
|
||||
diff = (diff + 2 * Math.PI) as Radians;
|
||||
} else if (diff > Math.PI) {
|
||||
diff = (diff - 2 * Math.PI) as Radians;
|
||||
}
|
||||
|
||||
return Math.abs(diff) as Radians;
|
||||
}
|
||||
|
Reference in New Issue
Block a user