mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-09-18 06:50:31 +02:00
feat: room list
This commit is contained in:
249
excalidraw-app/components/RoomList.scss
Normal file
249
excalidraw-app/components/RoomList.scss
Normal file
@@ -0,0 +1,249 @@
|
||||
@import "../../packages/excalidraw/css/variables.module.scss";
|
||||
|
||||
.RoomList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 400px;
|
||||
padding: 0 1rem;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.2rem;
|
||||
flex-shrink: 0;
|
||||
padding-top: 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__description {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__clearAll {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-lowest);
|
||||
color: var(--color-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__loading {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
padding: 2rem;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
p:first-child {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.RoomItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
// background-color: var(--color-surface-low);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-high);
|
||||
border-color: var(--color-border-accent);
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
min-width: 0; // Allow text truncation
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin-bottom: 0.25rem;
|
||||
height: 1.5rem; // Fixed height for consistent layout
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__nameText {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
height: 1.5rem;
|
||||
line-height: 1.5rem;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
&__nameInput {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-lowest);
|
||||
border: 1px solid var(--color-border-accent);
|
||||
border-radius: 4px;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
max-width: 200px;
|
||||
height: 1.5rem;
|
||||
line-height: 1.2;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&__date,
|
||||
&__lastAccessed {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
&__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-surface-lowest);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
&--danger {
|
||||
&:hover {
|
||||
background-color: var(--color-error-low);
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile responsiveness
|
||||
@media (max-width: 640px) {
|
||||
.RoomList {
|
||||
&__header {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
&__items {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.RoomItem {
|
||||
padding: 0.5rem;
|
||||
|
||||
&__nameText {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
&__nameInput {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
&__action {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
|
||||
svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
258
excalidraw-app/components/RoomList.tsx
Normal file
258
excalidraw-app/components/RoomList.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { trackEvent } from "@excalidraw/excalidraw/analytics";
|
||||
import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
import {
|
||||
FreedrawIcon,
|
||||
LinkIcon,
|
||||
TrashIcon,
|
||||
} from "@excalidraw/excalidraw/components/icons";
|
||||
|
||||
import { roomManager, type CollabRoom } from "../data/roomManager";
|
||||
|
||||
import "./RoomList.scss";
|
||||
|
||||
import type { CollabAPI } from "../collab/Collab";
|
||||
|
||||
interface RoomListProps {
|
||||
collabAPI: CollabAPI;
|
||||
onRoomSelect: (roomId: string, roomKey: string) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
const formatDate = (timestamp: number, t: ReturnType<typeof useI18n>["t"]) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return t("roomDialog.today");
|
||||
} else if (diffDays === 1) {
|
||||
return t("roomDialog.yesterday");
|
||||
} else if (diffDays < 7) {
|
||||
return t("roomDialog.daysAgo", { days: diffDays });
|
||||
}
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
const RoomItem = ({
|
||||
room,
|
||||
onDelete,
|
||||
onSelect,
|
||||
onRename,
|
||||
}: {
|
||||
room: CollabRoom;
|
||||
onDelete: (roomId: string) => void;
|
||||
onSelect: (roomId: string, roomKey: string) => void;
|
||||
onRename: (roomId: string, name: string) => void;
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(room.name || "");
|
||||
|
||||
const handleRename = () => {
|
||||
if (editName.trim()) {
|
||||
onRename(room.roomId, editName.trim());
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
handleRename();
|
||||
} else if (event.key === "Escape") {
|
||||
setEditName(room.name || "");
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="RoomItem">
|
||||
<div className="RoomItem__info">
|
||||
<div className="RoomItem__name">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("roomDialog.roomNamePlaceholder")}
|
||||
autoFocus
|
||||
className="RoomItem__nameInput"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="RoomItem__nameText"
|
||||
onClick={() => setIsEditing(true)}
|
||||
title={t("roomDialog.roomNameTooltip")}
|
||||
>
|
||||
{room.name || `Room ${room.roomId}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="RoomItem__meta">
|
||||
<span className="RoomItem__date">
|
||||
{t("roomDialog.created")} {formatDate(room.createdAt, t)}
|
||||
</span>
|
||||
{room.lastAccessed !== room.createdAt && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="RoomItem__lastAccessed">
|
||||
{t("roomDialog.lastUsed")}: {formatDate(room.lastAccessed, t)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="RoomItem__actions">
|
||||
<button
|
||||
className="RoomItem__action"
|
||||
onClick={() => onSelect(room.roomId, room.roomKey)}
|
||||
title={t("roomDialog.copyRoomLinkTooltip")}
|
||||
aria-label={t("roomDialog.copyRoomLinkTooltip")}
|
||||
>
|
||||
{LinkIcon}
|
||||
</button>
|
||||
<button
|
||||
className="RoomItem__action"
|
||||
onClick={() => setIsEditing(true)}
|
||||
title={t("roomDialog.renameRoomTooltip")}
|
||||
aria-label={t("roomDialog.renameRoomTooltip")}
|
||||
>
|
||||
{FreedrawIcon}
|
||||
</button>
|
||||
<button
|
||||
className="RoomItem__action RoomItem__action--danger"
|
||||
onClick={() => onDelete(room.roomId)}
|
||||
title={t("roomDialog.deleteRoomTooltip")}
|
||||
aria-label={t("roomDialog.deleteRoomTooltip")}
|
||||
>
|
||||
{TrashIcon}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const RoomList = ({
|
||||
collabAPI,
|
||||
onRoomSelect,
|
||||
handleClose,
|
||||
}: RoomListProps) => {
|
||||
const { t } = useI18n();
|
||||
const [rooms, setRooms] = useState<CollabRoom[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadRooms = async () => {
|
||||
try {
|
||||
const userRooms = await roomManager.getRooms();
|
||||
setRooms(userRooms);
|
||||
} catch (error) {
|
||||
console.error("Failed to load rooms:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRooms();
|
||||
}, []);
|
||||
|
||||
const handleDeleteRoom = async (roomId: string) => {
|
||||
if (window.confirm(t("roomDialog.deleteRoomConfirm"))) {
|
||||
try {
|
||||
await roomManager.deleteRoom(roomId);
|
||||
await loadRooms(); // Refresh the list
|
||||
trackEvent("share", "room deleted");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete room:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameRoom = async (roomId: string, name: string) => {
|
||||
try {
|
||||
await roomManager.updateRoomName(roomId, name);
|
||||
await loadRooms(); // Refresh the list
|
||||
} catch (error) {
|
||||
console.error("Failed to rename room:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoomSelect = async (roomId: string, roomKey: string) => {
|
||||
try {
|
||||
await roomManager.updateRoomAccess(roomId);
|
||||
trackEvent("share", "room rejoined");
|
||||
onRoomSelect(roomId, roomKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to update room access:", error);
|
||||
onRoomSelect(roomId, roomKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (window.confirm(t("roomDialog.deleteAllRoomsConfirm"))) {
|
||||
try {
|
||||
await roomManager.clearAllRooms();
|
||||
setRooms([]);
|
||||
trackEvent("share", "all rooms cleared");
|
||||
} catch (error) {
|
||||
console.error("Failed to clear all rooms:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="RoomList">
|
||||
<div className="RoomList__header">
|
||||
<h3>{t("roomDialog.roomListTitle")}</h3>
|
||||
</div>
|
||||
<div className="RoomList__loading">
|
||||
{t("roomDialog.roomListLoading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="RoomList">
|
||||
<div className="RoomList__header">
|
||||
<h3>{t("roomDialog.roomListTitle")}</h3>
|
||||
{rooms.length > 0 && (
|
||||
<button
|
||||
className="RoomList__clearAll"
|
||||
onClick={handleClearAll}
|
||||
title={t("roomDialog.deleteAllRooms")}
|
||||
>
|
||||
{t("roomDialog.deleteAllRooms")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="RoomList__description">
|
||||
{t("roomDialog.roomListDescription")}
|
||||
</p>
|
||||
|
||||
{rooms.length === 0 ? (
|
||||
<div className="RoomList__empty">
|
||||
<p>{t("roomDialog.roomListEmpty")}</p>
|
||||
<p>{t("roomDialog.roomListEmptySubtext")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="RoomList__items">
|
||||
{rooms.map((room) => (
|
||||
<RoomItem
|
||||
key={room.id}
|
||||
room={room}
|
||||
onDelete={handleDeleteRoom}
|
||||
onSelect={handleRoomSelect}
|
||||
onRename={handleRenameRoom}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -18,8 +18,12 @@ import { useI18n } from "@excalidraw/excalidraw/i18n";
|
||||
import { KEYS, getFrame } from "@excalidraw/common";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useExcalidrawSetAppState } from "@excalidraw/excalidraw/components/App";
|
||||
|
||||
import { atom, useAtom, useAtomValue } from "../app-jotai";
|
||||
import { activeRoomLinkAtom } from "../collab/Collab";
|
||||
import { RoomList } from "../components/RoomList";
|
||||
import { getCollaborationLink } from "../data";
|
||||
|
||||
import "./ShareDialog.scss";
|
||||
|
||||
@@ -180,6 +184,7 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { collabAPI } = props;
|
||||
const setAppState = useExcalidrawSetAppState();
|
||||
|
||||
const startCollabJSX = collabAPI ? (
|
||||
<>
|
||||
@@ -204,6 +209,34 @@ const ShareDialogPicker = (props: ShareDialogProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: "1px",
|
||||
backgroundColor: "var(--color-border)",
|
||||
width: "100%",
|
||||
}}
|
||||
></div>
|
||||
|
||||
<RoomList
|
||||
collabAPI={collabAPI}
|
||||
onRoomSelect={async (roomId, roomKey) => {
|
||||
const roomLink = getCollaborationLink({ roomId, roomKey });
|
||||
try {
|
||||
await copyTextToSystemClipboard(roomLink);
|
||||
trackEvent("share", "room link copied from list");
|
||||
// set a toast message when the link is copied
|
||||
setAppState({
|
||||
toast: {
|
||||
message: t("roomDialog.roomLinkCopied"),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
collabAPI.setCollabError(t("errors.copyToSystemClipboardFailed"));
|
||||
}
|
||||
}}
|
||||
handleClose={props.handleClose}
|
||||
/>
|
||||
|
||||
{props.type === "share" && (
|
||||
<div className="ShareDialog__separator">
|
||||
<span>{t("shareDialog.or")}</span>
|
||||
|
@@ -381,7 +381,26 @@
|
||||
"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.",
|
||||
"shareTitle": "Join a live collaboration session on Excalidraw"
|
||||
"shareTitle": "Join a live collaboration session on Excalidraw",
|
||||
"roomListTitle": "Your Collaboration Rooms",
|
||||
"roomListDescription": "Anyone with access to your rooms can join at any time. Deleting a room is permanent and will make it inaccessible to everyone (including you). Please be sure to save anything important before deleting.",
|
||||
"roomListEmpty": "No collaboration rooms yet.",
|
||||
"roomListEmptySubtext": "Create a room to start collaborating with others!",
|
||||
"roomListLoading": "Loading your rooms...",
|
||||
"roomNamePlaceholder": "Room name",
|
||||
"roomNameTooltip": "Click to rename",
|
||||
"copyRoomLinkTooltip": "Copy room link",
|
||||
"renameRoomTooltip": "Rename room",
|
||||
"deleteRoomTooltip": "Delete room",
|
||||
"deleteAllRooms": "Delete All",
|
||||
"deleteRoomConfirm": "Are you sure you want to delete this room? This action cannot be undone.",
|
||||
"deleteAllRoomsConfirm": "Are you sure you want to delete ALL your collaboration rooms? This action cannot be undone.",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday",
|
||||
"daysAgo": "{{days}} days ago",
|
||||
"created": "Created",
|
||||
"lastUsed": "Last used",
|
||||
"roomLinkCopied": "Room link copied to clipboard"
|
||||
},
|
||||
"errorDialog": {
|
||||
"title": "Error"
|
||||
|
Reference in New Issue
Block a user