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

@ -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>,
);