This commit is contained in:
Dan Wahlin 2021-08-16 23:40:56 -07:00
Коммит d6f99cf21e
27 изменённых файлов: 22723 добавлений и 0 удалений

29
.gitignore поставляемый Normal file
Просмотреть файл

@ -0,0 +1,29 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
.env
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
react-app-env.d.ts
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# work around for https://github.com/facebook/create-react-app/issues/6560
src/react-app-env.d.ts

30
README.md Normal file
Просмотреть файл

@ -0,0 +1,30 @@
# Lets Brainstorm
```
This example is using an experimental API surface so please be cautious. We will break it!
```
Brainstorm is an example of using the Fluid Framework to build a collaborative line of business application. In this example each user can create their own sticky notes that is managed on a board.
This application was shown during a [Microsoft Build session](https://aka.ms/OD522).
## Getting Started
To run this follow the steps below:
1. Run `npm install` from the brainstorm folder root
2. Run `npm run start` to start the client
3. Run `npx tinylicious` to start the "Tinylicious" test service
4. Navigate to `http://localhost:3000` in a browser tab
This package is based on the [Create React App](https://reactjs.org/docs/create-a-new-react-app.html), so much of the Create React App documentation applies.
## Using the Brainstorm App
1. Navigate to `http://localhost:3000`
You'll be taken to a url similar to 'http://localhost:3000/**#1621961220840**' the path `##1621961220840` is specifies one brainstorm document.
2. Create another chrome tab with `http://localhost:3000/**#1621961220840**`
Now you can create notes, write text, change colors and more!

21469
package-lock.json сгенерированный Normal file

Разница между файлами не показана из-за своего большого размера Загрузить разницу

69
package.json Normal file
Просмотреть файл

@ -0,0 +1,69 @@
{
"name": "@fluid-example/brainstorm",
"version": "0.44.0",
"description": "A simple brainstorming app built using Create React App plus a Fluid data model",
"homepage": "https://fluidframework.com",
"repository": "microsoft/FluidExamples",
"license": "MIT",
"author": "Microsoft and contributors",
"sideEffects": false,
"main": "dist/index.js",
"module": "lib/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "react-scripts build",
"eject": "react-scripts eject",
"start": "react-scripts start",
"start:server": "tinylicious",
"start:frs": "cross-env REACT_APP_FLUID_CLIENT='\"frs\"' npm run start",
"test": "react-scripts test",
"test:report": "echo No test for this example"
},
"eslintConfig": {
"extends": [
"react-app"
],
"rules": {
"no-restricted-globals": [
"error",
"event",
"fdescribe"
]
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"dependencies": {
"@fluentui/react": "^8.1.1",
"@fluid-experimental/fluid-framework": "^0.44.0",
"@fluid-experimental/frs-client": "^0.44.0",
"@fluidframework/test-runtime-utils": "0.44.0",
"cross-env": "^7.0.3",
"react": "^16.10.2",
"react-dnd": "^14.0.2",
"react-dnd-html5-backend": "^14.0.0",
"react-dom": "^16.10.2"
},
"devDependencies": {
"tinylicious": "^0.4.21640",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
"@types/jest": "22.2.3",
"@types/node": "^12.19.0",
"@types/react": "^16.9.15",
"@types/react-dom": "^16.9.4",
"react-scripts": "4.0.2",
"typescript": "~4.1.3"
}
}

12
public/index.html Normal file
Просмотреть файл

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://static2.sharepointonline.com/files/fabric/office-ui-fabric-core/11.0.0/css/fabric.min.css" />
<title>Lets Brainstorm</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="rootContent"></div>
</body>
</html>

25
public/manifest.json Normal file
Просмотреть файл

@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

3
public/robots.txt Normal file
Просмотреть файл

@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

138
src/BrainstormModel.ts Normal file
Просмотреть файл

@ -0,0 +1,138 @@
import { FluidContainer, ISharedMap, SharedMap } from "@fluid-experimental/fluid-framework";
import { FrsMember } from "@fluid-experimental/frs-client";
import { NoteData, Position } from "./Types";
const c_NoteIdPrefix = "noteId_";
const c_PositionPrefix = "position_";
const c_AuthorPrefix = "author_";
const c_votePrefix = "vote_";
const c_TextPrefix = "text_";
const c_ColorPrefix = "color_";
export type BrainstormModel = Readonly<{
CreateNote(noteId: string, myAuthor: FrsMember): NoteData;
MoveNote(noteId: string, newPos: Position): void;
SetNote(noteId: string, newCardData: NoteData): void;
SetNoteText(noteId: string, noteText: string): void;
SetNoteColor(noteId: string, noteColor: string): void;
LikeNote(noteId: string, author: FrsMember): void;
GetNoteLikedUsers(noteId: string): FrsMember[];
DeleteNote(noteId: string): void;
NoteIds: string[];
setChangeListener(listener: () => void): void;
removeChangeListener(listener: () => void): void;
}>;
export function createBrainstormModel(fluid: FluidContainer): BrainstormModel {
const sharedMap: ISharedMap = fluid.initialObjects.map as SharedMap;
const IsCompleteNote = (noteId: string) => {
if (
!sharedMap.get(c_PositionPrefix + noteId) ||
!sharedMap.get(c_AuthorPrefix + noteId)
) {
return false;
}
return true;
};
const IsDeletedNote = (noteId: string) => {
return sharedMap.get(c_NoteIdPrefix + noteId) === 0;
};
const SetNoteText = (noteId: string, noteText: string) => {
sharedMap.set(c_TextPrefix + noteId, noteText);
};
const SetNoteColor = (noteId: string, noteColor: string) => {
sharedMap.set(c_ColorPrefix + noteId, noteColor);
};
return {
CreateNote(noteId: string, myAuthor: FrsMember): NoteData {
const newNote: NoteData = {
id: noteId,
text: sharedMap.get(c_TextPrefix + noteId),
position: sharedMap.get(c_PositionPrefix + noteId)!,
author: sharedMap.get(c_AuthorPrefix + noteId)!,
numLikesCalculated: Array.from(sharedMap
.keys())
.filter((key: string) => key.includes(c_votePrefix + noteId))
.filter((key: string) => sharedMap.get(key) !== undefined).length,
didILikeThisCalculated:
Array.from(sharedMap
.keys())
.filter((key: string) =>
key.includes(c_votePrefix + noteId + "_" + myAuthor.userId)
)
.filter((key: string) => sharedMap.get(key) !== undefined).length > 0,
color: sharedMap.get(c_ColorPrefix + noteId)!,
};
return newNote;
},
GetNoteLikedUsers(noteId: string): FrsMember[] {
return (
Array.from(sharedMap
.keys())
// Filter keys that represent if a note was liked
.filter((key: string) => key.startsWith(c_votePrefix + noteId))
.filter((key: string) => sharedMap.get(key) !== undefined)
// Return the user associated with the like
.map((value: string) => sharedMap.get(value)!)
);
},
MoveNote(noteId: string, newPos: Position) {
sharedMap.set(c_PositionPrefix + noteId, newPos);
},
SetNote(noteId: string, newCardData: NoteData) {
sharedMap.set(c_PositionPrefix + noteId, newCardData.position);
sharedMap.set(c_AuthorPrefix + noteId, newCardData.author);
SetNoteText(newCardData.id, newCardData.text!);
sharedMap.set(c_NoteIdPrefix + noteId, 1);
sharedMap.set(c_ColorPrefix + noteId, newCardData.color);
},
SetNoteText,
SetNoteColor,
LikeNote(noteId: string, author: FrsMember) {
const voteString = c_votePrefix + noteId + "_" + author.userId;
sharedMap.get(voteString) === author
? sharedMap.set(voteString, undefined)
: sharedMap.set(voteString, author);
},
DeleteNote(noteId: string) {
sharedMap.set(c_NoteIdPrefix + noteId, 0);
},
get NoteIds(): string[] {
return (
Array.from(sharedMap
.keys())
// Only look at keys which represent if a note exists or not
.filter((key: String) => key.includes(c_NoteIdPrefix))
// Modify the note ids to not expose the prefix
.map((noteIdWithPrefix) =>
noteIdWithPrefix.substring(c_NoteIdPrefix.length)
)
// Remove notes which are incomplete or deleted
.filter((noteId) => IsCompleteNote(noteId) && !IsDeletedNote(noteId))
);
},
setChangeListener(listener: () => void): void {
sharedMap.on("valueChanged", listener);
},
removeChangeListener(listener: () => void): void {
sharedMap.off("valueChanged", listener);
}
};
}

26
src/Config.ts Normal file
Просмотреть файл

@ -0,0 +1,26 @@
import { FrsConnectionConfig, InsecureTokenProvider } from "@fluid-experimental/frs-client";
import { SharedMap } from "@fluid-experimental/fluid-framework";
import { generateUser } from "@fluidframework/server-services-client";
export const useFrs = process.env.REACT_APP_FLUID_CLIENT === "frs";
export const user = generateUser();
export const containerSchema = {
name: "brainstorm",
initialObjects: {
map: SharedMap,
},
}
export const connectionConfig: FrsConnectionConfig = useFrs ? {
tenantId: 'm365cda',
tokenProvider: new InsecureTokenProvider('c979c09e55407ceb62840c7ddfcfb0c1', user),
orderer: 'https://alfred.eus-1.canary.frs.azure.com',
storage: 'https://historian.eus-1.canary.frs.azure.com',
} : {
tenantId: "local",
tokenProvider: new InsecureTokenProvider("fooBar", user),
orderer: "http://localhost:7070",
storage: "http://localhost:7070",
};

21
src/Types.ts Normal file
Просмотреть файл

@ -0,0 +1,21 @@
import { FrsMember } from "@fluid-experimental/frs-client";
export type Position = Readonly<{ x: number; y: number }>;
export type NoteData = Readonly<{
id: any;
text?: string;
author: FrsMember;
position: Position;
numLikesCalculated: number;
didILikeThisCalculated: boolean;
color: ColorId;
}>;
export type ColorId =
| "Blue"
| "Green"
| "Yellow"
| "Pink"
| "Purple"
| "Orange";

56
src/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,56 @@
/*!
* Copyright (c) Microsoft Corporation and contributors. All rights reserved.
* Licensed under the MIT License.
*/
import { initializeIcons, ThemeProvider } from "@fluentui/react";
import { FrsClient } from '@fluid-experimental/frs-client';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrainstormView } from './view/BrainstormView';
import "./view/index.css"
import "./view/App.css";
import { themeNameToTheme } from './view/Themes';
import { connectionConfig, containerSchema } from "./Config";
export async function start() {
initializeIcons();
const getContainerId = (): { containerId: string; isNew: boolean } => {
let isNew = false;
if (location.hash.length === 0) {
isNew = true;
location.hash = Date.now().toString();
}
const containerId = location.hash.substring(1);
return { containerId, isNew };
};
const { containerId, isNew } = getContainerId();
const client = new FrsClient(connectionConfig);
const frsResources = isNew
? await client.createContainer({ id: containerId }, containerSchema)
: await client.getContainer({ id: containerId }, containerSchema);
if (!frsResources.fluidContainer.connected) {
await new Promise<void>((resolve) => {
frsResources.fluidContainer.once("connected", () => {
resolve();
});
});
}
ReactDOM.render(
<React.StrictMode>
<ThemeProvider theme={themeNameToTheme("default")}>
<BrainstormView frsResources={frsResources} />
</ThemeProvider>
</React.StrictMode>,
document.getElementById('root')
);
}
start().catch((error) => console.error(error));

24
src/view/App.css Normal file
Просмотреть файл

@ -0,0 +1,24 @@
.App {
text-align: center;
}
h1 {
color: gray;
}
h2 {
font-size: 0.8em;
}
.PropName {
font-weight: bold;
color: #6264a7;
}
.Logo {
font-size: 45pt;
color: #6264a7;
}
.Error {
color: red;
}
#NoteSpace {
height: 100vh
}

Просмотреть файл

@ -0,0 +1,57 @@
import { mergeStyles, Spinner } from "@fluentui/react";
import { FrsResources } from "@fluid-experimental/frs-client";
import * as React from "react";
import { DndProvider } from 'react-dnd'
import { HTML5Backend } from 'react-dnd-html5-backend'
import { BrainstormModel, createBrainstormModel } from "../BrainstormModel";
import { Header } from "./Header";
import { NoteSpace } from "./NoteSpace";
export const BrainstormView = (props: { frsResources: FrsResources }) => {
const { frsResources: { fluidContainer, containerServices } } = props;
const [model] = React.useState<BrainstormModel>(createBrainstormModel(fluidContainer));
const audience = containerServices.audience;
const [members, setMembers] = React.useState(Array.from(audience.getMembers().values()));
const authorInfo = audience.getMyself();
const setMembersCallback = React.useCallback(() => setMembers(
Array.from(
audience.getMembers().values()
)
), [setMembers, audience]);
// Setup a listener to update our users when new clients join the session
React.useEffect(() => {
fluidContainer.on("connected", setMembersCallback);
audience.on("membersChanged", setMembersCallback);
return () => {
fluidContainer.off("connected", () => setMembersCallback);
audience.off("membersChanged", () => setMembersCallback);
};
}, [fluidContainer, audience, setMembersCallback]);
const wrapperClass = mergeStyles({
height: "100%",
display: "flex",
flexDirection: "column",
});
if (authorInfo === undefined) {
return <Spinner />;
}
return (
<div className={wrapperClass}>
<Header
model={model}
author={authorInfo}
members={members}
/>
<DndProvider backend={HTML5Backend}>
<NoteSpace
model={model}
author={authorInfo}
/>
</DndProvider>
</div>
);
};

23
src/view/Color.ts Normal file
Просмотреть файл

@ -0,0 +1,23 @@
import { ColorId } from "../Types";
export type ColorValues = { base: string; dark: string; light: string };
export const ColorOrder: ColorId[] = [
"Blue",
"Green",
"Yellow",
"Pink",
"Purple",
"Orange",
];
export const DefaultColor = ColorOrder[0];
export const ColorOptions: { [key in ColorId]: ColorValues } = {
Blue: { base: "#0078D4", dark: "#99C9EE", light: "#CCE4F6" },
Green: { base: "#005E50", dark: "#99BFB9", light: "#CCDFDC" },
Yellow: { base: "#F2C811", dark: "#FAE9A0", light: "#FCF4CF" },
Pink: { base: "#E3008C", dark: "#F499D1", light: "#F9CCE8" },
Purple: { base: "#8764B8", dark: "#CFC1E3", light: "#E7E0F1" },
Orange: { base: "#CA5010", dark: "#EAB99F", light: "#F4DCCF" },
};

35
src/view/ColorPicker.tsx Normal file
Просмотреть файл

@ -0,0 +1,35 @@
import React from "react";
import { SwatchColorPicker, IColorCellProps } from "@fluentui/react";
import { ColorOptions, ColorOrder } from "./Color";
import { ColorId } from "../Types";
export type ColorButtonProps = {
parent?: any,
selectedColor: ColorId;
setColor: (color: ColorId) => void;
};
export function ColorPicker(props: ColorButtonProps) {
const { selectedColor, setColor } = props;
const colorCells = ColorOrder.map((id) => colorOptionToCell(id));
const onChange = (_event: React.FormEvent<HTMLElement>, colorId: string | undefined) => {
props.parent.current.dismissMenu();
setColor(colorId as ColorId);
};
return (
<SwatchColorPicker
columnCount={6}
colorCells={colorCells}
defaultSelectedId={selectedColor}
onChange={onChange}
/>
);
}
function colorOptionToCell(id: ColorId): IColorCellProps {
return {
id: id,
color: ColorOptions[id].dark,
};
}

109
src/view/Header.tsx Normal file
Просмотреть файл

@ -0,0 +1,109 @@
import {
Text,
CommandBar,
ICommandBarItemProps,
Facepile,
} from "@fluentui/react";
import { FrsMember } from "@fluid-experimental/frs-client";
import React from "react";
import { BrainstormModel } from "../BrainstormModel";
import { DefaultColor } from "./Color";
import { ColorPicker } from "./ColorPicker";
import { NoteData } from "../Types";
import { NOTE_SIZE } from "./Note.style";
function uuidv4() {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
var r = (Math.random() * 16) | 0,
v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export interface HeaderProps {
model: BrainstormModel;
author: FrsMember;
members: FrsMember[];
}
export function Header(props: HeaderProps) {
const colorButtonRef = React.useRef<any>();
const [color, setColor] = React.useState(DefaultColor);
const personas = React.useMemo(() => props.members.map(member => {return { personaName: member.userName}}), [props.members]);
const onAddNote = () => {
const { scrollHeight, scrollWidth } = document.getElementById("NoteSpace")!;
const id = uuidv4();
const newCardData: NoteData = {
id,
position: {
x: Math.floor(Math.random() * (scrollWidth - NOTE_SIZE.width)),
y: Math.floor(Math.random() * (scrollHeight - NOTE_SIZE.height)),
},
author: props.author,
numLikesCalculated: 0,
didILikeThisCalculated: false,
color
};
props.model.SetNote(id, newCardData);
};
const items: ICommandBarItemProps[] = [
{
key: "title",
onRender: () => (
<Text
variant="xLarge"
styles={{
root: { alignSelf: "center", marginBottom: 6, marginRight: 16 },
}}
>
Let's Brainstorm
</Text>
),
},
{
key: "add",
text: "Add note",
onClick: onAddNote,
iconProps: {
iconName: "QuickNote",
},
},
{
componentRef: colorButtonRef,
key: "color",
text: "Default Color",
iconProps: {
iconName: "Color",
},
subMenuProps: {
key: "color-picker",
items: [{ key: "foo" }],
onRenderMenuList: () => (
<ColorPicker
parent={colorButtonRef}
selectedColor={color}
setColor={setColor}
/>
),
},
},
];
const farItems: ICommandBarItemProps[] = [
{
key: "presence",
onRender: () => <Facepile
styles={{ root: { alignSelf: "center" } }}
personas={personas}
/>,
},
];
return (
<CommandBar
styles={{ root: { paddingLeft: 0 } }}
items={items}
farItems={farItems}
/>
);
}

61
src/view/Note.style.ts Normal file
Просмотреть файл

@ -0,0 +1,61 @@
import { IRawStyle, IStyle, ITooltipHostStyles, IButtonStyles } from '@fluentui/react';
import { ColorOptions } from "./Color";
import { ColorId } from "../Types";
export const NOTE_SIZE = {
width: 300,
height: 100
}
export const tooltipHostStyle: Partial<ITooltipHostStyles> = {
root: { display: "inline-block" },
};
export const iconStyle: React.CSSProperties = {
color: "black",
fontSize: "10px",
};
export const deleteButtonStyle: IButtonStyles = {
root: { backgroundColor: "transparent" },
rootHovered: { backgroundColor: "transparent" },
rootPressed: { backgroundColor: "transparent" },
icon: { fontSize: "13px" },
iconHovered: { fontSize: "15px" }
};
export const colorButtonStyle: IButtonStyles = {
root: { backgroundColor: "transparent " },
rootHovered: { backgroundColor: "transparent" },
rootPressed: { backgroundColor: "transparent" },
rootExpanded: { backgroundColor: "transparent" },
rootExpandedHovered: { backgroundColor: "transparent" },
iconHovered: { fontSize: "18px" },
iconExpanded: { fontSize: "18px" }
};
export const likesButtonStyle: IButtonStyles = {
root: { backgroundColor: "transparent" },
rootHovered: { backgroundColor: "transparent", fontSize: "18px" },
rootPressed: { backgroundColor: "transparent" },
iconHovered: { fontSize: "18px" }
};
export function getRootStyleForColor(color: ColorId): IStyle {
return {
background: ColorOptions[color].light,
position: "absolute",
borderRadius: "2px",
boxShadow:
"rgb(0 0 0 / 13%) 0px 1.6px 3.6px 0px, rgb(0 0 0 / 11%) 0px 0.3px 0.9px 0px",
width: NOTE_SIZE.width,
minHeight: NOTE_SIZE.height
};
}
export function getHeaderStyleForColor(color: ColorId): IRawStyle {
if (color === undefined) {
return { backgroundColor: ColorOptions["Blue"].dark };
}
return { backgroundColor: ColorOptions[color].dark };
}

63
src/view/Note.tsx Normal file
Просмотреть файл

@ -0,0 +1,63 @@
import {
mergeStyles,
} from "@fluentui/react";
import { FrsMember } from "@fluid-experimental/frs-client";
import React from "react";
import { useDrag } from "react-dnd";
import { DefaultColor } from "./Color";
import {
getRootStyleForColor
} from "./Note.style";
import { NoteData, Position } from "../Types";
import { NoteHeader } from "./NoteHeader";
import { NoteBody } from "./NoteBody";
export type NoteProps = Readonly<{
id: string;
setPosition: (position: Position) => void;
onLike: () => void;
getLikedUsers: () => FrsMember[];
onDelete: () => void;
onColorChange: (color: string) => void;
setText: (text: string) => void;
}> &
Pick<
NoteData,
| "author"
| "position"
| "color"
| "didILikeThisCalculated"
| "numLikesCalculated"
| "text"
>;
export function Note(props: NoteProps) {
const {
id,
position: { x: left, y: top },
color = DefaultColor,
setText,
text
} = props;
const [, drag] = useDrag(
() => ({
type: "note",
item: { id, left, top },
}),
[id, left, top]
);
const rootClass = mergeStyles(getRootStyleForColor(color));
return (
<div className={rootClass} ref={drag} style={{ left, top }}>
<NoteHeader {...props} />
<NoteBody setText={setText} text={text} color={color} />
</div>
);
}

28
src/view/NoteBody.tsx Normal file
Просмотреть файл

@ -0,0 +1,28 @@
import React from "react";
import { TextField } from "@fluentui/react";
import { NoteData } from "../Types";
import { ColorOptions, DefaultColor } from "./Color";
export type NoteBodyProps = Readonly<{
setText(text: string): void;
}> &
Pick<NoteData, "text" | "color">;
export function NoteBody(props: NoteBodyProps) {
const { setText, text, color = DefaultColor } = props;
return (
<div style={{ flex: 1 }}>
<TextField
styles={{ fieldGroup: { background: ColorOptions[color].light } }}
borderless
multiline
resizable={false}
autoAdjustHeight
onChange={(event) => setText(event.currentTarget.value)}
value={text}
placeholder={"Enter Text Here"}
/>
</div>
);
}

155
src/view/NoteHeader.tsx Normal file
Просмотреть файл

@ -0,0 +1,155 @@
import {
CommandBar,
CommandBarButton,
DirectionalHint,
ICommandBarItemProps,
IResizeGroupProps,
ITooltipProps,
mergeStyles,
PersonaCoin,
TooltipHost,
} from "@fluentui/react";
import React from "react";
import { ColorPicker } from "./ColorPicker";
import {
getHeaderStyleForColor,
deleteButtonStyle,
colorButtonStyle,
likesButtonStyle,
tooltipHostStyle,
} from "./Note.style";
import { ReactionListCallout } from "./ReactionListCallout";
import { NoteProps } from "./Note"
const HeaderComponent = (props: NoteProps) => {
const colorButtonRef = React.useRef();
const headerProps = {
className: mergeStyles(getHeaderStyleForColor(props.color)),
};
const likeBtnTooltipProps: ITooltipProps = {
onRenderContent: () => {
const likedUserList = props.getLikedUsers();
if (likedUserList.length === 0) {
// Don't render a tooltip if no users reacted.
return null;
}
return (
<ReactionListCallout
label={"Like Reactions"}
reactionIconName={"Like"}
usersToDisplay={likedUserList}
/>
);
},
calloutProps: {
beakWidth: 10,
},
};
const items: ICommandBarItemProps[] = [
{
key: "persona",
onRender: () => {
return (
<TooltipHost
styles={{ root: { alignSelf: "center", display: "block" } }}
content={props.author.userName}
>
<PersonaCoin
styles={{
coin: {
alignSelf: "center",
margin: "0px 8px",
userSelect: "none",
},
}}
text={props.author.userName}
coinSize={24}
/>
</TooltipHost>
);
},
},
];
const farItems: ICommandBarItemProps[] = [
{
key: "likes",
onClick: props.onLike,
text: props.numLikesCalculated.toString(),
iconProps: {
iconName: props.didILikeThisCalculated ? "LikeSolid" : "Like",
},
buttonStyles: likesButtonStyle,
commandBarButtonAs: (props) => {
return (
<TooltipHost
tooltipProps={likeBtnTooltipProps}
directionalHint={DirectionalHint.topAutoEdge}
styles={tooltipHostStyle}
>
<CommandBarButton {...(props as any)} />
</TooltipHost>
);
},
},
{
// @ts-ignore
componentRef: colorButtonRef,
key: "color",
iconProps: {
iconName: "Color",
},
subMenuProps: {
key: "color-picker",
items: [{ key: "foo" }],
onRenderMenuList: () => (
<ColorPicker
parent={colorButtonRef}
selectedColor={props.color!}
setColor={(color) => props.onColorChange(color)}
/>
),
},
buttonStyles: colorButtonStyle,
},
{
key: "delete",
iconProps: { iconName: "Clear" },
title: "Delete Note",
onClick: props.onDelete,
buttonStyles: deleteButtonStyle,
},
];
const nonResizingGroup = (props: IResizeGroupProps) => (
<div>
<div style={{ position: "relative" }}>
{props.onRenderData(props.data)}
</div>
</div>
);
return (
<div {...headerProps}>
<CommandBar
resizeGroupAs={nonResizingGroup}
styles={{
root: { padding: 0, height: 36, backgroundColor: "transparent" },
}}
items={items}
farItems={farItems}
/>
</div>
)
}
export const NoteHeader = React.memo(HeaderComponent, (prevProps, nextProps) => {
return prevProps.color === nextProps.color
&& prevProps.numLikesCalculated === nextProps.numLikesCalculated
&& prevProps.didILikeThisCalculated === nextProps.didILikeThisCalculated
})

107
src/view/NoteSpace.tsx Normal file
Просмотреть файл

@ -0,0 +1,107 @@
import { IStyle, mergeStyles, ThemeProvider } from "@fluentui/react";
import { FrsMember } from "@fluid-experimental/frs-client";
import React from "react";
import { useDrop } from 'react-dnd';
import { NoteData, Position } from "../Types";
import { Note } from "./Note";
import { BrainstormModel } from "../BrainstormModel";
import { lightTheme } from "./Themes";
export type NoteSpaceProps = Readonly<{
model: BrainstormModel;
author: FrsMember;
}>;
export function NoteSpace(props: NoteSpaceProps) {
const { model } = props;
const [notes, setNotes] = React.useState<readonly NoteData[]>([]);
// This runs when via model changes whether initiated by user or from external
React.useEffect(() => {
const syncLocalAndFluidState = () => {
const noteDataArr = [];
const ids: string[] = model.NoteIds;
// Recreate the list of cards to re-render them via setNotes
for (let noteId of ids) {
const newCardData: NoteData = model.CreateNote(noteId, props.author);
noteDataArr.push(newCardData);
}
setNotes(noteDataArr);
};
syncLocalAndFluidState();
model.setChangeListener(syncLocalAndFluidState);
return () => model.removeChangeListener(syncLocalAndFluidState);
}, [model, props.author]);
const rootStyle: IStyle = {
flexGrow: 1,
position: "relative",
margin: "10px",
borderRadius: "2px",
};
const spaceClass = mergeStyles(rootStyle);
const [, drop] = useDrop(() => ({
accept: 'note',
drop(item: any, monitor) {
const delta = monitor.getDifferenceFromInitialOffset()!;
const left = Math.round(item.left + delta.x);
const top = Math.round(item.top + delta.y);
model.MoveNote(item.id, {
x: left > 0 ? left : 0,
y: top > 0 ? top : 0
})
return undefined;
},
}), [model]);
return (
<div id="NoteSpace" ref={drop} className={spaceClass}>
<ThemeProvider theme={lightTheme}>
{notes.map((note, i) => {
const setPosition = (position: Position) => {
model.MoveNote(note.id, position);
};
const setText = (text: string) => {
model.SetNoteText(note.id, text);
};
const onLike = () => {
model.LikeNote(note.id, props.author);
};
const getLikedUsers = () => {
return model.GetNoteLikedUsers(note.id);
};
const onDelete = () => {
model.DeleteNote(note.id);
};
const onColorChange = (color: string) => {
model.SetNoteColor(note.id, color);
};
return (
<Note
{...note}
id={note.id}
key={note.id}
text={note.text}
setPosition={setPosition}
onLike={onLike}
getLikedUsers={getLikedUsers}
onDelete={onDelete}
onColorChange={onColorChange}
setText={setText}
/>
);
})}
</ThemeProvider>
</div>
);
}

24
src/view/PersonaList.tsx Normal file
Просмотреть файл

@ -0,0 +1,24 @@
import { IPersonaStyles, List, Persona, PersonaSize } from "@fluentui/react";
import { FrsMember } from "@fluid-experimental/frs-client";
import React from "react";
export function PersonaList(props: { users: FrsMember[] }) {
const personaStyles: Partial<IPersonaStyles> = {
root: {
marginTop: 10,
},
};
const renderPersonaListItem = (item?: FrsMember) => {
return (
item && (
<Persona
text={item.userName}
size={PersonaSize.size24}
styles={personaStyles}
></Persona>
)
);
};
return <List items={props.users} onRenderCell={renderPersonaListItem}></List>;
}

Просмотреть файл

@ -0,0 +1,29 @@
import { Icon, Label, Stack } from "@fluentui/react";
import { FrsMember } from "@fluid-experimental/frs-client";
import React from "react";
import { PersonaList } from "./PersonaList";
export type ReactionListCalloutProps = {
label: string;
usersToDisplay: FrsMember[];
reactionIconName?: string;
};
export function ReactionListCallout(props: ReactionListCalloutProps) {
return (
<div>
<Stack horizontal tokens={{ childrenGap: 10 }}>
{props.reactionIconName && (
<Icon
iconName={props.reactionIconName}
style={{ fontSize: 15, alignSelf: "center" }}
></Icon>
)}
<Label>Like Reactions</Label>
</Stack>
<PersonaList
users={props.usersToDisplay}
/>
</div>
);
}

80
src/view/Themes.ts Normal file
Просмотреть файл

@ -0,0 +1,80 @@
import { createTheme } from "@fluentui/react";
export type ThemeName = "default" | "dark" | "contrast";
export function normalizeThemeName(theme?: string): ThemeName {
switch (theme) {
case "dark":
return "dark";
case "contrast":
return "contrast";
default:
return "default";
}
}
export function themeNameToTheme(themeName: ThemeName) {
switch (themeName) {
case "default":
return lightTheme;
case "dark":
return darkTheme;
case "contrast":
return darkTheme;
}
}
export const lightTheme = createTheme({
palette: {
themePrimary: "#6264a7",
themeLighterAlt: "#f7f7fb",
themeLighter: "#e1e1f1",
themeLight: "#c8c9e4",
themeTertiary: "#989ac9",
themeSecondary: "#7173b0",
themeDarkAlt: "#585a95",
themeDark: "#4a4c7e",
themeDarker: "#37385d",
neutralLighterAlt: "#faf9f8",
neutralLighter: "#f3f2f1",
neutralLight: "#edebe9",
neutralQuaternaryAlt: "#e1dfdd",
neutralQuaternary: "#d0d0d0",
neutralTertiaryAlt: "#c8c6c4",
neutralTertiary: "#b9b8b7",
neutralSecondary: "#a2a1a0",
neutralPrimaryAlt: "#8b8a89",
neutralPrimary: "#30302f",
neutralDark: "#5e5d5c",
black: "#474645",
white: "#ffffff",
},
});
export const darkTheme = createTheme({
isInverted: true,
palette: {
themePrimary: "#6264a7",
themeLighterAlt: "#040407",
themeLighter: "#10101b",
themeLight: "#1d1e32",
themeTertiary: "#3b3c63",
themeSecondary: "#565892",
themeDarkAlt: "#6e70af",
themeDark: "#8183bb",
themeDarker: "#9ea0cd",
neutralLighterAlt: "#000000",
neutralLighter: "#000000",
neutralLight: "#000000",
neutralQuaternaryAlt: "#000000",
neutralQuaternary: "#000000",
neutralTertiaryAlt: "#000000",
neutralTertiary: "#c8c8c8",
neutralSecondary: "#d0d0d0",
neutralPrimaryAlt: "#dadada",
neutralPrimary: "#ffffff",
neutralDark: "#f4f4f4",
black: "#f8f8f8",
white: "#000000",
},
});

14
src/view/index.css Normal file
Просмотреть файл

@ -0,0 +1,14 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.rootContent {
position: absolute;
top: 0;
left: 0;
width: 100%;
}

10
src/view/index.tsx Normal file
Просмотреть файл

@ -0,0 +1,10 @@
export * from "./BrainstormView";
export * from "./Color";
export * from "./ColorPicker";
export * from "./Header";
export * from "./Note";
export * from "./NoteBody";
export * from "./NoteHeader";
export * from "./NoteSpace";
export * from "./PersonaList";
export * from "./Themes";

26
tsconfig.json Normal file
Просмотреть файл

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}