Room dialog (#905)

* support ToolIcon className and fix label padding

* factor some ExportDialog classes out to Modal

* initial RoomDialog prototype

* change label for another-session button

* remove unused css

* add color comments

* Move the collaboration button to the main menu, add support for mobile

* remove button for creating another session

* add locks

* Fix alignment issue

* Reorder button

* reuse current scene for collab session

* keep collaboration state on restore

Co-authored-by: Jed Fox <git@twopointzero.us>
This commit is contained in:
David Luzar 2020-03-11 19:42:18 +01:00 committed by GitHub
parent aa9a6b0909
commit b82b0754ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 341 additions and 40 deletions

View File

@ -1,6 +1,5 @@
import { AppState, FlooredNumber } from "./types";
import { getDateTime } from "./utils";
import { getCollaborationLinkData } from "./data";
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
export const DEFAULT_FONT = "20px Virgil";
@ -28,7 +27,7 @@ export function getDefaultAppState(): AppState {
cursorY: 0,
scrolledOutside: false,
name: DEFAULT_PROJECT_NAME,
isCollaborating: !!getCollaborationLinkData(window.location.href),
isCollaborating: false,
isResizing: false,
selectionElement: null,
zoom: 1,

View File

@ -190,7 +190,12 @@ export class App extends React.Component<any, AppState> {
if (commitToHistory) {
history.resumeRecording();
}
this.setState({ ...res.appState });
this.setState(state => ({
...res.appState,
isCollaborating: state.isCollaborating,
remotePointers: state.remotePointers,
collaboratorCount: state.collaboratorCount,
}));
}
};
@ -226,12 +231,27 @@ export class App extends React.Component<any, AppState> {
event.preventDefault();
};
private destroySocketClient = () => {
this.setState({
isCollaborating: false,
});
if (this.socket) {
this.socket.close();
this.socket = null;
this.roomID = null;
this.roomKey = null;
}
};
private initializeSocketClient = () => {
if (this.socket) {
return;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (roomMatch) {
this.setState({
isCollaborating: true,
});
this.socket = socketIOClient(SOCKET_SERVER);
this.roomID = roomMatch[1];
this.roomKey = roomMatch[2];
@ -611,6 +631,20 @@ export class App extends React.Component<any, AppState> {
gesture.pointers.delete(event.pointerId);
};
createRoom = async () => {
window.history.pushState(
{},
"Excalidraw",
await generateCollaborationLink(),
);
this.initializeSocketClient();
};
destroyRoom = () => {
window.history.pushState({}, "Excalidraw", window.location.origin);
this.destroySocketClient();
};
public render() {
const canvasDOMWidth = window.innerWidth;
const canvasDOMHeight = window.innerHeight;
@ -630,6 +664,8 @@ export class App extends React.Component<any, AppState> {
elements={elements}
setElements={this.setElements}
language={getLanguage()}
onRoomCreate={this.createRoom}
onRoomDestroy={this.destroyRoom}
/>
<main>
<canvas

View File

@ -1,28 +1,3 @@
.ExportDialog__dialog {
/* transition: opacity 0.15s ease-in, transform 0.15s ease-in; */
opacity: 0;
transform: translateY(10px);
animation: ExportDialog__fade-in 0.1s ease-out 0.05s forwards;
position: relative;
}
@keyframes ExportDialog__fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ExportDialog__close {
position: absolute;
right: calc(var(--space-factor) * 5);
top: calc(var(--space-factor) * 5);
}
.ExportDialog__preview {
--preview-padding: calc(var(--space-factor) * 4);

View File

@ -112,10 +112,10 @@ function ExportModal({
}
return (
<div className="ExportDialog__dialog" onKeyDown={handleKeyDown}>
<div onKeyDown={handleKeyDown}>
<Island padding={4}>
<button
className="ExportDialog__close"
className="Modal__close"
onClick={onCloseRequest}
aria-label={t("buttons.close")}
ref={closeButton}

View File

@ -21,6 +21,7 @@ import { ExportType } from "../scene/types";
import { MobileMenu } from "./MobileMenu";
import { ZoomActions, SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import { RoomDialog } from "./RoomDialog";
interface LayerUIProps {
actionManager: ActionManager;
@ -30,6 +31,8 @@ interface LayerUIProps {
elements: readonly ExcalidrawElement[];
language: string;
setElements: (elements: readonly ExcalidrawElement[]) => void;
onRoomCreate: () => void;
onRoomDestroy: () => void;
}
export const LayerUI = React.memo(
@ -41,6 +44,8 @@ export const LayerUI = React.memo(
elements,
language,
setElements,
onRoomCreate,
onRoomDestroy,
}: LayerUIProps) => {
const isMobile = useIsMobile();
@ -92,21 +97,28 @@ export const LayerUI = React.memo(
actionManager={actionManager}
exportButton={renderExportDialog()}
setAppState={setAppState}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
/>
) : (
<>
<FixedSideContainer side="top">
<HintViewer appState={appState} elements={elements} />
<div className="App-menu App-menu_top">
<Stack.Col gap={4} align="end">
<Stack.Col gap={4}>
<Section className="App-right-menu" heading="canvasActions">
<Island padding={4}>
<Stack.Col gap={4}>
<Stack.Row justifyContent={"space-between"}>
<Stack.Row gap={2.25} justifyContent={"space-between"}>
{actionManager.renderAction("loadScene")}
{actionManager.renderAction("saveScene")}
{renderExportDialog()}
{actionManager.renderAction("clearCanvas")}
<RoomDialog
isCollaborating={appState.isCollaborating}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
/>
</Stack.Row>
{actionManager.renderAction("changeViewBackgroundColor")}
</Stack.Col>

View File

@ -12,6 +12,7 @@ import { HintViewer } from "./HintViewer";
import { calculateScrollCenter, getTargetElement } from "../scene";
import { SelectedShapeActions, ShapesSwitcher } from "./Actions";
import { Section } from "./Section";
import { RoomDialog } from "./RoomDialog";
type MobileMenuProps = {
appState: AppState;
@ -20,6 +21,8 @@ type MobileMenuProps = {
setAppState: any;
elements: readonly ExcalidrawElement[];
setElements: any;
onRoomCreate: () => void;
onRoomDestroy: () => void;
};
export function MobileMenu({
@ -29,6 +32,8 @@ export function MobileMenu({
actionManager,
exportButton,
setAppState,
onRoomCreate,
onRoomDestroy,
}: MobileMenuProps) {
return (
<>
@ -40,6 +45,11 @@ export function MobileMenu({
{actionManager.renderAction("saveScene")}
{exportButton}
{actionManager.renderAction("clearCanvas")}
<RoomDialog
isCollaborating={appState.isCollaborating}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
/>
{actionManager.renderAction("changeViewBackgroundColor")}
<fieldset>
<legend>{t("labels.language")}</legend>

View File

@ -27,4 +27,26 @@
z-index: 2;
width: 100%;
max-width: 100%;
opacity: 0;
transform: translateY(10px);
animation: Modal__content_fade-in 0.1s ease-out 0.05s forwards;
position: relative;
}
@keyframes Modal__content_fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.Modal__close {
position: absolute;
right: calc(var(--space-factor) * 5);
top: calc(var(--space-factor) * 5);
}

View File

@ -0,0 +1,43 @@
.RoomDialog-modalButton.is-collaborating {
background-color: #ebfbee; // OC GREEN-0
color: #2b8a3e; // OC GREEN-9
}
.RoomDialog-linkContainer {
display: flex;
margin: 1.5em 0;
}
.RoomDialog-link {
min-width: 0;
flex: 1 1 auto;
margin-left: 1.5em;
display: inline-block;
cursor: pointer;
border: none;
height: 2.5rem;
line-height: 2.5rem;
padding: 0 0.5rem;
white-space: nowrap;
border-radius: var(--space-factor);
background-color: #eee;
}
.RoomDialog-link:hover {
background-color: #eee;
}
.RoomDialog-link:focus {
outline: none;
box-shadow: 0 0 0 2px steelblue;
}
.RoomDialog-sessionStartButtonContainer {
display: flex;
justify-content: center;
}
.RoomDialog-stopSession {
background-color: #ffe3e3; // OC RED-1
color: #c92a2a; // OC RED-9
}

View File

@ -0,0 +1,165 @@
import React, { useState, useEffect, useRef } from "react";
import { ToolButton } from "./ToolButton";
import { Island } from "./Island";
import { t } from "../i18n";
import useIsMobile from "../is-mobile";
import { users, clipboard, start, stop } from "./icons";
import { Modal } from "./Modal";
import "./RoomDialog.scss";
import { copyTextToSystemClipboard } from "../clipboard";
function RoomModal({
onCloseRequest,
activeRoomLink,
onRoomCreate,
onRoomDestroy,
}: {
onCloseRequest: () => void;
activeRoomLink: string;
onRoomCreate: () => void;
onRoomDestroy: () => void;
}) {
const roomLinkInput = useRef<HTMLInputElement>(null);
function copyRoomLink() {
copyTextToSystemClipboard(activeRoomLink);
if (roomLinkInput.current) {
roomLinkInput.current.select();
}
}
function selectInput(event: React.MouseEvent<HTMLInputElement>) {
if (event.target !== document.activeElement) {
event.preventDefault();
(event.target as HTMLInputElement).select();
}
}
return (
<div className="RoomDialog-modal">
<Island padding={4}>
<button
className="Modal__close"
onClick={onCloseRequest}
aria-label={t("buttons.close")}
>
</button>
<h2 id="export-title">{t("labels.createRoom")}</h2>
{!activeRoomLink && (
<>
<p>{t("roomDialog.desc_intro")}</p>
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
<p>{t("roomDialog.desc_start")}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-startSession"
type="button"
icon={start}
title={t("roomDialog.button_startSession")}
aria-label={t("roomDialog.button_startSession")}
showAriaLabel={true}
onClick={onRoomCreate}
/>
</div>
</>
)}
{activeRoomLink && (
<>
<p>{t("roomDialog.desc_inProgressIntro")}</p>
<p>{t("roomDialog.desc_shareLink")}</p>
<div className="RoomDialog-linkContainer">
<ToolButton
type="button"
icon={clipboard}
title={t("labels.copy")}
aria-label={t("labels.copy")}
onClick={copyRoomLink}
/>
<input
value={activeRoomLink}
readOnly={true}
className="RoomDialog-link"
ref={roomLinkInput}
onPointerDown={selectInput}
/>
</div>
<p>{`🔒 ${t("roomDialog.desc_privacy")}`}</p>
<p>
<span role="img" aria-hidden="true">
</span>{" "}
{t("roomDialog.desc_persistenceWarning")}
</p>
<p>{t("roomDialog.desc_exitSession")}</p>
<div className="RoomDialog-sessionStartButtonContainer">
<ToolButton
className="RoomDialog-stopSession"
type="button"
icon={stop}
title={t("roomDialog.button_stopSession")}
aria-label={t("roomDialog.button_stopSession")}
showAriaLabel={true}
onClick={onRoomDestroy}
/>
</div>
</>
)}
</Island>
</div>
);
}
export function RoomDialog({
isCollaborating,
onRoomCreate,
onRoomDestroy,
}: {
isCollaborating: boolean;
onRoomCreate: () => void;
onRoomDestroy: () => void;
}) {
const [modalIsShown, setModalIsShown] = useState(false);
const [activeRoomLink, setActiveRoomLink] = useState("");
const triggerButton = useRef<HTMLButtonElement>(null);
const handleClose = React.useCallback(() => {
setModalIsShown(false);
triggerButton.current?.focus();
}, []);
useEffect(() => {
setActiveRoomLink(isCollaborating ? window.location.href : "");
}, [isCollaborating]);
return (
<>
<ToolButton
className={`RoomDialog-modalButton ${
isCollaborating ? "is-collaborating" : ""
}`}
onClick={() => setModalIsShown(true)}
icon={users}
type="button"
title={t("buttons.roomDialog")}
aria-label={t("buttons.roomDialog")}
showAriaLabel={useIsMobile()}
ref={triggerButton}
/>
{modalIsShown && (
<Modal
maxWidth={800}
labelledBy="room-title"
onCloseRequest={handleClose}
>
<RoomModal
onCloseRequest={handleClose}
activeRoomLink={activeRoomLink}
onRoomCreate={onRoomCreate}
onRoomDestroy={onRoomDestroy}
/>
</Modal>
)}
</>
);
}

View File

@ -17,6 +17,7 @@ type ToolButtonBaseProps = {
showAriaLabel?: boolean;
visible?: boolean;
selected?: boolean;
className?: string;
};
type ToolButtonProps =
@ -43,7 +44,7 @@ export const ToolButton = React.forwardRef(function(
<button
className={`ToolIcon_type_button ToolIcon ${sizeCn}${
props.selected ? " ToolIcon--selected" : ""
}`}
} ${props.className || ""}`}
title={props.title}
aria-label={props["aria-label"]}
type="button"

View File

@ -29,10 +29,15 @@
position: relative;
height: 1em;
}
& + .ToolIcon__label {
margin-left: 0;
}
}
.ToolIcon__label {
font-family: var(--ui-font);
margin: 0 0.8em;
}
.ToolIcon_size_s .ToolIcon__icon {

View File

@ -181,3 +181,26 @@ export const sendToBack = createIcon(
</>,
24,
);
export const users = createIcon(
<path
fill="currentColor"
d="M192 256c61.9 0 112-50.1 112-112S253.9 32 192 32 80 82.1 80 144s50.1 112 112 112zm76.8 32h-8.3c-20.8 10-43.9 16-68.5 16s-47.6-6-68.5-16h-8.3C51.6 288 0 339.6 0 403.2V432c0 26.5 21.5 48 48 48h288c26.5 0 48-21.5 48-48v-28.8c0-63.6-51.6-115.2-115.2-115.2zM480 256c53 0 96-43 96-96s-43-96-96-96-96 43-96 96 43 96 96 96zm48 32h-3.8c-13.9 4.8-28.6 8-44.2 8s-30.3-3.2-44.2-8H432c-20.4 0-39.2 5.9-55.7 15.4 24.4 26.3 39.7 61.2 39.7 99.8v38.4c0 2.2-.5 4.3-.6 6.4H592c26.5 0 48-21.5 48-48 0-61.9-50.1-112-112-112z"
></path>,
640,
512,
);
export const start = createIcon(
<path
fill="currentColor"
d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm115.7 272l-176 101c-15.8 8.8-35.7-2.5-35.7-21V152c0-18.4 19.8-29.8 35.7-21l176 107c16.4 9.2 16.4 32.9 0 42z"
></path>,
);
export const stop = createIcon(
<path
fill="currentColor"
d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm96 328c0 8.8-7.2 16-16 16H176c-8.8 0-16-7.2-16-16V176c0-8.8 7.2-16 16-16h160c8.8 0 16 7.2 16 16v160z"
></path>,
);

View File

@ -92,7 +92,7 @@ export function getCollaborationLinkData(link: string) {
export async function generateCollaborationLink() {
const id = await generateRandomID();
const key = await generateEncryptionKey();
return `${window.location.href}#room=${id},${key}`;
return `${window.location.origin}#room=${id},${key}`;
}
async function getImportedKey(key: string, usage: string): Promise<CryptoKey> {

View File

@ -41,7 +41,8 @@
"canvasBackground": "Canvas background",
"drawingCanvas": "Drawing Canvas",
"layers": "Layers",
"language": "Language"
"language": "Language",
"createRoom": "Share a live-collaboration session"
},
"buttons": {
"clearReset": "Reset the canvas",
@ -62,7 +63,9 @@
"done": "Done",
"edit": "Edit",
"undo": "Undo",
"redo": "Redo"
"redo": "Redo",
"roomDialog": "Share a live-collaboration session",
"createNewRoom": "Create new room"
},
"alerts": {
"clearReset": "This will clear the whole canvas. Are you sure?",
@ -105,5 +108,16 @@
"errorStack": "Error stack trace:",
"errorStack_loading": "Loading data. please wait...",
"sceneContent": "Scene content:"
},
"roomDialog": {
"desc_intro": "You can invite people to your current scene to collaborate with you.",
"desc_privacy": "Don't worry, the session uses end-to-end encryption, so whatever you draw will stay private. Not even our server will be able to see what you come up with.",
"desc_start": "To begin, click the button below. (Will use the current scene. If you don't want this, you can manually clear it, first)",
"button_startSession": "Start session",
"button_stopSession": "Stop session",
"desc_inProgressIntro": "Live-collaboration session is now in progress.",
"desc_persistenceWarning": "Note that the scene data is shared across collaborators in a P2P fashion, and not persisted to our server. Thus, if all of you disconnect, you will loose the data unless you export it to a file or a shareable link.",
"desc_shareLink": "Share this link with anyone you want to collaborate with:",
"desc_exitSession": "Stopping the session will disconnect your 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."
}
}

View File

@ -239,10 +239,6 @@ button,
height: 100%;
}
.App-right-menu {
width: 13.75rem;
}
.ErrorSplash {
min-height: 100vh;
padding: 20px 0;