mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-08-15 22:39:44 +02:00
Compare commits
1 Commits
ryan-di/ro
...
mrazator/c
Author | SHA1 | Date | |
---|---|---|---|
![]() |
6bcd5b622c |
@@ -1,5 +1,3 @@
|
||||
MODE="development"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json-dev.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json-dev.excalidraw.com/api/v2/post/
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
MODE="production"
|
||||
|
||||
VITE_APP_BACKEND_V2_GET_URL=https://json.excalidraw.com/api/v2/
|
||||
VITE_APP_BACKEND_V2_POST_URL=https://json.excalidraw.com/api/v2/post/
|
||||
|
||||
|
45
.github/copilot-instructions.md
vendored
45
.github/copilot-instructions.md
vendored
@@ -1,45 +0,0 @@
|
||||
# Project coding standards
|
||||
|
||||
## Generic Communication Guidelines
|
||||
|
||||
- Be succint and be aware that expansive generative AI answers are costly and slow
|
||||
- Avoid providing explanations, trying to teach unless asked for, your chat partner is an expert
|
||||
- Stop apologising if corrected, just provide the correct information or code
|
||||
- Prefer code unless asked for explanation
|
||||
- Stop summarizing what you've changed after modifications unless asked for
|
||||
|
||||
## TypeScript Guidelines
|
||||
|
||||
- Use TypeScript for all new code
|
||||
- Where possible, prefer implementations without allocation
|
||||
- When there is an option, opt for more performant solutions and trade RAM usage for less CPU cycles
|
||||
- Prefer immutable data (const, readonly)
|
||||
- Use optional chaining (?.) and nullish coalescing (??) operators
|
||||
|
||||
## React Guidelines
|
||||
|
||||
- Use functional components with hooks
|
||||
- Follow the React hooks rules (no conditional hooks)
|
||||
- Keep components small and focused
|
||||
- Use CSS modules for component styling
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Use PascalCase for component names, interfaces, and type aliases
|
||||
- Use camelCase for variables, functions, and methods
|
||||
- Use ALL_CAPS for constants
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Use try/catch blocks for async operations
|
||||
- Implement proper error boundaries in React components
|
||||
- Always log errors with contextual information
|
||||
|
||||
## Testing
|
||||
|
||||
- Always attempt to fix #problems
|
||||
- Always offer to run `yarn test:app` in the project root after modifications are complete and attempt fixing the issues reported
|
||||
|
||||
## Types
|
||||
|
||||
- Always include `packages/math/src/types.ts` in the context when your write math related code and always use the Point type instead of { x, y}
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,5 +25,4 @@ packages/excalidraw/types
|
||||
coverage
|
||||
dev-dist
|
||||
html
|
||||
meta*.json
|
||||
.claude
|
||||
meta*.json
|
34
CLAUDE.md
34
CLAUDE.md
@@ -1,34 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## Project Structure
|
||||
|
||||
Excalidraw is a **monorepo** with a clear separation between the core library and the application:
|
||||
|
||||
- **`packages/excalidraw/`** - Main React component library published to npm as `@excalidraw/excalidraw`
|
||||
- **`excalidraw-app/`** - Full-featured web application (excalidraw.com) that uses the library
|
||||
- **`packages/`** - Core packages: `@excalidraw/common`, `@excalidraw/element`, `@excalidraw/math`, `@excalidraw/utils`
|
||||
- **`examples/`** - Integration examples (NextJS, browser script)
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **Package Development**: Work in `packages/*` for editor features
|
||||
2. **App Development**: Work in `excalidraw-app/` for app-specific features
|
||||
3. **Testing**: Always run `yarn test:update` before committing
|
||||
4. **Type Safety**: Use `yarn test:typecheck` to verify TypeScript
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
yarn test:typecheck # TypeScript type checking
|
||||
yarn test:update # Run all tests (with snapshot updates)
|
||||
yarn fix # Auto-fix formatting and linting issues
|
||||
```
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
### Package System
|
||||
|
||||
- Uses Yarn workspaces for monorepo management
|
||||
- Internal packages use path aliases (see `vitest.config.mts`)
|
||||
- Build system uses esbuild for packages, Vite for the app
|
||||
- TypeScript throughout with strict configuration
|
@@ -40,6 +40,7 @@
|
||||
<a href="https://twitter.com/excalidraw">
|
||||
<img alt="Follow Excalidraw on Twitter" src="https://img.shields.io/twitter/follow/excalidraw.svg?label=follow+@excalidraw&style=social&logo=twitter"/>
|
||||
</a>
|
||||
<img alt"CodeRabbit Reviews" src="https://img.shields.io/coderabbit/prs/github/excalidraw/excalidraw?utm_source=oss&utm_medium=github&utm_campaign=excalidraw%2Fexcalidraw&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews"/>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
|
@@ -23,7 +23,6 @@ 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,7 +72,6 @@ import {
|
||||
} from "../data/FileManager";
|
||||
import { LocalData } from "../data/LocalData";
|
||||
import {
|
||||
deleteRoomFromFirebase,
|
||||
isSavedToFirebase,
|
||||
loadFilesFromFirebase,
|
||||
loadFromFirebase,
|
||||
@@ -84,7 +83,6 @@ import {
|
||||
saveUsernameToLocalStorage,
|
||||
} from "../data/localStorage";
|
||||
import { resetBrowserStateVersions } from "../data/tabSync";
|
||||
import { roomManager } from "../data/roomManager";
|
||||
|
||||
import { collabErrorIndicatorAtom } from "./CollabError";
|
||||
import Portal from "./Portal";
|
||||
@@ -116,7 +114,6 @@ export interface CollabAPI {
|
||||
onPointerUpdate: CollabInstance["onPointerUpdate"];
|
||||
startCollaboration: CollabInstance["startCollaboration"];
|
||||
stopCollaboration: CollabInstance["stopCollaboration"];
|
||||
deleteRoom: CollabInstance["deleteRoom"];
|
||||
syncElements: CollabInstance["syncElements"];
|
||||
fetchImageFilesFromFirebase: CollabInstance["fetchImageFilesFromFirebase"];
|
||||
setUsername: CollabInstance["setUsername"];
|
||||
@@ -230,7 +227,6 @@ 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,
|
||||
@@ -551,25 +547,6 @@ 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
|
||||
@@ -678,18 +655,6 @@ 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);
|
||||
}
|
||||
@@ -909,42 +874,6 @@ 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,20 +252,6 @@ 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,10 +315,3 @@ export const loadFilesFromFirebase = async (
|
||||
|
||||
return { loadedFiles, erroredFiles };
|
||||
};
|
||||
|
||||
export const deleteRoomFromFirebase = async (
|
||||
roomId: string,
|
||||
roomKey: string,
|
||||
): Promise<void> => {
|
||||
// TODO: delete the room...
|
||||
};
|
||||
|
@@ -119,13 +119,6 @@ export type SocketUpdateDataSource = {
|
||||
username: string;
|
||||
};
|
||||
};
|
||||
ROOM_DELETED: {
|
||||
type: WS_SUBTYPES.DELETE;
|
||||
payload: {
|
||||
socketId: SocketId;
|
||||
roomId: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type SocketUpdateDataIncoming =
|
||||
@@ -317,7 +310,7 @@ export const exportToBackend = async (
|
||||
|
||||
const response = await fetch(BACKEND_V2_POST, {
|
||||
method: "POST",
|
||||
body: new Uint8Array(payload.buffer),
|
||||
body: payload.buffer,
|
||||
});
|
||||
const json = await response.json();
|
||||
if (json.id) {
|
||||
|
@@ -1,218 +0,0 @@
|
||||
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,10 +106,6 @@
|
||||
|
||||
color: var(--text-primary-color);
|
||||
|
||||
&__text {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
& strong {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
@@ -159,16 +155,11 @@
|
||||
& p + p {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
||||
& h3 {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -11,7 +11,6 @@ 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";
|
||||
@@ -19,8 +18,6 @@ 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";
|
||||
|
||||
@@ -72,19 +69,6 @@ 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);
|
||||
@@ -169,10 +153,7 @@ 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">
|
||||
@@ -190,21 +171,6 @@ 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>
|
||||
</>
|
||||
);
|
||||
@@ -214,6 +180,7 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { collabAPI } = props;
|
||||
|
||||
const startCollabJSX = collabAPI ? (
|
||||
<>
|
||||
<div className="ShareDialog__picker__header">
|
||||
@@ -221,15 +188,8 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__description">
|
||||
<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 style={{ marginBottom: "1em" }}>{t("roomDialog.desc_intro")}</div>
|
||||
{t("roomDialog.desc_privacy")}
|
||||
</div>
|
||||
|
||||
<div className="ShareDialog__picker__button">
|
||||
@@ -244,14 +204,6 @@ 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>
|
||||
|
@@ -477,10 +477,3 @@ export enum UserIdleState {
|
||||
AWAY = "away",
|
||||
IDLE = "idle",
|
||||
}
|
||||
|
||||
/**
|
||||
* distance at which we merge points instead of adding a new merge-point
|
||||
* when converting a line to a polygon (merge currently means overlaping
|
||||
* the start and end points)
|
||||
*/
|
||||
export const LINE_POLYGON_POINT_MERGE_DISTANCE = 20;
|
||||
|
@@ -63,13 +63,10 @@ import {
|
||||
getControlPointsForBezierCurve,
|
||||
mapIntervalToBezierT,
|
||||
getBezierXY,
|
||||
toggleLinePolygonState,
|
||||
} from "./shapes";
|
||||
|
||||
import { getLockedLinearCursorAlignSize } from "./sizeHelpers";
|
||||
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
import type { Bounds } from "./bounds";
|
||||
@@ -88,35 +85,6 @@ import type {
|
||||
PointsPositionUpdates,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Normalizes line points so that the start point is at [0,0]. This is
|
||||
* expected in various parts of the codebase.
|
||||
*
|
||||
* Also returns the offsets - [0,0] if no normalization needed.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
const getNormalizedPoints = ({
|
||||
points,
|
||||
}: {
|
||||
points: ExcalidrawLinearElement["points"];
|
||||
}): {
|
||||
points: LocalPoint[];
|
||||
offsetX: number;
|
||||
offsetY: number;
|
||||
} => {
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
|
||||
return {
|
||||
points: points.map((p) => {
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
offsetX,
|
||||
offsetY,
|
||||
};
|
||||
};
|
||||
|
||||
export class LinearElementEditor {
|
||||
public readonly elementId: ExcalidrawElement["id"] & {
|
||||
_brand: "excalidrawLinearElementId";
|
||||
@@ -149,7 +117,6 @@ 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>,
|
||||
@@ -160,11 +127,7 @@ export class LinearElementEditor {
|
||||
};
|
||||
if (!pointsEqual(element.points[0], pointFrom(0, 0))) {
|
||||
console.error("Linear element is not normalized", Error().stack);
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element),
|
||||
);
|
||||
LinearElementEditor.normalizePoints(element, elementsMap);
|
||||
}
|
||||
this.selectedPointsIndices = null;
|
||||
this.lastUncommittedPoint = null;
|
||||
@@ -187,7 +150,6 @@ export class LinearElementEditor {
|
||||
this.hoverPointIndex = -1;
|
||||
this.segmentMidPointHoveredCoords = null;
|
||||
this.elbowed = isElbowArrow(element) && element.elbowed;
|
||||
this.customLineAngle = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -291,7 +253,6 @@ export class LinearElementEditor {
|
||||
const { elementId } = linearElementEditor;
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
const element = LinearElementEditor.getElement(elementId, elementsMap);
|
||||
let customLineAngle = linearElementEditor.customLineAngle;
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
@@ -332,12 +293,6 @@ 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,
|
||||
@@ -345,7 +300,6 @@ export class LinearElementEditor {
|
||||
referencePoint,
|
||||
pointFrom(scenePointerX, scenePointerY),
|
||||
event[KEYS.CTRL_OR_CMD] ? null : app.getEffectiveGridSize(),
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
LinearElementEditor.movePoints(
|
||||
@@ -467,7 +421,6 @@ export class LinearElementEditor {
|
||||
? lastClickedPoint
|
||||
: -1,
|
||||
isDragging: true,
|
||||
customLineAngle,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -506,18 +459,6 @@ export class LinearElementEditor {
|
||||
selectedPoint === element.points.length - 1
|
||||
) {
|
||||
if (isPathALoop(element.points, appState.zoom.value)) {
|
||||
if (isLineElement(element)) {
|
||||
scene.mutateElement(
|
||||
element,
|
||||
{
|
||||
...toggleLinePolygonState(element, true),
|
||||
},
|
||||
{
|
||||
informMutation: false,
|
||||
isDragging: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
LinearElementEditor.movePoints(
|
||||
element,
|
||||
scene,
|
||||
@@ -562,8 +503,6 @@ 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.
|
||||
@@ -585,7 +524,6 @@ export class LinearElementEditor {
|
||||
: selectedPointsIndices,
|
||||
isDragging: false,
|
||||
pointerOffset: { x: 0, y: 0 },
|
||||
customLineAngle: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1008,7 +946,9 @@ export class LinearElementEditor {
|
||||
|
||||
if (!event.altKey) {
|
||||
if (lastPoint === lastUncommittedPoint) {
|
||||
LinearElementEditor.deletePoints(element, app, [points.length - 1]);
|
||||
LinearElementEditor.deletePoints(element, app.scene, [
|
||||
points.length - 1,
|
||||
]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1059,7 +999,7 @@ export class LinearElementEditor {
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
LinearElementEditor.addPoints(element, app.scene, [newPoint]);
|
||||
LinearElementEditor.addPoints(element, app.scene, [{ point: newPoint }]);
|
||||
}
|
||||
return {
|
||||
...appState.editingLinearElement,
|
||||
@@ -1202,23 +1142,40 @@ export class LinearElementEditor {
|
||||
|
||||
/**
|
||||
* Normalizes line points so that the start point is at [0,0]. This is
|
||||
* expected in various parts of the codebase.
|
||||
*
|
||||
* Also returns normalized x and y coords to account for the normalization
|
||||
* of the points.
|
||||
* expected in various parts of the codebase. Also returns new x/y to account
|
||||
* for the potential normalization.
|
||||
*/
|
||||
static getNormalizeElementPointsAndCoords(element: ExcalidrawLinearElement) {
|
||||
const { points, offsetX, offsetY } = getNormalizedPoints(element);
|
||||
static getNormalizedPoints(element: ExcalidrawLinearElement): {
|
||||
points: LocalPoint[];
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const { points } = element;
|
||||
|
||||
const offsetX = points[0][0];
|
||||
const offsetY = points[0][1];
|
||||
|
||||
return {
|
||||
points,
|
||||
points: points.map((p) => {
|
||||
return pointFrom(p[0] - offsetX, p[1] - offsetY);
|
||||
}),
|
||||
x: element.x + offsetX,
|
||||
y: element.y + offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
// element-mutating methods
|
||||
// ---------------------------------------------------------------------------
|
||||
static normalizePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
elementsMap: ElementsMap,
|
||||
) {
|
||||
mutateElement(
|
||||
element,
|
||||
elementsMap,
|
||||
LinearElementEditor.getNormalizedPoints(element),
|
||||
);
|
||||
}
|
||||
|
||||
static duplicateSelectedPoints(appState: AppState, scene: Scene): AppState {
|
||||
invariant(
|
||||
appState.editingLinearElement,
|
||||
@@ -1297,42 +1254,41 @@ export class LinearElementEditor {
|
||||
|
||||
static deletePoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
app: AppClassProperties,
|
||||
scene: Scene,
|
||||
pointIndices: readonly number[],
|
||||
) {
|
||||
const isUncommittedPoint =
|
||||
app.state.editingLinearElement?.lastUncommittedPoint ===
|
||||
element.points[element.points.length - 1];
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
const nextPoints = element.points.filter((_, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
});
|
||||
const isDeletingOriginPoint = pointIndices.includes(0);
|
||||
|
||||
const isPolygon = isLineElement(element) && element.polygon;
|
||||
|
||||
// keep polygon intact if deleting start/end point or uncommitted point
|
||||
if (
|
||||
isPolygon &&
|
||||
(isUncommittedPoint ||
|
||||
pointIndices.includes(0) ||
|
||||
pointIndices.includes(element.points.length - 1))
|
||||
) {
|
||||
nextPoints[0] = pointFrom(
|
||||
nextPoints[nextPoints.length - 1][0],
|
||||
nextPoints[nextPoints.length - 1][1],
|
||||
);
|
||||
// if deleting first point, make the next to be [0,0] and recalculate
|
||||
// positions of the rest with respect to it
|
||||
if (isDeletingOriginPoint) {
|
||||
const firstNonDeletedPoint = element.points.find((point, idx) => {
|
||||
return !pointIndices.includes(idx);
|
||||
});
|
||||
if (firstNonDeletedPoint) {
|
||||
offsetX = firstNonDeletedPoint[0];
|
||||
offsetY = firstNonDeletedPoint[1];
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
points: normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
} = getNormalizedPoints({ points: nextPoints });
|
||||
const nextPoints = element.points.reduce((acc: LocalPoint[], p, idx) => {
|
||||
if (!pointIndices.includes(idx)) {
|
||||
acc.push(
|
||||
!acc.length
|
||||
? pointFrom(0, 0)
|
||||
: pointFrom(p[0] - offsetX, p[1] - offsetY),
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
app.scene,
|
||||
normalizedPoints,
|
||||
scene,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -1341,27 +1297,16 @@ export class LinearElementEditor {
|
||||
static addPoints(
|
||||
element: NonDeleted<ExcalidrawLinearElement>,
|
||||
scene: Scene,
|
||||
addedPoints: LocalPoint[],
|
||||
targetPoints: { point: LocalPoint }[],
|
||||
) {
|
||||
const nextPoints = [...element.points, ...addedPoints];
|
||||
|
||||
if (isLineElement(element) && element.polygon) {
|
||||
nextPoints[0] = pointFrom(
|
||||
nextPoints[nextPoints.length - 1][0],
|
||||
nextPoints[nextPoints.length - 1][1],
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
points: normalizedPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
} = getNormalizedPoints({ points: nextPoints });
|
||||
const offsetX = 0;
|
||||
const offsetY = 0;
|
||||
|
||||
const nextPoints = [...element.points, ...targetPoints.map((x) => x.point)];
|
||||
LinearElementEditor._updatePoints(
|
||||
element,
|
||||
scene,
|
||||
normalizedPoints,
|
||||
nextPoints,
|
||||
offsetX,
|
||||
offsetY,
|
||||
);
|
||||
@@ -1378,37 +1323,17 @@ export class LinearElementEditor {
|
||||
) {
|
||||
const { points } = element;
|
||||
|
||||
// if polygon, move start and end points together
|
||||
if (isLineElement(element) && element.polygon) {
|
||||
const firstPointUpdate = pointUpdates.get(0);
|
||||
const lastPointUpdate = pointUpdates.get(points.length - 1);
|
||||
|
||||
if (firstPointUpdate) {
|
||||
pointUpdates.set(points.length - 1, {
|
||||
point: pointFrom(
|
||||
firstPointUpdate.point[0],
|
||||
firstPointUpdate.point[1],
|
||||
),
|
||||
isDragging: firstPointUpdate.isDragging,
|
||||
});
|
||||
} else if (lastPointUpdate) {
|
||||
pointUpdates.set(0, {
|
||||
point: pointFrom(lastPointUpdate.point[0], lastPointUpdate.point[1]),
|
||||
isDragging: lastPointUpdate.isDragging,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// in case we're moving start point, instead of modifying its position
|
||||
// which would break the invariant of it being at [0,0], we move
|
||||
// all the other points in the opposite direction by delta to
|
||||
// offset it. We do the same with actual element.x/y position, so
|
||||
// this hacks are completely transparent to the user.
|
||||
|
||||
const updatedOriginPoint =
|
||||
const [deltaX, deltaY] =
|
||||
pointUpdates.get(0)?.point ?? pointFrom<LocalPoint>(0, 0);
|
||||
|
||||
const [offsetX, offsetY] = updatedOriginPoint;
|
||||
const [offsetX, offsetY] = pointFrom<LocalPoint>(
|
||||
deltaX - points[0][0],
|
||||
deltaY - points[0][1],
|
||||
);
|
||||
|
||||
const nextPoints = isElbowArrow(element)
|
||||
? [
|
||||
@@ -1578,7 +1503,6 @@ export class LinearElementEditor {
|
||||
isDragging: options?.isDragging ?? false,
|
||||
});
|
||||
} else {
|
||||
// TODO do we need to get precise coords here just to calc centers?
|
||||
const nextCoords = getElementPointsCoords(element, nextPoints);
|
||||
const prevCoords = getElementPointsCoords(element, element.points);
|
||||
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
|
||||
@@ -1587,7 +1511,7 @@ export class LinearElementEditor {
|
||||
const prevCenterY = (prevCoords[1] + prevCoords[3]) / 2;
|
||||
const dX = prevCenterX - nextCenterX;
|
||||
const dY = prevCenterY - nextCenterY;
|
||||
const rotatedOffset = pointRotateRads(
|
||||
const rotated = pointRotateRads(
|
||||
pointFrom(offsetX, offsetY),
|
||||
pointFrom(dX, dY),
|
||||
element.angle,
|
||||
@@ -1595,8 +1519,8 @@ export class LinearElementEditor {
|
||||
scene.mutateElement(element, {
|
||||
...otherUpdates,
|
||||
points: nextPoints,
|
||||
x: element.x + rotatedOffset[0],
|
||||
y: element.y + rotatedOffset[1],
|
||||
x: element.x + rotated[0],
|
||||
y: element.y + rotated[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1607,7 +1531,6 @@ export class LinearElementEditor {
|
||||
referencePoint: LocalPoint,
|
||||
scenePointer: GlobalPoint,
|
||||
gridSize: NullableGridSize,
|
||||
customLineAngle?: number,
|
||||
) {
|
||||
const referencePointCoords = LinearElementEditor.getPointGlobalCoordinates(
|
||||
element,
|
||||
@@ -1633,7 +1556,6 @@ export class LinearElementEditor {
|
||||
referencePointCoords[1],
|
||||
gridX,
|
||||
gridY,
|
||||
customLineAngle,
|
||||
);
|
||||
|
||||
return pointRotateRads(
|
||||
|
@@ -25,8 +25,6 @@ import { getBoundTextMaxWidth } from "./textElement";
|
||||
import { normalizeText, measureText } from "./textMeasurements";
|
||||
import { wrapText } from "./textWrapping";
|
||||
|
||||
import { isLineElement } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawImageElement,
|
||||
@@ -47,7 +45,6 @@ import type {
|
||||
ElementsMap,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "./types";
|
||||
|
||||
export type ElementConstructorOpts = MarkOptional<
|
||||
@@ -460,10 +457,9 @@ export const newLinearElement = (
|
||||
opts: {
|
||||
type: ExcalidrawLinearElement["type"];
|
||||
points?: ExcalidrawLinearElement["points"];
|
||||
polygon?: ExcalidrawLineElement["polygon"];
|
||||
} & ElementConstructorOpts,
|
||||
): NonDeleted<ExcalidrawLinearElement> => {
|
||||
const element = {
|
||||
return {
|
||||
..._newElementBase<ExcalidrawLinearElement>(opts.type, opts),
|
||||
points: opts.points || [],
|
||||
lastCommittedPoint: null,
|
||||
@@ -472,17 +468,6 @@ export const newLinearElement = (
|
||||
startArrowhead: null,
|
||||
endArrowhead: null,
|
||||
};
|
||||
|
||||
if (isLineElement(element)) {
|
||||
const lineElement: NonDeleted<ExcalidrawLineElement> = {
|
||||
...element,
|
||||
polygon: opts.polygon ?? false,
|
||||
};
|
||||
|
||||
return lineElement;
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const newArrowElement = <T extends boolean>(
|
||||
|
@@ -5,7 +5,6 @@ import {
|
||||
ROUNDNESS,
|
||||
invariant,
|
||||
elementCenterPoint,
|
||||
LINE_POLYGON_POINT_MERGE_DISTANCE,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
isPoint,
|
||||
@@ -36,13 +35,10 @@ import { ShapeCache } from "./ShapeCache";
|
||||
|
||||
import { getElementAbsoluteCoords, type Bounds } from "./bounds";
|
||||
|
||||
import { canBecomePolygon } from "./typeChecks";
|
||||
|
||||
import type {
|
||||
ElementsMap,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawLineElement,
|
||||
NonDeleted,
|
||||
} from "./types";
|
||||
|
||||
@@ -400,47 +396,3 @@ export const isPathALoop = (
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const toggleLinePolygonState = (
|
||||
element: ExcalidrawLineElement,
|
||||
nextPolygonState: boolean,
|
||||
): {
|
||||
polygon: ExcalidrawLineElement["polygon"];
|
||||
points: ExcalidrawLineElement["points"];
|
||||
} | null => {
|
||||
const updatedPoints = [...element.points];
|
||||
|
||||
if (nextPolygonState) {
|
||||
if (!canBecomePolygon(element.points)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstPoint = updatedPoints[0];
|
||||
const lastPoint = updatedPoints[updatedPoints.length - 1];
|
||||
|
||||
const distance = Math.hypot(
|
||||
firstPoint[0] - lastPoint[0],
|
||||
firstPoint[1] - lastPoint[1],
|
||||
);
|
||||
|
||||
if (
|
||||
distance > LINE_POLYGON_POINT_MERGE_DISTANCE ||
|
||||
updatedPoints.length < 4
|
||||
) {
|
||||
updatedPoints.push(pointFrom(firstPoint[0], firstPoint[1]));
|
||||
} else {
|
||||
updatedPoints[updatedPoints.length - 1] = pointFrom(
|
||||
firstPoint[0],
|
||||
firstPoint[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: satisfies ElementUpdate<ExcalidrawLineElement>
|
||||
const ret = {
|
||||
polygon: nextPolygonState,
|
||||
points: updatedPoints,
|
||||
};
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
@@ -2,28 +2,14 @@ import {
|
||||
SHIFT_LOCKING_ANGLE,
|
||||
viewportCoordsToSceneCoords,
|
||||
} from "@excalidraw/common";
|
||||
import {
|
||||
normalizeRadians,
|
||||
radiansBetweenAngles,
|
||||
radiansDifference,
|
||||
type Radians,
|
||||
} from "@excalidraw/math";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { AppState, Offsets, Zoom } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import { getCommonBounds, getElementBounds } from "./bounds";
|
||||
import {
|
||||
isArrowElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
} from "./typeChecks";
|
||||
import { isFreeDrawElement, isLinearElement } from "./typeChecks";
|
||||
|
||||
import type { ElementsMap, ExcalidrawElement } from "./types";
|
||||
|
||||
export const INVISIBLY_SMALL_ELEMENT_SIZE = 0.1;
|
||||
|
||||
// TODO: remove invisible elements consistently actions, so that invisible elements are not recorded by the store, exported, broadcasted or persisted
|
||||
// - perhaps could be as part of a standalone 'cleanup' action, in addition to 'finalize'
|
||||
// - could also be part of `_clearElements`
|
||||
@@ -31,18 +17,8 @@ export const isInvisiblySmallElement = (
|
||||
element: ExcalidrawElement,
|
||||
): boolean => {
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
return (
|
||||
element.points.length < 2 ||
|
||||
(element.points.length === 2 &&
|
||||
isArrowElement(element) &&
|
||||
pointsEqual(
|
||||
element.points[0],
|
||||
element.points[element.points.length - 1],
|
||||
INVISIBLY_SMALL_ELEMENT_SIZE,
|
||||
))
|
||||
);
|
||||
return element.points.length < 2;
|
||||
}
|
||||
|
||||
return element.width === 0 && element.height === 0;
|
||||
};
|
||||
|
||||
@@ -158,42 +134,13 @@ export const getLockedLinearCursorAlignSize = (
|
||||
originY: number,
|
||||
x: number,
|
||||
y: number,
|
||||
customAngle?: number,
|
||||
) => {
|
||||
let width = x - originX;
|
||||
let height = y - originY;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
const lockedAngle =
|
||||
Math.round(Math.atan(height / width) / SHIFT_LOCKING_ANGLE) *
|
||||
SHIFT_LOCKING_ANGLE;
|
||||
|
||||
if (lockedAngle === 0) {
|
||||
height = 0;
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import { ROUNDNESS, assertNever } from "@excalidraw/common";
|
||||
|
||||
import { pointsEqual } from "@excalidraw/math";
|
||||
|
||||
import type { ElementOrToolType } from "@excalidraw/excalidraw/types";
|
||||
|
||||
import type { MarkNonNullable } from "@excalidraw/common/utility-types";
|
||||
@@ -27,7 +25,6 @@ import type {
|
||||
ExcalidrawMagicFrameElement,
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElbowArrowElement,
|
||||
ExcalidrawLineElement,
|
||||
PointBinding,
|
||||
FixedPointBinding,
|
||||
ExcalidrawFlowchartNodeElement,
|
||||
@@ -111,12 +108,6 @@ export const isLinearElement = (
|
||||
return element != null && isLinearElementType(element.type);
|
||||
};
|
||||
|
||||
export const isLineElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawLineElement => {
|
||||
return element != null && element.type === "line";
|
||||
};
|
||||
|
||||
export const isArrowElement = (
|
||||
element?: ExcalidrawElement | null,
|
||||
): element is ExcalidrawArrowElement => {
|
||||
@@ -381,26 +372,3 @@ export const getLinearElementSubType = (
|
||||
}
|
||||
return "line";
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if current element points meet all the conditions for polygon=true
|
||||
* (this isn't a element type check, for that use isLineElement).
|
||||
*
|
||||
* If you want to check if points *can* be turned into a polygon, use
|
||||
* canBecomePolygon(points).
|
||||
*/
|
||||
export const isValidPolygon = (
|
||||
points: ExcalidrawLineElement["points"],
|
||||
): boolean => {
|
||||
return points.length > 3 && pointsEqual(points[0], points[points.length - 1]);
|
||||
};
|
||||
|
||||
export const canBecomePolygon = (
|
||||
points: ExcalidrawLineElement["points"],
|
||||
): boolean => {
|
||||
return (
|
||||
points.length > 3 ||
|
||||
// 3-point polygons can't have all points in a single line
|
||||
(points.length === 3 && !pointsEqual(points[0], points[points.length - 1]))
|
||||
);
|
||||
};
|
||||
|
@@ -296,10 +296,8 @@ export type FixedPointBinding = Merge<
|
||||
}
|
||||
>;
|
||||
|
||||
type Index = number;
|
||||
|
||||
export type PointsPositionUpdates = Map<
|
||||
Index,
|
||||
number,
|
||||
{ point: LocalPoint; isDragging?: boolean }
|
||||
>;
|
||||
|
||||
@@ -328,16 +326,10 @@ export type ExcalidrawLinearElement = _ExcalidrawElementBase &
|
||||
endArrowhead: Arrowhead | null;
|
||||
}>;
|
||||
|
||||
export type ExcalidrawLineElement = ExcalidrawLinearElement &
|
||||
Readonly<{
|
||||
type: "line";
|
||||
polygon: boolean;
|
||||
}>;
|
||||
|
||||
export type FixedSegment = {
|
||||
start: LocalPoint;
|
||||
end: LocalPoint;
|
||||
index: Index;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export type ExcalidrawArrowElement = ExcalidrawLinearElement &
|
||||
|
@@ -1411,55 +1411,5 @@ 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -258,7 +258,11 @@ export const actionDeleteSelected = register({
|
||||
: endBindingElement,
|
||||
};
|
||||
|
||||
LinearElementEditor.deletePoints(element, app, selectedPointsIndices);
|
||||
LinearElementEditor.deletePoints(
|
||||
element,
|
||||
app.scene,
|
||||
selectedPointsIndices,
|
||||
);
|
||||
|
||||
return {
|
||||
elements,
|
||||
|
@@ -3,16 +3,10 @@ import { pointFrom } from "@excalidraw/math";
|
||||
import {
|
||||
maybeBindLinearElement,
|
||||
bindOrUnbindLinearElement,
|
||||
isBindingEnabled,
|
||||
} from "@excalidraw/element/binding";
|
||||
import { isValidPolygon, LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
isBindingElement,
|
||||
isFreeDrawElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
|
||||
import { isBindingElement, isLinearElement } from "@excalidraw/element";
|
||||
|
||||
import { KEYS, arrayToMap, updateActiveTool } from "@excalidraw/common";
|
||||
import { isPathALoop } from "@excalidraw/element";
|
||||
@@ -21,13 +15,6 @@ import { isInvisiblySmallElement } from "@excalidraw/element";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
import type {
|
||||
ExcalidrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
NonDeleted,
|
||||
} from "@excalidraw/element/types";
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { resetCursor } from "../cursor";
|
||||
import { done } from "../components/icons";
|
||||
@@ -41,50 +28,11 @@ export const actionFinalize = register({
|
||||
name: "finalize",
|
||||
label: "",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, data, app) => {
|
||||
perform: (elements, appState, _, app) => {
|
||||
const { interactiveCanvas, focusContainer, scene } = app;
|
||||
|
||||
const elementsMap = scene.getNonDeletedElementsMap();
|
||||
|
||||
if (data?.event && appState.selectedLinearElement) {
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
data.event,
|
||||
appState.selectedLinearElement,
|
||||
appState,
|
||||
app.scene,
|
||||
);
|
||||
|
||||
const { startBindingElement, endBindingElement } = linearElementEditor;
|
||||
const element = app.scene.getElement(linearElementEditor.elementId);
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
app.scene,
|
||||
);
|
||||
}
|
||||
|
||||
if (linearElementEditor !== appState.selectedLinearElement) {
|
||||
let newElements = elements;
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
}
|
||||
return {
|
||||
elements: newElements,
|
||||
appState: {
|
||||
selectedLinearElement: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
},
|
||||
suggestedBindings: [],
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (appState.editingLinearElement) {
|
||||
const { elementId, startBindingElement, endBindingElement } =
|
||||
appState.editingLinearElement;
|
||||
@@ -99,12 +47,6 @@ export const actionFinalize = register({
|
||||
scene,
|
||||
);
|
||||
}
|
||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
elements:
|
||||
element.points.length < 2 || isInvisiblySmallElement(element)
|
||||
@@ -138,98 +80,75 @@ export const actionFinalize = register({
|
||||
focusContainer();
|
||||
}
|
||||
|
||||
let element: NonDeleted<ExcalidrawElement> | null = null;
|
||||
if (appState.multiElement) {
|
||||
element = appState.multiElement;
|
||||
} else if (
|
||||
appState.newElement?.type === "freedraw" ||
|
||||
isBindingElement(appState.newElement)
|
||||
) {
|
||||
element = appState.newElement;
|
||||
} else if (Object.keys(appState.selectedElementIds).length === 1) {
|
||||
const candidate = elementsMap.get(
|
||||
Object.keys(appState.selectedElementIds)[0],
|
||||
) as NonDeleted<ExcalidrawLinearElement> | undefined;
|
||||
if (candidate) {
|
||||
element = candidate;
|
||||
}
|
||||
}
|
||||
const multiPointElement = appState.multiElement
|
||||
? appState.multiElement
|
||||
: appState.newElement?.type === "freedraw"
|
||||
? appState.newElement
|
||||
: null;
|
||||
|
||||
if (element) {
|
||||
if (multiPointElement) {
|
||||
// pen and mouse have hover
|
||||
if (
|
||||
appState.multiElement &&
|
||||
element.type !== "freedraw" &&
|
||||
multiPointElement.type !== "freedraw" &&
|
||||
appState.lastPointerDownWith !== "touch"
|
||||
) {
|
||||
const { points, lastCommittedPoint } = element;
|
||||
const { points, lastCommittedPoint } = multiPointElement;
|
||||
if (
|
||||
!lastCommittedPoint ||
|
||||
points[points.length - 1] !== lastCommittedPoint
|
||||
) {
|
||||
scene.mutateElement(element, {
|
||||
points: element.points.slice(0, -1),
|
||||
scene.mutateElement(multiPointElement, {
|
||||
points: multiPointElement.points.slice(0, -1),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (element && isInvisiblySmallElement(element)) {
|
||||
if (isInvisiblySmallElement(multiPointElement)) {
|
||||
// TODO: #7348 in theory this gets recorded by the store, so the invisible elements could be restored by the undo/redo, which might be not what we would want
|
||||
newElements = newElements.filter((el) => el.id !== element!.id);
|
||||
newElements = newElements.filter(
|
||||
(el) => el.id !== multiPointElement.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLinearElement(element) || isFreeDrawElement(element)) {
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(element.points, appState.zoom.value);
|
||||
|
||||
if (isLoop && (isLineElement(element) || isFreeDrawElement(element))) {
|
||||
const linePoints = element.points;
|
||||
// If the multi point line closes the loop,
|
||||
// set the last point to first point.
|
||||
// This ensures that loop remains closed at different scales.
|
||||
const isLoop = isPathALoop(multiPointElement.points, appState.zoom.value);
|
||||
if (
|
||||
multiPointElement.type === "line" ||
|
||||
multiPointElement.type === "freedraw"
|
||||
) {
|
||||
if (isLoop) {
|
||||
const linePoints = multiPointElement.points;
|
||||
const firstPoint = linePoints[0];
|
||||
const points: LocalPoint[] = linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
);
|
||||
if (isLineElement(element)) {
|
||||
scene.mutateElement(element, {
|
||||
points,
|
||||
polygon: true,
|
||||
});
|
||||
} else {
|
||||
scene.mutateElement(element, {
|
||||
points,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isLineElement(element) && !isValidPolygon(element.points)) {
|
||||
scene.mutateElement(element, {
|
||||
polygon: false,
|
||||
scene.mutateElement(multiPointElement, {
|
||||
points: linePoints.map((p, index) =>
|
||||
index === linePoints.length - 1
|
||||
? pointFrom(firstPoint[0], firstPoint[1])
|
||||
: p,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isBindingElement(element) &&
|
||||
!isLoop &&
|
||||
element.points.length > 1 &&
|
||||
isBindingEnabled(appState)
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
element,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(element, appState, { x, y }, scene);
|
||||
}
|
||||
if (
|
||||
isBindingElement(multiPointElement) &&
|
||||
!isLoop &&
|
||||
multiPointElement.points.length > 1
|
||||
) {
|
||||
const [x, y] = LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiPointElement,
|
||||
-1,
|
||||
arrayToMap(elements),
|
||||
);
|
||||
maybeBindLinearElement(multiPointElement, appState, { x, y }, scene);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw") ||
|
||||
!element
|
||||
!multiPointElement
|
||||
) {
|
||||
resetCursor(interactiveCanvas);
|
||||
}
|
||||
@@ -256,7 +175,7 @@ export const actionFinalize = register({
|
||||
activeTool:
|
||||
(appState.activeTool.locked ||
|
||||
appState.activeTool.type === "freedraw") &&
|
||||
element
|
||||
multiPointElement
|
||||
? appState.activeTool
|
||||
: activeTool,
|
||||
activeEmbeddable: null,
|
||||
@@ -267,18 +186,21 @@ export const actionFinalize = register({
|
||||
startBoundElement: null,
|
||||
suggestedBindings: [],
|
||||
selectedElementIds:
|
||||
element &&
|
||||
multiPointElement &&
|
||||
!appState.activeTool.locked &&
|
||||
appState.activeTool.type !== "freedraw"
|
||||
? {
|
||||
...appState.selectedElementIds,
|
||||
[element.id]: true,
|
||||
[multiPointElement.id]: true,
|
||||
}
|
||||
: appState.selectedElementIds,
|
||||
// To select the linear element when user has finished mutipoint editing
|
||||
selectedLinearElement:
|
||||
element && isLinearElement(element)
|
||||
? new LinearElementEditor(element, arrayToMap(newElements))
|
||||
multiPointElement && isLinearElement(multiPointElement)
|
||||
? new LinearElementEditor(
|
||||
multiPointElement,
|
||||
arrayToMap(newElements),
|
||||
)
|
||||
: appState.selectedLinearElement,
|
||||
pendingImageElementId: null,
|
||||
},
|
||||
|
@@ -1,29 +1,19 @@
|
||||
import { LinearElementEditor } from "@excalidraw/element";
|
||||
import {
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
import { isElbowArrow, isLinearElement } from "@excalidraw/element";
|
||||
|
||||
import { arrayToMap } from "@excalidraw/common";
|
||||
|
||||
import { CaptureUpdateAction } from "@excalidraw/element";
|
||||
|
||||
import type {
|
||||
ExcalidrawLinearElement,
|
||||
ExcalidrawLineElement,
|
||||
} from "@excalidraw/element/types";
|
||||
import type { ExcalidrawLinearElement } from "@excalidraw/element/types";
|
||||
|
||||
import { DEFAULT_CATEGORIES } from "../components/CommandPalette/CommandPalette";
|
||||
import { ToolButton } from "../components/ToolButton";
|
||||
import { lineEditorIcon, polygonIcon } from "../components/icons";
|
||||
import { lineEditorIcon } from "../components/icons";
|
||||
|
||||
import { t } from "../i18n";
|
||||
|
||||
import { ButtonIcon } from "../components/ButtonIcon";
|
||||
|
||||
import { newElementWith } from "../../element/src/mutateElement";
|
||||
|
||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
export const actionToggleLinearEditor = register({
|
||||
@@ -93,110 +83,3 @@ export const actionToggleLinearEditor = register({
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const actionTogglePolygon = register({
|
||||
name: "togglePolygon",
|
||||
category: DEFAULT_CATEGORIES.elements,
|
||||
icon: polygonIcon,
|
||||
keywords: ["loop"],
|
||||
label: (elements, appState, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
const allPolygons = !selectedElements.some(
|
||||
(element) => !isLineElement(element) || !element.polygon,
|
||||
);
|
||||
|
||||
return allPolygons
|
||||
? "labels.polygon.breakPolygon"
|
||||
: "labels.polygon.convertToPolygon";
|
||||
},
|
||||
trackEvent: {
|
||||
category: "element",
|
||||
},
|
||||
predicate: (elements, appState, _, app) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
return (
|
||||
selectedElements.length > 0 &&
|
||||
selectedElements.every(
|
||||
(element) => isLineElement(element) && element.points.length >= 4,
|
||||
)
|
||||
);
|
||||
},
|
||||
perform(elements, appState, _, app) {
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
|
||||
if (selectedElements.some((element) => !isLineElement(element))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const targetElements = selectedElements as ExcalidrawLineElement[];
|
||||
|
||||
// if one element not a polygon, convert all to polygon
|
||||
const nextPolygonState = targetElements.some((element) => !element.polygon);
|
||||
|
||||
const targetElementsMap = arrayToMap(targetElements);
|
||||
|
||||
return {
|
||||
elements: elements.map((element) => {
|
||||
if (!targetElementsMap.has(element.id) || !isLineElement(element)) {
|
||||
return element;
|
||||
}
|
||||
|
||||
return newElementWith(element, {
|
||||
backgroundColor: nextPolygonState
|
||||
? element.backgroundColor
|
||||
: "transparent",
|
||||
...toggleLinePolygonState(element, nextPolygonState),
|
||||
});
|
||||
}),
|
||||
appState,
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ appState, updateData, app }) => {
|
||||
const selectedElements = app.scene.getSelectedElements({
|
||||
selectedElementIds: appState.selectedElementIds,
|
||||
});
|
||||
|
||||
if (
|
||||
selectedElements.length === 0 ||
|
||||
selectedElements.some(
|
||||
(element) =>
|
||||
!isLineElement(element) ||
|
||||
// only show polygon button if every selected element is already
|
||||
// a polygon, effectively showing this button only to allow for
|
||||
// disabling the polygon state
|
||||
!element.polygon ||
|
||||
element.points.length < 3,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allPolygon = selectedElements.every(
|
||||
(element) => isLineElement(element) && element.polygon,
|
||||
);
|
||||
|
||||
const label = t(
|
||||
allPolygon
|
||||
? "labels.polygon.breakPolygon"
|
||||
: "labels.polygon.convertToPolygon",
|
||||
);
|
||||
|
||||
return (
|
||||
<ButtonIcon
|
||||
icon={polygonIcon}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
active={allPolygon}
|
||||
onClick={() => updateData(null)}
|
||||
style={{ marginLeft: "auto" }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
@@ -20,11 +20,10 @@ import {
|
||||
getShortcutKey,
|
||||
tupleToCoors,
|
||||
getLineHeight,
|
||||
isTransparent,
|
||||
reduceToCommonValue,
|
||||
} from "@excalidraw/common";
|
||||
|
||||
import { canBecomePolygon, getNonDeletedElements } from "@excalidraw/element";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bindLinearElement,
|
||||
@@ -48,7 +47,6 @@ import {
|
||||
isBoundToContainer,
|
||||
isElbowArrow,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
@@ -138,8 +136,6 @@ import {
|
||||
isSomeElementSelected,
|
||||
} from "../scene";
|
||||
|
||||
import { toggleLinePolygonState } from "../../element/src/shapes";
|
||||
|
||||
import { register } from "./register";
|
||||
|
||||
import type { AppClassProperties, AppState, Primitive } from "../types";
|
||||
@@ -353,52 +349,22 @@ export const actionChangeBackgroundColor = register({
|
||||
name: "changeBackgroundColor",
|
||||
label: "labels.changeBackground",
|
||||
trackEvent: false,
|
||||
perform: (elements, appState, value, app) => {
|
||||
if (!value.currentItemBackgroundColor) {
|
||||
return {
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
}
|
||||
|
||||
let nextElements;
|
||||
|
||||
const selectedElements = app.scene.getSelectedElements(appState);
|
||||
const shouldEnablePolygon =
|
||||
!isTransparent(value.currentItemBackgroundColor) &&
|
||||
selectedElements.every(
|
||||
(el) => isLineElement(el) && canBecomePolygon(el.points),
|
||||
);
|
||||
|
||||
if (shouldEnablePolygon) {
|
||||
const selectedElementsMap = arrayToMap(selectedElements);
|
||||
nextElements = elements.map((el) => {
|
||||
if (selectedElementsMap.has(el.id) && isLineElement(el)) {
|
||||
return newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
...toggleLinePolygonState(el, true),
|
||||
});
|
||||
}
|
||||
return el;
|
||||
});
|
||||
} else {
|
||||
nextElements = changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
perform: (elements, appState, value) => {
|
||||
return {
|
||||
elements: nextElements,
|
||||
...(value.currentItemBackgroundColor && {
|
||||
elements: changeProperty(elements, appState, (el) =>
|
||||
newElementWith(el, {
|
||||
backgroundColor: value.currentItemBackgroundColor,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
appState: {
|
||||
...appState,
|
||||
...value,
|
||||
},
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
captureUpdate: !!value.currentItemBackgroundColor
|
||||
? CaptureUpdateAction.IMMEDIATELY
|
||||
: CaptureUpdateAction.EVENTUALLY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => (
|
||||
@@ -1407,7 +1373,7 @@ export const actionChangeRoundness = register({
|
||||
captureUpdate: CaptureUpdateAction.IMMEDIATELY,
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData, app, renderAction }) => {
|
||||
PanelComponent: ({ elements, appState, updateData, app }) => {
|
||||
const targetElements = getTargetElements(
|
||||
getNonDeletedElements(elements),
|
||||
appState,
|
||||
@@ -1451,7 +1417,6 @@ export const actionChangeRoundness = register({
|
||||
)}
|
||||
onChange={(value) => updateData(value)}
|
||||
/>
|
||||
{renderAction("togglePolygon")}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
|
@@ -179,7 +179,6 @@ export class ActionManager {
|
||||
appProps={this.app.props}
|
||||
app={this.app}
|
||||
data={data}
|
||||
renderAction={this.renderAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@@ -142,8 +142,7 @@ export type ActionName =
|
||||
| "cropEditor"
|
||||
| "wrapSelectionInFrame"
|
||||
| "toggleLassoTool"
|
||||
| "toggleShapeSwitch"
|
||||
| "togglePolygon";
|
||||
| "toggleShapeSwitch";
|
||||
|
||||
export type PanelComponentProps = {
|
||||
elements: readonly ExcalidrawElement[];
|
||||
@@ -152,10 +151,6 @@ export type PanelComponentProps = {
|
||||
appProps: ExcalidrawProps;
|
||||
data?: Record<string, any>;
|
||||
app: AppClassProperties;
|
||||
renderAction: (
|
||||
name: ActionName,
|
||||
data?: PanelComponentProps["data"],
|
||||
) => React.JSX.Element | null;
|
||||
};
|
||||
|
||||
export interface Action {
|
||||
|
@@ -107,11 +107,13 @@ import {
|
||||
import { getCommonBounds, getElementAbsoluteCoords } from "@excalidraw/element";
|
||||
|
||||
import {
|
||||
bindOrUnbindLinearElement,
|
||||
bindOrUnbindLinearElements,
|
||||
fixBindingsAfterDeletion,
|
||||
getHoveredElementForBinding,
|
||||
isBindingEnabled,
|
||||
isLinearElementSimpleAndAlreadyBound,
|
||||
maybeBindLinearElement,
|
||||
shouldEnableBindingForPointerEvent,
|
||||
updateBoundElements,
|
||||
getSuggestedBindingsForArrows,
|
||||
@@ -2795,6 +2797,7 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.updateEmbeddables();
|
||||
const elements = this.scene.getElementsIncludingDeleted();
|
||||
const elementsMap = this.scene.getElementsMapIncludingDeleted();
|
||||
const nonDeletedElementsMap = this.scene.getNonDeletedElementsMap();
|
||||
|
||||
if (!this.state.showWelcomeScreen && !elements.length) {
|
||||
this.setState({ showWelcomeScreen: true });
|
||||
@@ -2941,6 +2944,27 @@ class App extends React.Component<AppProps, AppState> {
|
||||
this.setState({ selectedLinearElement: null });
|
||||
}
|
||||
|
||||
const { multiElement } = prevState;
|
||||
if (
|
||||
prevState.activeTool !== this.state.activeTool &&
|
||||
multiElement != null &&
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(multiElement, false)
|
||||
) {
|
||||
maybeBindLinearElement(
|
||||
multiElement,
|
||||
this.state,
|
||||
tupleToCoors(
|
||||
LinearElementEditor.getPointAtIndexGlobalCoordinates(
|
||||
multiElement,
|
||||
-1,
|
||||
nonDeletedElementsMap,
|
||||
),
|
||||
),
|
||||
this.scene,
|
||||
);
|
||||
}
|
||||
|
||||
this.store.commit(elementsMap, this.state);
|
||||
|
||||
// Do not notify consumers if we're still loading the scene. Among other
|
||||
@@ -9118,10 +9142,35 @@ class App extends React.Component<AppProps, AppState> {
|
||||
if (selectedELements.length > 1) {
|
||||
this.setState({ selectedLinearElement: null });
|
||||
}
|
||||
} else if (this.state.selectedLinearElement.isDragging) {
|
||||
this.actionManager.executeAction(actionFinalize, "ui", {
|
||||
event: childEvent,
|
||||
});
|
||||
} else {
|
||||
const linearElementEditor = LinearElementEditor.handlePointerUp(
|
||||
childEvent,
|
||||
this.state.selectedLinearElement,
|
||||
this.state,
|
||||
this.scene,
|
||||
);
|
||||
|
||||
const { startBindingElement, endBindingElement } =
|
||||
linearElementEditor;
|
||||
const element = this.scene.getElement(linearElementEditor.elementId);
|
||||
if (isBindingElement(element)) {
|
||||
bindOrUnbindLinearElement(
|
||||
element,
|
||||
startBindingElement,
|
||||
endBindingElement,
|
||||
this.scene,
|
||||
);
|
||||
}
|
||||
|
||||
if (linearElementEditor !== this.state.selectedLinearElement) {
|
||||
this.setState({
|
||||
selectedLinearElement: {
|
||||
...linearElementEditor,
|
||||
selectedPointsIndices: null,
|
||||
},
|
||||
suggestedBindings: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9245,7 +9294,12 @@ class App extends React.Component<AppProps, AppState> {
|
||||
isBindingEnabled(this.state) &&
|
||||
isBindingElement(newElement, false)
|
||||
) {
|
||||
this.actionManager.executeAction(actionFinalize);
|
||||
maybeBindLinearElement(
|
||||
newElement,
|
||||
this.state,
|
||||
pointerCoords,
|
||||
this.scene,
|
||||
);
|
||||
}
|
||||
this.setState({ suggestedBindings: [], startBoundElement: null });
|
||||
if (!activeTool.locked) {
|
||||
|
@@ -15,7 +15,6 @@ interface ButtonIconProps {
|
||||
/** include standalone style (could interfere with parent styles) */
|
||||
standalone?: boolean;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
@@ -31,7 +30,6 @@ export const ButtonIcon = forwardRef<HTMLButtonElement, ButtonIconProps>(
|
||||
data-testid={testId}
|
||||
className={clsx(className, { standalone, active })}
|
||||
onClick={onClick}
|
||||
style={props.style}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
@@ -293,7 +293,6 @@ function CommandPaletteInner({
|
||||
actionManager.actions.decreaseFontSize,
|
||||
actionManager.actions.toggleLinearEditor,
|
||||
actionManager.actions.cropEditor,
|
||||
actionManager.actions.togglePolygon,
|
||||
actionLink,
|
||||
actionCopyElementLink,
|
||||
actionLinkToElement,
|
||||
|
@@ -129,21 +129,6 @@ export const PinIcon = createIcon(
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
export const polygonIcon = createIcon(
|
||||
<g strokeWidth={1.25}>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 5m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M19 8m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M5 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M15 19m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
|
||||
<path d="M6.5 9.5l3.5 -3" />
|
||||
<path d="M14 5.5l3 1.5" />
|
||||
<path d="M18.5 10l-2.5 7" />
|
||||
<path d="M13.5 17.5l-7 -5" />
|
||||
</g>,
|
||||
tablerIconProps,
|
||||
);
|
||||
|
||||
// tabler-icons: lock-open (via Figma)
|
||||
export const UnlockedIcon = createIcon(
|
||||
<g>
|
||||
|
@@ -948,7 +948,6 @@ exports[`Test Transform > should transform linear elements 3`] = `
|
||||
0,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
@@ -996,7 +995,6 @@ exports[`Test Transform > should transform linear elements 4`] = `
|
||||
0,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": null,
|
||||
"seed": Any<Number>,
|
||||
|
@@ -18,7 +18,7 @@ import {
|
||||
normalizeLink,
|
||||
getLineHeight,
|
||||
} from "@excalidraw/common";
|
||||
import { getNonDeletedElements, isValidPolygon } from "@excalidraw/element";
|
||||
import { getNonDeletedElements } from "@excalidraw/element";
|
||||
import { normalizeFixedPoint } from "@excalidraw/element";
|
||||
import {
|
||||
updateElbowArrowPoints,
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
isElbowArrow,
|
||||
isFixedPointBinding,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
isUsingAdaptiveRadius,
|
||||
} from "@excalidraw/element";
|
||||
@@ -324,8 +323,7 @@ const restoreElement = (
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
({ points, x, y } =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
return restoreElementWithProperties(element, {
|
||||
@@ -341,13 +339,6 @@ const restoreElement = (
|
||||
points,
|
||||
x,
|
||||
y,
|
||||
...(isLineElement(element)
|
||||
? {
|
||||
polygon: isValidPolygon(element.points)
|
||||
? element.polygon ?? false
|
||||
: false,
|
||||
}
|
||||
: {}),
|
||||
...getSizeFromPoints(points),
|
||||
});
|
||||
case "arrow": {
|
||||
@@ -360,8 +351,7 @@ const restoreElement = (
|
||||
: element.points;
|
||||
|
||||
if (points[0][0] !== 0 || points[0][1] !== 0) {
|
||||
({ points, x, y } =
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords(element));
|
||||
({ points, x, y } = LinearElementEditor.getNormalizedPoints(element));
|
||||
}
|
||||
|
||||
const base = {
|
||||
|
@@ -466,7 +466,7 @@ const bindLinearElementToElement = (
|
||||
|
||||
Object.assign(
|
||||
linearElement,
|
||||
LinearElementEditor.getNormalizeElementPointsAndCoords({
|
||||
LinearElementEditor.getNormalizedPoints({
|
||||
...linearElement,
|
||||
points: newPoints,
|
||||
}),
|
||||
|
@@ -141,10 +141,6 @@
|
||||
"edit": "Edit line",
|
||||
"editArrow": "Edit arrow"
|
||||
},
|
||||
"polygon": {
|
||||
"breakPolygon": "Break polygon",
|
||||
"convertToPolygon": "Convert to polygon"
|
||||
},
|
||||
"elementLock": {
|
||||
"lock": "Lock",
|
||||
"unlock": "Unlock",
|
||||
@@ -253,8 +249,7 @@
|
||||
"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!",
|
||||
"collabRoomDeleted": "This collab room has been deleted by its owner."
|
||||
"collabOfflineWarning": "No internet connection available.\nYour changes will not be saved!"
|
||||
},
|
||||
"errors": {
|
||||
"unsupportedFileType": "Unsupported file type.",
|
||||
@@ -281,8 +276,7 @@
|
||||
},
|
||||
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
|
||||
"asyncPasteFailedOnParse": "Couldn't paste.",
|
||||
"copyToSystemClipboardFailed": "Couldn't copy to clipboard.",
|
||||
"roomDeletionFailed": "Couldn't delete the collaboration room."
|
||||
"copyToSystemClipboardFailed": "Couldn't copy to clipboard."
|
||||
},
|
||||
"toolBar": {
|
||||
"selection": "Selection",
|
||||
@@ -378,14 +372,11 @@
|
||||
"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": {
|
||||
|
@@ -31,14 +31,11 @@ export const fillCircle = (
|
||||
cx: number,
|
||||
cy: number,
|
||||
radius: number,
|
||||
stroke: boolean,
|
||||
fill = true,
|
||||
stroke = true,
|
||||
) => {
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
if (fill) {
|
||||
context.fill();
|
||||
}
|
||||
context.fill();
|
||||
if (stroke) {
|
||||
context.stroke();
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
pointFrom,
|
||||
pointsEqual,
|
||||
type GlobalPoint,
|
||||
type LocalPoint,
|
||||
type Radians,
|
||||
@@ -29,7 +28,6 @@ import {
|
||||
isFrameLikeElement,
|
||||
isImageElement,
|
||||
isLinearElement,
|
||||
isLineElement,
|
||||
isTextElement,
|
||||
} from "@excalidraw/element";
|
||||
|
||||
@@ -163,8 +161,7 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
point: Point,
|
||||
radius: number,
|
||||
isSelected: boolean,
|
||||
isPhantomPoint: boolean,
|
||||
isOverlappingPoint: boolean,
|
||||
isPhantomPoint = false,
|
||||
) => {
|
||||
context.strokeStyle = "#5e5ad8";
|
||||
context.setLineDash([]);
|
||||
@@ -179,11 +176,8 @@ const renderSingleLinearPoint = <Point extends GlobalPoint | LocalPoint>(
|
||||
context,
|
||||
point[0],
|
||||
point[1],
|
||||
(isOverlappingPoint
|
||||
? radius * (appState.editingLinearElement ? 1.5 : 2)
|
||||
: radius) / appState.zoom.value,
|
||||
radius / appState.zoom.value,
|
||||
!isPhantomPoint,
|
||||
!isOverlappingPoint || isSelected,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -259,7 +253,7 @@ const renderBindingHighlightForSuggestedPointBinding = (
|
||||
index,
|
||||
elementsMap,
|
||||
);
|
||||
fillCircle(context, x, y, threshold, true);
|
||||
fillCircle(context, x, y, threshold);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -448,48 +442,15 @@ const renderLinearPointHandles = (
|
||||
const radius = appState.editingLinearElement
|
||||
? POINT_HANDLE_SIZE
|
||||
: POINT_HANDLE_SIZE / 2;
|
||||
|
||||
const _isElbowArrow = isElbowArrow(element);
|
||||
const _isLineElement = isLineElement(element);
|
||||
|
||||
points.forEach((point, idx) => {
|
||||
if (_isElbowArrow && idx !== 0 && idx !== points.length - 1) {
|
||||
if (isElbowArrow(element) && idx !== 0 && idx !== points.length - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isOverlappingPoint =
|
||||
idx > 0 &&
|
||||
(idx !== points.length - 1 || !_isLineElement || !element.polygon) &&
|
||||
pointsEqual(
|
||||
point,
|
||||
idx === points.length - 1 ? points[0] : points[idx - 1],
|
||||
2 / appState.zoom.value,
|
||||
);
|
||||
|
||||
let isSelected =
|
||||
const isSelected =
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(idx);
|
||||
// when element is a polygon, highlight the last point as well if first
|
||||
// point is selected since they overlap and the last point tends to be
|
||||
// rendered on top
|
||||
if (
|
||||
_isLineElement &&
|
||||
element.polygon &&
|
||||
!isSelected &&
|
||||
idx === element.points.length - 1 &&
|
||||
!!appState.editingLinearElement?.selectedPointsIndices?.includes(0)
|
||||
) {
|
||||
isSelected = true;
|
||||
}
|
||||
|
||||
renderSingleLinearPoint(
|
||||
context,
|
||||
appState,
|
||||
point,
|
||||
radius,
|
||||
isSelected,
|
||||
false,
|
||||
isOverlappingPoint,
|
||||
);
|
||||
renderSingleLinearPoint(context, appState, point, radius, isSelected);
|
||||
});
|
||||
|
||||
// Rendering segment mid points
|
||||
@@ -516,7 +477,6 @@ const renderLinearPointHandles = (
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
!fixedSegments.includes(idx + 1),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -540,7 +500,6 @@ const renderLinearPointHandles = (
|
||||
POINT_HANDLE_SIZE / 2,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -567,7 +526,7 @@ const renderTransformHandles = (
|
||||
context.strokeStyle = renderConfig.selectionColor;
|
||||
}
|
||||
if (key === "rotation") {
|
||||
fillCircle(context, x + width / 2, y + height / 2, width / 2, true);
|
||||
fillCircle(context, x + width / 2, y + height / 2, width / 2);
|
||||
// prefer round corners if roundRect API is available
|
||||
} else if (context.roundRect) {
|
||||
context.beginPath();
|
||||
|
@@ -153,7 +153,6 @@ exports[`Test dragCreate > add element to the scene when pointer dragging long e
|
||||
50,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
|
@@ -173,7 +173,7 @@ exports[`move element > rectangles with binding arrow 6`] = `
|
||||
"type": "rectangle",
|
||||
"updated": 1,
|
||||
"version": 7,
|
||||
"versionNonce": 1051383431,
|
||||
"versionNonce": 745419401,
|
||||
"width": 300,
|
||||
"x": 201,
|
||||
"y": 2,
|
||||
@@ -231,7 +231,7 @@ exports[`move element > rectangles with binding arrow 7`] = `
|
||||
"type": "arrow",
|
||||
"updated": 1,
|
||||
"version": 11,
|
||||
"versionNonce": 1996028265,
|
||||
"versionNonce": 1051383431,
|
||||
"width": "86.85786",
|
||||
"x": "107.07107",
|
||||
"y": "47.07107",
|
||||
|
@@ -93,7 +93,6 @@ exports[`multi point mode in linear elements > line 3`] = `
|
||||
110,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
|
@@ -6492,7 +6492,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
10,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
@@ -6717,7 +6716,6 @@ exports[`regression tests > draw every type of shape > [end of test] undo stack
|
||||
10,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
@@ -8630,7 +8628,6 @@ 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",
|
||||
@@ -8854,7 +8851,6 @@ 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",
|
||||
@@ -8958,7 +8954,6 @@ exports[`regression tests > key 6 selects line tool > [end of test] undo stack 1
|
||||
10,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
@@ -9272,7 +9267,6 @@ 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",
|
||||
@@ -9676,7 +9670,6 @@ 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",
|
||||
@@ -9780,7 +9773,6 @@ exports[`regression tests > key l selects line tool > [end of test] undo stack 1
|
||||
10,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
|
@@ -79,7 +79,6 @@ exports[`select single element on the scene > arrow escape 1`] = `
|
||||
50,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
|
@@ -240,7 +240,6 @@ exports[`restoreElements > should restore line and draw elements correctly 1`] =
|
||||
100,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
@@ -290,7 +289,6 @@ exports[`restoreElements > should restore line and draw elements correctly 2`] =
|
||||
100,
|
||||
],
|
||||
],
|
||||
"polygon": false,
|
||||
"roughness": 1,
|
||||
"roundness": {
|
||||
"type": 2,
|
||||
|
@@ -6,10 +6,7 @@ import { DEFAULT_SIDEBAR, FONT_FAMILY, ROUNDNESS } from "@excalidraw/common";
|
||||
import { newElementWith } from "@excalidraw/element";
|
||||
import * as sizeHelpers from "@excalidraw/element";
|
||||
|
||||
import type { LocalPoint } from "@excalidraw/math";
|
||||
|
||||
import type {
|
||||
ExcalidrawArrowElement,
|
||||
ExcalidrawElement,
|
||||
ExcalidrawFreeDrawElement,
|
||||
ExcalidrawLinearElement,
|
||||
@@ -166,109 +163,6 @@ describe("restoreElements", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should remove imperceptibly small elements", () => {
|
||||
const arrowElement = API.createElement({
|
||||
type: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.02, 0.05],
|
||||
] as LocalPoint[],
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
||||
const restoredArrow = restoredElements[0] as
|
||||
| ExcalidrawArrowElement
|
||||
| undefined;
|
||||
|
||||
expect(restoredArrow).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should keep 'imperceptibly' small freedraw/line elements", () => {
|
||||
const freedrawElement = API.createElement({
|
||||
type: "freedraw",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.0001, 0.0001],
|
||||
] as LocalPoint[],
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const lineElement = API.createElement({
|
||||
type: "line",
|
||||
points: [
|
||||
[0, 0],
|
||||
[0.0001, 0.0001],
|
||||
] as LocalPoint[],
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[freedrawElement, lineElement],
|
||||
null,
|
||||
);
|
||||
|
||||
expect(restoredElements).toEqual([
|
||||
expect.objectContaining({ id: freedrawElement.id }),
|
||||
expect.objectContaining({ id: lineElement.id }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should restore loop linears correctly", () => {
|
||||
const linearElement = API.createElement({
|
||||
type: "line",
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[100, 200],
|
||||
[0, 0],
|
||||
] as LocalPoint[],
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
const arrowElement = API.createElement({
|
||||
type: "arrow",
|
||||
points: [
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[100, 200],
|
||||
[0, 0],
|
||||
] as LocalPoint[],
|
||||
x: 500,
|
||||
y: 500,
|
||||
});
|
||||
|
||||
const restoredElements = restore.restoreElements(
|
||||
[linearElement, arrowElement],
|
||||
null,
|
||||
);
|
||||
|
||||
const restoredLinear = restoredElements[0] as
|
||||
| ExcalidrawLinearElement
|
||||
| undefined;
|
||||
const restoredArrow = restoredElements[1] as
|
||||
| ExcalidrawArrowElement
|
||||
| undefined;
|
||||
|
||||
expect(restoredLinear?.type).toBe("line");
|
||||
expect(restoredLinear?.points).toEqual([
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[100, 200],
|
||||
[0, 0],
|
||||
] as LocalPoint[]);
|
||||
expect(restoredArrow?.type).toBe("arrow");
|
||||
expect(restoredArrow?.points).toEqual([
|
||||
[0, 0],
|
||||
[100, 100],
|
||||
[100, 200],
|
||||
[0, 0],
|
||||
] as LocalPoint[]);
|
||||
});
|
||||
|
||||
it('should set arrow element endArrowHead as "arrow" when arrow element endArrowHead is null', () => {
|
||||
const arrowElement = API.createElement({ type: "arrow" });
|
||||
const restoredElements = restore.restoreElements([arrowElement], null);
|
||||
|
@@ -8,10 +8,16 @@ import type {
|
||||
Radians,
|
||||
} from "./types";
|
||||
|
||||
export const normalizeRadians = (angle: Radians): Radians =>
|
||||
angle < 0
|
||||
? (((angle % (2 * Math.PI)) + 2 * Math.PI) as Radians)
|
||||
: ((angle % (2 * Math.PI)) as Radians);
|
||||
// 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the polar coordinates for the given cartesian point represented by
|
||||
@@ -43,35 +49,3 @@ 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;
|
||||
}
|
||||
|
@@ -91,10 +91,9 @@ export function isPoint(p: unknown): p is LocalPoint | GlobalPoint {
|
||||
export function pointsEqual<Point extends GlobalPoint | LocalPoint>(
|
||||
a: Point,
|
||||
b: Point,
|
||||
tolerance: number = PRECISION,
|
||||
): boolean {
|
||||
const abs = Math.abs;
|
||||
return abs(a[0] - b[0]) < tolerance && abs(a[1] - b[1]) < tolerance;
|
||||
return abs(a[0] - b[0]) < PRECISION && abs(a[1] - b[1]) < PRECISION;
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user