├── .env ├── jest.setup.js ├── src ├── drawing │ ├── attachment │ │ ├── index.ts │ │ └── utils.ts │ ├── livestroke │ │ └── index.types.ts │ ├── helpers │ │ ├── index.ts │ │ ├── index.test.ts │ │ └── mocks.ts │ ├── hitbox │ │ └── index.test.ts │ ├── stroke │ │ └── index.types.ts │ └── page │ │ └── index.ts ├── state │ ├── action │ │ ├── index.ts │ │ └── state │ │ │ └── index.types.ts │ ├── board │ │ ├── index.ts │ │ ├── state │ │ │ └── default.ts │ │ └── serializers │ │ │ ├── index.test.ts │ │ │ └── __test__ │ │ │ └── stateV1.json │ ├── drawing │ │ ├── index.ts │ │ ├── state │ │ │ ├── index.types.ts │ │ │ └── default.ts │ │ └── serializers │ │ │ ├── __test__ │ │ │ └── stateV1.json │ │ │ └── index.test.ts │ ├── loading │ │ ├── index.ts │ │ └── state │ │ │ ├── index.types.ts │ │ │ └── index.ts │ ├── menu │ │ ├── index.ts │ │ └── state │ │ │ └── index.types.ts │ ├── online │ │ ├── index.ts │ │ ├── serializers │ │ │ ├── __test__ │ │ │ │ └── stateV1.json │ │ │ └── index.test.ts │ │ └── state │ │ │ ├── default.ts │ │ │ └── index.types.ts │ ├── settings │ │ ├── index.ts │ │ ├── serializers │ │ │ ├── __test__ │ │ │ │ └── stateV1.json │ │ │ └── index.test.ts │ │ └── state │ │ │ ├── index.types.ts │ │ │ ├── default.ts │ │ │ └── index.ts │ ├── view │ │ ├── index.ts │ │ ├── serializers │ │ │ ├── __test__ │ │ │ │ └── stateV1.json │ │ │ └── index.test.ts │ │ ├── util │ │ │ ├── index.ts │ │ │ ├── zoomTo.ts │ │ │ ├── helpers.ts │ │ │ ├── bounds.ts │ │ │ └── multiTouch.ts │ │ └── state │ │ │ ├── default.ts │ │ │ └── index.types.ts │ ├── notification │ │ ├── index.ts │ │ └── state │ │ │ ├── index.types.ts │ │ │ └── index.ts │ ├── index.ts │ ├── types.ts │ └── subscription │ │ ├── usePageLayer.ts │ │ ├── index.types.ts │ │ └── useGState.ts ├── util │ ├── testing │ │ ├── svgrMock.ts │ │ └── index.tsx │ ├── lib │ │ ├── index.test.ts │ │ └── index.ts │ └── color │ │ ├── index.test.ts │ │ └── index.tsx ├── App │ ├── routes.ts │ ├── electron.tsx │ ├── router.tsx │ └── index.tsx ├── View │ ├── MainMenu │ │ ├── cssTransition.ts │ │ ├── menu │ │ │ ├── General │ │ │ │ ├── File │ │ │ │ │ ├── index.styled.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── index.styled.ts │ │ │ │ ├── Settings │ │ │ │ │ └── index.tsx │ │ │ │ └── Edit │ │ │ │ │ └── index.tsx │ │ │ ├── Session │ │ │ │ ├── index.styled.ts │ │ │ │ ├── SessionSettings │ │ │ │ │ └── index.tsx │ │ │ │ └── UserOptions │ │ │ │ │ └── index.tsx │ │ │ ├── View │ │ │ │ ├── GoTo │ │ │ │ │ └── index.tsx │ │ │ │ └── index.tsx │ │ │ └── Page │ │ │ │ ├── PageSize │ │ │ │ └── index.tsx │ │ │ │ └── PageStyle │ │ │ │ └── index.tsx │ │ ├── MainMenuBar │ │ │ ├── MenuButton │ │ │ │ ├── index.styled.ts │ │ │ │ └── index.tsx │ │ │ ├── ViewButton │ │ │ │ └── index.tsx │ │ │ ├── SessionButton │ │ │ │ ├── index.tsx │ │ │ │ └── index.styled.ts │ │ │ ├── PageButton │ │ │ │ └── index.tsx │ │ │ └── index.styled.ts │ │ ├── MenuItem │ │ │ ├── index.tsx │ │ │ └── index.styled.ts │ │ ├── index.tsx │ │ └── index.styled.ts │ ├── Board │ │ ├── RenderNG │ │ │ ├── Page │ │ │ │ ├── Transformer │ │ │ │ │ ├── shapeTransform │ │ │ │ │ │ └── index.types.ts │ │ │ │ │ └── bounds.ts │ │ │ │ ├── index.styled.ts │ │ │ │ ├── index.types.ts │ │ │ │ ├── Background │ │ │ │ │ ├── index.styled.ts │ │ │ │ │ └── index.tsx │ │ │ │ ├── ActiveTextField │ │ │ │ │ ├── useKeylistener.ts │ │ │ │ │ ├── helpers.ts │ │ │ │ │ └── index.styled.ts │ │ │ │ ├── index.tsx │ │ │ │ ├── Content │ │ │ │ │ ├── useRender.ts │ │ │ │ │ └── index.tsx │ │ │ │ └── Live │ │ │ │ │ └── index.tsx │ │ │ ├── index.types.ts │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── Observer │ │ │ ├── index.styled.ts │ │ │ └── index.tsx │ ├── Dialog │ │ ├── OnlineCreate │ │ │ └── index.styled.ts │ │ ├── OnlineChangeAlias │ │ │ └── index.styled.ts │ │ ├── OnlineLeave │ │ │ └── index.tsx │ │ ├── InitialSelection │ │ │ ├── index.styled.ts │ │ │ ├── PaperSize │ │ │ │ └── index.tsx │ │ │ └── PaperBackground │ │ │ │ └── index.tsx │ │ ├── Subscribe │ │ │ └── index.styled.ts │ │ └── index.tsx │ ├── ToolRing │ │ ├── StylePicker │ │ │ ├── ShapeTools │ │ │ │ └── index.styled.ts │ │ │ ├── index.tsx │ │ │ ├── ColorPicker │ │ │ │ ├── index.tsx │ │ │ │ └── index.styled.ts │ │ │ ├── index.styled.ts │ │ │ └── WidthPicker │ │ │ │ ├── index.tsx │ │ │ │ └── index.styled.ts │ │ ├── index.styled.ts │ │ └── ActiveTool │ │ │ └── index.tsx │ ├── index.styled.ts │ ├── Shortcuts │ │ ├── index.styled.ts │ │ └── Shortcut │ │ │ ├── index.tsx │ │ │ └── index.styled.ts │ ├── FavoriteTools │ │ ├── FavoriteToolButton │ │ │ └── index.styled.ts │ │ ├── index.styled.ts │ │ └── index.tsx │ ├── Oauth2 │ │ └── index.tsx │ ├── index.tsx │ ├── Loading │ │ ├── index.tsx │ │ └── index.styled.ts │ └── Notification │ │ ├── index.styled.ts │ │ └── index.tsx ├── components │ ├── Svg │ │ ├── svgs.d.ts │ │ └── svgs │ │ │ ├── circle.svg │ │ │ ├── pageclear.svg │ │ │ ├── rectangle.svg │ │ │ ├── expandable.svg │ │ │ ├── line.svg │ │ │ ├── minus.svg │ │ │ ├── pagedelete.svg │ │ │ ├── plus.svg │ │ │ ├── menu.svg │ │ │ ├── tick.svg │ │ │ ├── pageabove.svg │ │ │ ├── pagebelow.svg │ │ │ ├── pagedeleteall.svg │ │ │ ├── pen.svg │ │ │ ├── redo.svg │ │ │ ├── undo.svg │ │ │ ├── highlighter.svg │ │ │ ├── zoomout.svg │ │ │ ├── eraser.svg │ │ │ ├── zoomin.svg │ │ │ ├── expand.svg │ │ │ ├── shrink.svg │ │ │ ├── online.svg │ │ │ ├── download.svg │ │ │ ├── upload.svg │ │ │ ├── select.svg │ │ │ ├── textfield.svg │ │ │ ├── pan.svg │ │ │ └── background │ │ │ └── ruledlandscape.svg │ ├── ToolTip │ │ ├── index.types.ts │ │ └── index.tsx │ ├── HorizontalRule │ │ └── index.styled.ts │ ├── VerticalRule │ │ └── index.styled.ts │ ├── Popup │ │ ├── index.styled.ts │ │ └── index.tsx │ ├── FormikColorInput │ │ ├── index.styled.ts │ │ └── index.tsx │ ├── Button │ │ ├── index.tsx │ │ └── index.styled.ts │ ├── ToolButton │ │ ├── index.tsx │ │ └── index.styled.ts │ ├── Dialog │ │ ├── index.tsx │ │ └── index.styled.ts │ ├── IconButton │ │ ├── index.tsx │ │ └── index.styled.ts │ ├── FormikInput │ │ ├── index.tsx │ │ └── index.styled.ts │ ├── Drawer │ │ └── index.tsx │ └── index.ts ├── api │ ├── error.ts │ ├── auth.ts │ └── types.ts ├── index.tsx ├── hooks │ ├── index.ts │ ├── useWindowResize.ts │ ├── useInitialDialog.ts │ └── useLayerConfig.ts ├── theme │ ├── index.tsx │ ├── options │ │ ├── dark.ts │ │ ├── soil.ts │ │ ├── sun.ts │ │ ├── teal.ts │ │ ├── apple.ts │ │ ├── candy.ts │ │ ├── light.ts │ │ ├── ocean.ts │ │ ├── orange.ts │ │ └── purple.ts │ ├── globalStyles.ts │ ├── themes.ts │ ├── baseTheme.ts │ └── styled.d.ts ├── storage │ ├── util │ │ └── index.ts │ ├── local │ │ └── index.ts │ └── pdf │ │ └── index.tsx └── language │ └── index.tsx ├── .prettierrc.yaml ├── public ├── favicon.ico ├── robots.txt ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── site.webmanifest ├── browserconfig.xml ├── manifest.json ├── electron.js └── index.html ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature.md │ └── bug_report.md └── workflows │ ├── testing.yml │ └── build.yml ├── babel.config.js ├── config ├── webpack │ └── persistentCache │ │ └── createEnvironmentHash.js └── jest │ ├── cssTransform.js │ ├── babelTransform.js │ └── fileTransform.js ├── Dockerfile ├── .gitignore ├── jest.config.js ├── tsconfig.json └── scripts └── test.js /.env: -------------------------------------------------------------------------------- 1 | REACT_APP_B_API_URL=https://api.boardsite.io 2 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom" 2 | -------------------------------------------------------------------------------- /src/drawing/attachment/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./pdf" 2 | -------------------------------------------------------------------------------- /src/state/action/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/board/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/drawing/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/loading/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/menu/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/online/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/settings/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/view/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /src/state/notification/index.ts: -------------------------------------------------------------------------------- 1 | // State 2 | export * from "./state" 3 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: "es5" 2 | tabWidth: 4 3 | semi: false 4 | singleQuote: false 5 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardsite-io/boardsite/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/util/testing/svgrMock.ts: -------------------------------------------------------------------------------- 1 | export default "SvgrURL" 2 | export const ReactComponent = "div" 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Andyomat, heat1q] 4 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardsite-io/boardsite/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardsite-io/boardsite/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardsite-io/boardsite/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /src/state/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./subscription/useGState" 2 | export * from "./subscription/usePageLayer" 3 | -------------------------------------------------------------------------------- /src/state/view/serializers/__test__/stateV1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "pageIndex": 0 4 | } 5 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardsite-io/boardsite/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardsite-io/boardsite/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@babel/preset-env", "@babel/preset-typescript"], 3 | plugins: ["inline-react-svg"], 4 | } 5 | -------------------------------------------------------------------------------- /src/App/routes.ts: -------------------------------------------------------------------------------- 1 | export const ROUTE = { 2 | HOME: "/", 3 | SESSION: "/b/:sessionId", 4 | AUTH_CALLBACK: "/github/oauth/callback", 5 | } 6 | -------------------------------------------------------------------------------- /src/state/view/util/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./bounds" 2 | export * from "./zoomTo" 3 | export * from "./helpers" 4 | export * from "./multiTouch" 5 | -------------------------------------------------------------------------------- /src/View/MainMenu/cssTransition.ts: -------------------------------------------------------------------------------- 1 | export const cssTransition = { 2 | unmountOnExit: true, 3 | timeout: 500, 4 | classNames: "menu", 5 | } 6 | -------------------------------------------------------------------------------- /src/state/settings/serializers/__test__/stateV1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "theme": 0, 4 | "keepCentered": true, 5 | "directDraw": false 6 | } 7 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/General/File/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const InvisibleInput = styled.input` 4 | display: none; 5 | ` 6 | -------------------------------------------------------------------------------- /src/components/Svg/svgs.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: React.FunctionComponent> 3 | export default content 4 | } 5 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/state/notification/state/index.types.ts: -------------------------------------------------------------------------------- 1 | import { IntlMessageId } from "language" 2 | 3 | export interface NotificationState { 4 | notifications: IntlMessageId[] 5 | } 6 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Transformer/shapeTransform/index.types.ts: -------------------------------------------------------------------------------- 1 | export type TransformState = { 2 | x: number 3 | y: number 4 | scaleX: number 5 | scaleY: number 6 | } 7 | -------------------------------------------------------------------------------- /src/state/loading/state/index.types.ts: -------------------------------------------------------------------------------- 1 | import { IntlMessageId } from "language" 2 | 3 | export interface LoadingState { 4 | isLoading: boolean 5 | messageId: IntlMessageId 6 | } 7 | -------------------------------------------------------------------------------- /src/util/lib/index.test.ts: -------------------------------------------------------------------------------- 1 | import { parseFontSize } from "." 2 | 3 | test("parseFontSize parses a style value correctly", () => { 4 | expect(parseFontSize("12px")).toEqual(12) 5 | }) 6 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pageclear.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/api/error.ts: -------------------------------------------------------------------------------- 1 | import { ErrorBody, ErrorCode } from "./types" 2 | 3 | export const isErrorResponse = (error: T, code: ErrorCode): boolean => 4 | (error as ErrorBody).response?.data.code === code 5 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/rectangle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /src/View/Dialog/OnlineCreate/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const CreateButtons = styled.div` 4 | display: flex; 5 | gap: 6px; 6 | align-items: center; 7 | ` 8 | -------------------------------------------------------------------------------- /src/View/Dialog/OnlineChangeAlias/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const Selection = styled.div` 4 | display: flex; 5 | align-items: center; 6 | gap: 1rem; 7 | ` 8 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const Canvas = styled.canvas` 4 | position: absolute; 5 | display: flex; 6 | background: transparent; 7 | ` 8 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import ReactDOM from "react-dom" 3 | import App from "App" 4 | 5 | // ======================================== 6 | 7 | ReactDOM.render(, document.getElementById("root")) 8 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/expandable.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/line.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/minus.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useKeyboardShortcuts" 2 | export * from "./useInitialDialog" 3 | export * from "./useWindowResize" 4 | export * from "./useLiveStroke" 5 | export * from "./useViewControl" 6 | export * from "./useLayerConfig" 7 | -------------------------------------------------------------------------------- /src/state/online/serializers/__test__/stateV1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "token": "abcdef", 4 | "user": { 5 | "id": "abcd-1234", 6 | "alias": "potato", 7 | "color": "#00beef" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pagedelete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/state/action/state/index.types.ts: -------------------------------------------------------------------------------- 1 | export interface ActionState { 2 | undoStack: Array 3 | redoStack: Array 4 | } 5 | 6 | export interface StackAction { 7 | handler: () => void 8 | undoHandler: () => void 9 | } 10 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/ShapeTools/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const StyledShapeTools = styled.div` 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: space-between; 7 | align-items: center; 8 | ` 9 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} -------------------------------------------------------------------------------- /src/components/Svg/svgs/plus.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /config/webpack/persistentCache/createEnvironmentHash.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | const { createHash } = require("crypto") 3 | 4 | module.exports = (env) => { 5 | const hash = createHash("md5") 6 | hash.update(JSON.stringify(env)) 7 | 8 | return hash.digest("hex") 9 | } 10 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/index.types.ts: -------------------------------------------------------------------------------- 1 | import { Page } from "state/board/state/index.types" 2 | 3 | export type PageOffset = { 4 | left: number 5 | top: number 6 | } 7 | 8 | export interface PageProps { 9 | page: Page 10 | pageOffset: PageOffset 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ToolTip/index.types.ts: -------------------------------------------------------------------------------- 1 | export enum Position { 2 | TopLeft, 3 | Top, 4 | TopRight, 5 | Right, 6 | BottomRight, 7 | Bottom, 8 | BottomLeft, 9 | Left, 10 | } 11 | 12 | export interface ToolTipBoxProps { 13 | position: Position 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/tick.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 7 | 8 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pageabove.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pagebelow.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pagedeleteall.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pen.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/redo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/undo.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Background/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | import { Canvas } from "../index.styled" 3 | 4 | export const CanvasBG = styled(Canvas)` 5 | background: ${({ theme }) => theme.palette.editor.paper}; 6 | box-shadow: rgba(0, 0, 0, 0.15) 0px 5px 15px 0px; 7 | ` 8 | -------------------------------------------------------------------------------- /src/View/Board/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import RenderNG from "./RenderNG" 3 | import Observer from "./Observer" 4 | 5 | const Board: React.FC = memo(() => { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | }) 12 | export default Board 13 | -------------------------------------------------------------------------------- /src/state/board/state/default.ts: -------------------------------------------------------------------------------- 1 | import { BoardState } from "./index.types" 2 | 3 | export const getDefaultBoardState = (): BoardState => ({ 4 | pageRank: [], 5 | pageCollection: {}, 6 | attachments: {}, 7 | activeTextfield: undefined, 8 | transformStrokes: undefined, 9 | transformPagePosition: undefined, 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/HorizontalRule/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const HorizontalRule = styled.hr` 4 | background: ${({ theme }) => theme.palette.common.rule}; 5 | outline: none; 6 | border: none; 7 | width: 100%; 8 | height: 1px; 9 | margin: 0.1rem 0; 10 | padding: 0; 11 | ` 12 | -------------------------------------------------------------------------------- /src/util/color/index.test.ts: -------------------------------------------------------------------------------- 1 | import { getRandomColor } from "." 2 | 3 | describe("getRandomColor", () => { 4 | it("should generate a valid hex color", () => { 5 | const randomHex = getRandomColor() 6 | 7 | expect(randomHex.length).toEqual(7) 8 | expect(/^#[0-9A-F]{6}$/i.test(randomHex)).toEqual(true) 9 | }) 10 | }) 11 | -------------------------------------------------------------------------------- /src/state/settings/state/index.types.ts: -------------------------------------------------------------------------------- 1 | import { ThemeOption } from "theme/themes" 2 | import { SerializedVersionState } from "state/types" 3 | 4 | export interface SettingsState { 5 | theme: ThemeOption 6 | keepCentered: boolean 7 | directDraw: boolean 8 | } 9 | 10 | export type SerializedSettingsState = SerializedVersionState 11 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/highlighter.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/zoomout.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax = docker/dockerfile:experimental 2 | 3 | FROM electronuserland/builder:wine AS builder 4 | WORKDIR /app 5 | COPY . . 6 | RUN --mount=type=cache,target=/app/build \ 7 | yarn build 8 | RUN --mount=type=cache,target=/app/build \ 9 | yarn electron-builder --linux --win 10 | 11 | 12 | FROM scratch AS bin 13 | COPY --from=builder /app/dist / 14 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/Session/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | import MenuItem from "View/MainMenu/MenuItem" 3 | 4 | export const UserMenuItem = styled(MenuItem)<{ color: string }>` 5 | border-left: 2px solid; 6 | border-top-left-radius: 0; 7 | border-bottom-left-radius: 0; 8 | border-color: ${({ color }) => color}; 9 | ` 10 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/eraser.svg: -------------------------------------------------------------------------------- 1 | 3 | 8 | 10 | -------------------------------------------------------------------------------- /src/state/settings/state/default.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_DIRECTDRAW, DEFAULT_KEEP_CENTERED } from "consts" 2 | import { ThemeOption } from "theme/themes" 3 | import { SettingsState } from "./index.types" 4 | 5 | export const getDefaultSettingsState = (): SettingsState => ({ 6 | theme: ThemeOption.Light, 7 | keepCentered: DEFAULT_KEEP_CENTERED, 8 | directDraw: DEFAULT_DIRECTDRAW, 9 | }) 10 | -------------------------------------------------------------------------------- /config/jest/cssTransform.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | // This is a custom Jest transformer turning style imports into empty objects. 4 | // http://facebook.github.io/jest/docs/en/webpack.html 5 | 6 | module.exports = { 7 | process() { 8 | return "module.exports = {};" 9 | }, 10 | getCacheKey() { 11 | // The output is always the same. 12 | return "cssTransform" 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/zoomin.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/VerticalRule/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const VerticalRule = styled.div` 4 | ${({ theme }) => css` 5 | background: ${theme.palette.common.rule}; 6 | outline: none; 7 | border: none; 8 | height: ${theme.iconButton.size}; 9 | width: 1px; 10 | margin: 0; 11 | padding: 0; 12 | `} 13 | ` 14 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "BoardSite", 3 | "name": "Online Whiteboard Website", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-96x96.png", 7 | "sizes": "96x96", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Popup/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const PopupCover = styled.button` 4 | z-index: ${({ theme }) => theme.zIndex.popupBG}; 5 | position: fixed; 6 | inset: 0; 7 | width: 100vw; 8 | height: 100vh; 9 | background: transparent; 10 | border: none; 11 | 12 | &:focus-visible { 13 | background: #00000033; 14 | outline-width: 4px; 15 | } 16 | ` 17 | -------------------------------------------------------------------------------- /src/util/lib/index.ts: -------------------------------------------------------------------------------- 1 | export const reduceRecord = ( 2 | record: Record, 3 | fn: (a: T) => U 4 | ): Record => 5 | Object.keys(record).reduce>( 6 | (col, key) => ({ 7 | ...col, 8 | [key]: fn(record[key]), 9 | }), 10 | {} 11 | ) 12 | 13 | export const parseFontSize = (fontSize: string): number => 14 | parseInt(fontSize.replace("px", ""), 10) 15 | -------------------------------------------------------------------------------- /src/components/FormikColorInput/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | interface ColorProps { 4 | $color: string 5 | } 6 | 7 | export const ColorButton = styled.button` 8 | background: ${(props: ColorProps) => props.$color}; 9 | height: 30px; 10 | width: 30px; 11 | border-radius: 100%; 12 | border: none; 13 | margin: 0; 14 | padding: 0; 15 | 16 | &:hover { 17 | cursor: pointer; 18 | } 19 | ` 20 | -------------------------------------------------------------------------------- /src/App/electron.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import isElectron from "is-electron" 3 | import { HashRouter } from "react-router-dom" 4 | 5 | interface ElectronWrapperProps { 6 | children: JSX.Element 7 | } 8 | 9 | const ElectronWrapper = ({ children }: ElectronWrapperProps): JSX.Element => { 10 | if (isElectron()) { 11 | return {children} 12 | } 13 | return children 14 | } 15 | 16 | export default ElectronWrapper 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | /dist 14 | 15 | # misc 16 | .DS_Store 17 | .env 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | .eslintcache 28 | .vscode 29 | .idea 30 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ["/src/"], 3 | moduleDirectories: ["node_modules", "src"], 4 | preset: "ts-jest", 5 | transform: { 6 | "^.+\\.(ts|tsx)?$": "ts-jest", 7 | "^.+\\.(js|jsx)$": "babel-jest", 8 | }, 9 | transformIgnorePatterns: ["node_modules/(?!(canvas)/)"], 10 | moduleNameMapper: { 11 | "\\.svg$": "/src/util/testing/svgrMock.ts", 12 | }, 13 | setupFilesAfterEnv: ["./jest.setup.js"], 14 | } 15 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import ColorPicker from "./ColorPicker" 3 | import ShapeTools from "./ShapeTools" 4 | import WidthPicker from "./WidthPicker" 5 | import { StylePickerWrap } from "./index.styled" 6 | 7 | const StylePicker: React.FC = memo(() => ( 8 | 9 | 10 | 11 | 12 | 13 | )) 14 | 15 | export default StylePicker 16 | -------------------------------------------------------------------------------- /src/drawing/livestroke/index.types.ts: -------------------------------------------------------------------------------- 1 | import { SerializedStroke, Point, Tool } from "drawing/stroke/index.types" 2 | 3 | export interface LiveStroke extends SerializedStroke { 4 | start(point: Point, pageId: string): void 5 | move(point: Point, pagePosition: Point): void 6 | end(point: Point): void 7 | setTool(tool: Tool): void 8 | addPoint(point: Point): void 9 | processPoints(): void 10 | reset(): void 11 | moveEraser(): void 12 | isReset(): boolean 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useWindowResize.ts: -------------------------------------------------------------------------------- 1 | import { debounce } from "lodash" 2 | import { useEffect } from "react" 3 | import { view } from "state/view" 4 | 5 | const onResize = debounce(() => { 6 | view.updateViewDimensions() 7 | }, 300) 8 | 9 | /** 10 | * Handle a window resize event 11 | */ 12 | export const useWindowResize = () => { 13 | useEffect(() => { 14 | window.addEventListener("resize", onResize) 15 | return () => window.removeEventListener("resize", onResize) 16 | }, []) 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/expand.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/shrink.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/state/view/state/default.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_CURRENT_PAGE_INDEX, 3 | DEFAULT_VIEW_TRANSFORM, 4 | DEVICE_PIXEL_RATIO, 5 | } from "consts" 6 | import { ViewState } from "./index.types" 7 | 8 | export const getDefaultViewState = (): ViewState => ({ 9 | pageIndex: DEFAULT_CURRENT_PAGE_INDEX, 10 | innerWidth: window.innerWidth, 11 | innerHeight: window.innerHeight, 12 | viewTransform: DEFAULT_VIEW_TRANSFORM, 13 | layerConfig: { 14 | pixelScale: DEVICE_PIXEL_RATIO, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /src/components/Popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler, ReactNode } from "react" 2 | import { PopupCover } from "./index.styled" 3 | 4 | interface PopupProps { 5 | open: boolean 6 | onClose: MouseEventHandler 7 | children: ReactNode 8 | } 9 | 10 | const Popup: React.FC = ({ open, onClose, children }) => 11 | open ? ( 12 | <> 13 | 14 | {children} 15 | 16 | ) : null 17 | 18 | export default Popup 19 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/online.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 💡 3 | about: Suggest a new idea for the project. 4 | labels: feature 5 | --- 6 | 7 | ## Summary 8 | 9 | 12 | 13 | ## What needs to be done? 14 | 15 | 18 | 19 | ## Additional Infos 20 | 21 | 24 | -------------------------------------------------------------------------------- /src/components/Button/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { StyledButton } from "./index.styled" 3 | 4 | interface ButtonProps extends React.ButtonHTMLAttributes { 5 | withIcon?: boolean 6 | fullWidth?: boolean 7 | } 8 | 9 | const Button: React.FC = ({ 10 | withIcon = false, 11 | children, 12 | ...props 13 | }) => ( 14 | 15 | {children} 16 | 17 | ) 18 | 19 | export default Button 20 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/index.types.ts: -------------------------------------------------------------------------------- 1 | import { ToolType } from "drawing/stroke/index.types" 2 | 3 | export type StrokeStyle = { 4 | color: string 5 | width: number 6 | opacity: number 7 | } 8 | 9 | export type StrokeId = string 10 | export type StrokePageId = string 11 | 12 | export interface RenderStroke { 13 | id: StrokeId 14 | pageId: StrokePageId 15 | type: ToolType 16 | style: StrokeStyle 17 | x: number 18 | y: number 19 | scaleX?: number 20 | scaleY?: number 21 | points: number[] 22 | } 23 | -------------------------------------------------------------------------------- /src/drawing/helpers/index.ts: -------------------------------------------------------------------------------- 1 | import { board } from "state/board" 2 | import { Page, PageRank } from "state/board/state/index.types" 3 | 4 | export const getVerifiedPageIds = (pageIds: PageRank): PageRank => { 5 | return pageIds.filter((pageId) => { 6 | return board.getState().pageCollection[pageId] !== undefined 7 | }) 8 | } 9 | 10 | export const getVerifiedPages = (pageIds: PageRank): Page[] => { 11 | return getVerifiedPageIds(pageIds).map((pageId) => { 12 | return board.getState().pageCollection[pageId] 13 | }) as Page[] 14 | } 15 | -------------------------------------------------------------------------------- /src/state/drawing/state/index.types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StrokeCollection, 3 | TextfieldAttrs, 4 | Tool, 5 | } from "drawing/stroke/index.types" 6 | import { PageMeta } from "state/board/state/index.types" 7 | import { SerializedVersionState } from "state/types" 8 | 9 | export interface DrawingState { 10 | tool: Tool 11 | pageMeta: PageMeta 12 | favoriteTools: Tool[] 13 | textfieldAttributes: TextfieldAttrs 14 | erasedStrokes: StrokeCollection 15 | } 16 | 17 | export type SerializedDrawingState = SerializedVersionState 18 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/ColorPicker/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { drawing } from "state/drawing" 3 | import { useGState } from "state" 4 | import { CustomColorPicker } from "./index.styled" 5 | 6 | const handleChange = (newColor: string) => { 7 | drawing.setColor(newColor) 8 | } 9 | 10 | const ColorPicker: React.FC = memo(() => { 11 | const { color } = useGState("ColorPicker").drawing.tool.style 12 | 13 | return 14 | }) 15 | 16 | export default ColorPicker 17 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/MenuButton/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | import { MainMenuButton } from "../index.styled" 3 | 4 | export const StyledMainMenuButton = styled(MainMenuButton)` 5 | ${({ theme }) => css` 6 | cursor: pointer; 7 | margin-right: 0.6rem; 8 | padding: ${theme.iconButton.padding}; 9 | height: ${theme.iconButton.size}; 10 | width: ${theme.iconButton.size}; 11 | 12 | svg { 13 | width: 80%; 14 | height: 80%; 15 | } 16 | `} 17 | ` 18 | -------------------------------------------------------------------------------- /src/View/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const ViewWrap = styled.div` 4 | position: fixed; 5 | display: flex; 6 | inset: 0; 7 | background: ${({ theme }) => theme.palette.editor.background}; 8 | -webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */ 9 | -webkit-text-size-adjust: none; /* prevent webkit from resizing text to fit */ 10 | -webkit-user-select: none; /* prevent copy paste, to allow, change 'none' to 'text' */ 11 | user-select: none; 12 | touch-action: none; 13 | ` 14 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/download.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/upload.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/theme/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useGState } from "state" 3 | import { ThemeProvider } from "styled-components" 4 | import GlobalStyles from "./globalStyles" 5 | import { themes } from "./themes" 6 | 7 | const Theme: React.FC<{ children: React.ReactNode }> = ({ children }) => { 8 | const { theme: currentTheme } = useGState("Theme").settings 9 | 10 | return ( 11 | 12 | 13 | {children} 14 | 15 | ) 16 | } 17 | 18 | export default Theme 19 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/MenuButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { MenuIcon } from "components" 2 | import React, { memo } from "react" 3 | import { StyledMainMenuButton } from "./index.styled" 4 | 5 | const MenuButton: React.FC> = 6 | memo((props) => { 7 | return ( 8 | 13 | 14 | 15 | ) 16 | }) 17 | export default MenuButton 18 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/select.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/App/router.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Route, Routes as RouterRoutes, Navigate } from "react-router-dom" 3 | import View from "View" 4 | import Callback from "View/Oauth2" 5 | import { ROUTE } from "./routes" 6 | 7 | const Routes = (): JSX.Element => ( 8 | 9 | } /> 10 | } /> 11 | } /> 12 | } /> 13 | 14 | ) 15 | 16 | export default Routes 17 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/textfield.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/View/Shortcuts/index.styled.ts: -------------------------------------------------------------------------------- 1 | import { Drawer } from "components" 2 | import styled from "styled-components" 3 | 4 | export const ShortcutDrawer = styled(Drawer)` 5 | width: fit-content; 6 | max-width: 80vw; 7 | ` 8 | export const ShortcutList = styled.ul` 9 | display: grid; 10 | margin: 0; 11 | padding: 0.5rem 0.8rem; 12 | gap: 0.2rem 3em; 13 | 14 | @media (min-width: ${({ theme }) => theme.breakpoint.md}) { 15 | grid-auto-flow: row; 16 | justify-content: space-between; 17 | align-items: center; 18 | grid-template-columns: repeat(2, 1fr); 19 | } 20 | ` 21 | -------------------------------------------------------------------------------- /src/state/view/state/index.types.ts: -------------------------------------------------------------------------------- 1 | import { SerializedVersionState } from "state/types" 2 | 3 | export type LayerConfig = { 4 | pixelScale: number 5 | } 6 | 7 | export type ViewTransform = { 8 | xOffset: number 9 | yOffset: number 10 | scale: number 11 | } 12 | 13 | export type PageIndex = number 14 | 15 | export type ViewState = { 16 | pageIndex: PageIndex 17 | innerWidth: number 18 | innerHeight: number 19 | viewTransform: ViewTransform 20 | layerConfig: LayerConfig 21 | } 22 | 23 | export type SerializedViewState = SerializedVersionState< 24 | Pick 25 | > 26 | -------------------------------------------------------------------------------- /src/util/color/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrokeStyle } from "View/Board/RenderNG/index.types" 2 | 3 | export const getRandomColor = () => { 4 | const color = new Array(6) 5 | .fill(0) 6 | .map(() => Math.floor(Math.random() * 16).toString(16)) 7 | .join("") 8 | return "#".concat(color) 9 | } 10 | 11 | export const strokeStyleToRGBA = (style: StrokeStyle): string => { 12 | const r = parseInt(style.color.slice(1, 3), 16) 13 | const g = parseInt(style.color.slice(3, 5), 16) 14 | const b = parseInt(style.color.slice(5, 7), 16) 15 | return `rgba(${r},${g},${b},${style.opacity})` 16 | } 17 | -------------------------------------------------------------------------------- /src/state/menu/state/index.types.ts: -------------------------------------------------------------------------------- 1 | export enum MainMenuState { 2 | Closed, 3 | General, 4 | View, 5 | Page, 6 | Session, 7 | } 8 | 9 | export enum DialogState { 10 | Closed, 11 | InitialSelectionFirstLoad, 12 | InitialSelection, 13 | OnlineCreate, 14 | OnlineJoin, 15 | OnlineEnterPassword, 16 | OnlineChangeAlias, 17 | OnlineChangePassword, 18 | OnlineLeave, 19 | Subscribe, 20 | } 21 | 22 | export interface MenuState { 23 | dialogState: DialogState 24 | mainMenuState: MainMenuState 25 | shortcutsOpen: boolean 26 | textfieldSettingsOpen: boolean 27 | } 28 | -------------------------------------------------------------------------------- /src/state/online/state/default.ts: -------------------------------------------------------------------------------- 1 | import { 2 | adjectives, 3 | animals, 4 | uniqueNamesGenerator, 5 | } from "unique-names-generator" 6 | import { getRandomColor } from "util/color" 7 | import { OnlineState } from "./index.types" 8 | 9 | export const getDefaultOnlineState = (): OnlineState => ({ 10 | user: { 11 | alias: uniqueNamesGenerator({ 12 | dictionaries: [adjectives, animals], 13 | separator: "", 14 | style: "capital", 15 | }), 16 | color: getRandomColor(), 17 | }, 18 | token: "", 19 | session: {}, 20 | isAuthorized: false, 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/ToolButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import IconButton, { IconButtonProps } from "../IconButton" 3 | import { ToolInfo, ToolButtonWrap } from "./index.styled" 4 | 5 | interface ToolButtonProps extends IconButtonProps { 6 | toolColor: string 7 | toolWidth: number 8 | } 9 | 10 | const ToolButton: React.FC = ({ 11 | toolWidth, 12 | toolColor, 13 | ...props 14 | }) => ( 15 | 16 | 17 | {toolWidth} 18 | 19 | ) 20 | 21 | export default ToolButton 22 | -------------------------------------------------------------------------------- /src/drawing/attachment/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Attachment, 3 | AttachType, 4 | SerializedAttachment, 5 | } from "state/board/state/index.types" 6 | import { PDFAttachment } from "./pdf" 7 | 8 | /** 9 | * creates a new Attachment instance from a serialized one 10 | */ 11 | export const newAttachment = ({ 12 | type, 13 | id, 14 | cachedBlob, 15 | }: SerializedAttachment): Attachment => { 16 | switch (type) { 17 | case AttachType.PDF: 18 | return new PDFAttachment(cachedBlob).setId(id) 19 | 20 | default: 21 | throw new Error("unknown attachment type") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/View/ToolRing/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const ToolRingWrap = styled.div` 4 | ${({ theme }) => css` 5 | position: fixed; 6 | display: flex; 7 | flex-direction: column; 8 | top: 0; 9 | right: 0; 10 | margin: ${theme.toolbar.margin}; 11 | padding: ${theme.toolbar.padding}; 12 | gap: ${theme.toolbar.gap}; 13 | box-shadow: ${theme.toolbar.boxShadow}; 14 | z-index: ${theme.zIndex.toolRing}; 15 | background: ${theme.palette.primary.main}; 16 | border-radius: ${theme.borderRadius}; 17 | `} 18 | ` 19 | -------------------------------------------------------------------------------- /src/state/types.ts: -------------------------------------------------------------------------------- 1 | export interface GlobalState { 2 | getState: () => T 3 | setState: (newState: T) => GlobalState 4 | } 5 | 6 | export type SerializedState = Partial & { version?: string } 7 | 8 | export interface Version { 9 | version?: string 10 | } 11 | 12 | export type SerializedVersionState = T & Version 13 | 14 | export interface Serializer { 15 | serialize(): U 16 | deserialize(serialized: U): Promise 17 | } 18 | 19 | export interface StateSerializer 20 | extends Serializer { 21 | saveToLocalStorage(): void 22 | loadFromLocalStorage(): Promise 23 | } 24 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/ActiveTextField/useKeylistener.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | import { onFinishTextEdit } from "./helpers" 3 | 4 | export const useKeylistener = ( 5 | inputRef: React.RefObject 6 | ) => { 7 | useEffect(() => { 8 | const onKeyDown = (e: KeyboardEvent) => { 9 | if (e.key === "Escape") { 10 | onFinishTextEdit(inputRef) 11 | } 12 | } 13 | window.addEventListener("keydown", onKeyDown) 14 | 15 | return () => { 16 | window.removeEventListener("keydown", onKeyDown) 17 | } 18 | }, [inputRef]) 19 | } 20 | -------------------------------------------------------------------------------- /src/View/FavoriteTools/FavoriteToolButton/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const FavToolWrapper = styled.div` 4 | position: relative; 5 | ` 6 | 7 | export const FavToolOptions = styled.div` 8 | ${({ theme }) => css` 9 | z-index: ${theme.zIndex.favoriteTools}; 10 | position: absolute; 11 | display: flex; 12 | left: 100%; 13 | top: 0; 14 | margin-left: ${theme.toolbar.margin}; 15 | border-radius: ${theme.borderRadius}; 16 | background-color: ${theme.palette.primary.main}; 17 | box-shadow: ${theme.toolbar.boxShadow}; 18 | `} 19 | ` 20 | -------------------------------------------------------------------------------- /src/View/FavoriteTools/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const FavToolsStyled = styled.div` 4 | ${({ theme }) => css` 5 | z-index: ${theme.zIndex.favoriteTools}; 6 | position: fixed; 7 | display: flex; 8 | flex-direction: column; 9 | left: 0; 10 | bottom: 0; 11 | background: ${theme.palette.primary.main}; 12 | margin: ${theme.toolbar.margin}; 13 | gap: ${theme.toolbar.gap}; 14 | padding: ${theme.toolbar.padding}; 15 | box-shadow: ${theme.toolbar.boxShadow}; 16 | border-radius: ${theme.borderRadius}; 17 | `} 18 | ` 19 | -------------------------------------------------------------------------------- /src/View/Shortcuts/Shortcut/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FormattedMessage, IntlMessageId } from "language" 3 | import { Item, Keys, Title } from "./index.styled" 4 | 5 | interface ShortcutProps { 6 | titleId: IntlMessageId 7 | keysId: IntlMessageId 8 | } 9 | 10 | const Shortcut: React.FC = ({ titleId, keysId }) => { 11 | return ( 12 | 13 | 14 | <FormattedMessage id={titleId} /> 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | 23 | export default Shortcut 24 | -------------------------------------------------------------------------------- /src/drawing/helpers/index.test.ts: -------------------------------------------------------------------------------- 1 | import { board } from "state/board" 2 | import { getVerifiedPages, getVerifiedPageIds } from "." 3 | import { mockBoardState1, MOCK_PAGE_1, MOCK_PAGE_ID_1 } from "./mocks" 4 | 5 | board.getState = () => mockBoardState1 6 | 7 | describe("helpers", () => { 8 | it("should return only verified page ids", () => { 9 | expect(getVerifiedPageIds([MOCK_PAGE_ID_1, "fake_id"])).toEqual([ 10 | MOCK_PAGE_ID_1, 11 | ]) 12 | }) 13 | 14 | it("should return only verified pages", () => { 15 | expect(getVerifiedPages([MOCK_PAGE_ID_1, "fake_id"])).toEqual([ 16 | MOCK_PAGE_1, 17 | ]) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/ViewButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { useGState } from "state" 3 | import { MainMenuButton } from "../index.styled" 4 | 5 | const ViewButton: React.FC> = 6 | memo((props) => { 7 | const { scale } = useGState("ViewTransform").view.viewTransform 8 | 9 | return ( 10 | 15 | {(scale * 100).toFixed(0)} % 16 | 17 | ) 18 | }) 19 | export default ViewButton 20 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | import { API_URL } from "./request" 3 | 4 | export const AUTH_URL = `${API_URL}/github/oauth/authorize` 5 | export const AUTH_VALIDATE_URL = `${API_URL}/github/oauth/validate` 6 | 7 | const isAuthorized: Record = {} 8 | 9 | export const validateToken = async (token: string): Promise => { 10 | if (!token) return false 11 | if (isAuthorized[token] !== undefined) return isAuthorized[token] 12 | const response = await axios.get(AUTH_VALIDATE_URL, { 13 | headers: { Authorization: `Bearer ${token}` }, 14 | }) 15 | isAuthorized[token] = response.status === 204 16 | return isAuthorized[token] 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler, ReactNode } from "react" 2 | import { DialogBox, DialogBackground } from "./index.styled" 3 | 4 | export interface DialogProps { 5 | open: boolean 6 | onClose: MouseEventHandler 7 | children: ReactNode 8 | className?: string 9 | } 10 | 11 | const Dialog: React.FC = ({ 12 | className, 13 | open, 14 | onClose, 15 | children, 16 | }) => ( 17 | <> 18 | 19 | 20 | {children} 21 | 22 | 23 | ) 24 | 25 | export default Dialog 26 | -------------------------------------------------------------------------------- /src/components/IconButton/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { StyledIconButton } from "./index.styled" 3 | 4 | export interface IconButtonProps 5 | extends React.ButtonHTMLAttributes { 6 | deactivated?: boolean 7 | active?: boolean 8 | icon: JSX.Element 9 | } 10 | 11 | const IconButton: React.FC = ({ 12 | deactivated, 13 | active, 14 | icon, 15 | ...props 16 | }) => ( 17 | 23 | {icon} 24 | 25 | ) 26 | 27 | export default IconButton 28 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { Background } from "./Background" 3 | import { PageProps } from "./index.types" 4 | import { Content } from "./Content" 5 | import { Live } from "./Live" 6 | import { Transformer } from "./Transformer" 7 | import { ActiveTextfield } from "./ActiveTextField" 8 | 9 | const Page: React.FC = memo((props) => { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | }) 20 | 21 | export default Page 22 | -------------------------------------------------------------------------------- /src/components/FormikInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from "react" 2 | import { FieldProps } from "formik" 3 | import { Input, ValidationError } from "./index.styled" 4 | 5 | const FormikInput: FC = ({ 6 | field, // { name, value, onChange, onBlur } 7 | form: { touched, errors, isValid, submitCount }, 8 | ...props 9 | }) => { 10 | return ( 11 | <> 12 | 13 | {submitCount > 0 && touched[field.name] && errors[field.name] && ( 14 | {errors[field.name]} 15 | )} 16 | 17 | ) 18 | } 19 | 20 | export default FormikInput 21 | -------------------------------------------------------------------------------- /src/components/ToolButton/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const ToolButtonWrap = styled.div` 4 | position: relative; 5 | ` 6 | 7 | export const ToolInfo = styled.div<{ $toolColor: string }>` 8 | touch-action: none; 9 | pointer-events: none; 10 | position: absolute; 11 | text-align: center; 12 | bottom: -0.2rem; 13 | right: 0; 14 | padding: 0 0.3rem; 15 | color: ${({ theme }) => theme.palette.secondary.contrastText}; 16 | border-radius: ${({ theme }) => theme.borderRadius}; 17 | box-shadow: ${({ theme }) => theme.boxShadow}; 18 | ${({ $toolColor }) => 19 | css` 20 | background: ${$toolColor}66; 21 | `} 22 | ` 23 | -------------------------------------------------------------------------------- /src/theme/options/dark.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const darkTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#424242", 10 | contrastText: "#E0E0E0", 11 | }, 12 | secondary: { 13 | main: "#616161", 14 | contrastText: "#ffffff", 15 | }, 16 | editor: { 17 | background: "#757575", 18 | paper: "#f9fbff", 19 | selected: "#212121", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#00000022", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/soil.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const soilTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#8D6E63", 10 | contrastText: "#EFEBE9", 11 | }, 12 | secondary: { 13 | main: "#6D4C41", 14 | contrastText: "#EFEBE9", 15 | }, 16 | editor: { 17 | background: "#BCAAA4", 18 | paper: "#f9fbff", 19 | selected: "#4E342E", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#EFEBE944", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/sun.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const sunTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#FDD835", 10 | contrastText: "#212121", 11 | }, 12 | secondary: { 13 | main: "#FFEE58", 14 | contrastText: "#212121", 15 | }, 16 | editor: { 17 | background: "#FFF59D", 18 | paper: "#f9fbff", 19 | selected: "#FFF176", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#42424255", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/teal.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const tealTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#00695C", 10 | contrastText: "#E0F2F1", 11 | }, 12 | secondary: { 13 | main: "#004D40", 14 | contrastText: "#E0F2F1", 15 | }, 16 | editor: { 17 | background: "#009688", 18 | paper: "#f9fbff", 19 | selected: "#00796B", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#E0F2F155", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/View/Board/Observer/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const ViewBackground = styled.div` 4 | -webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */ 5 | -webkit-text-size-adjust: none; /* prevent webkit from resizing text to fit */ 6 | -webkit-user-select: none; /* prevent copy paste, to allow, change 'none' to 'text' */ 7 | user-select: none; 8 | touch-action: none; 9 | overflow: hidden; 10 | position: fixed; 11 | inset: 0; 12 | ` 13 | 14 | export const ViewControl = styled.div` 15 | position: absolute; 16 | width: 0; 17 | height: 0; 18 | ` 19 | 20 | export const Content = styled.div` 21 | position: relative; 22 | ` 23 | -------------------------------------------------------------------------------- /src/theme/options/apple.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const appleTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#2E7D32", 10 | contrastText: "#EFEBE9", 11 | }, 12 | secondary: { 13 | main: "#43A047", 14 | contrastText: "#EFEBE9", 15 | }, 16 | editor: { 17 | background: "#81C784", 18 | paper: "#f9fbff", 19 | selected: "#5D4037", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#1B5E2044", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/candy.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const candyTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#F8BBD0", 10 | contrastText: "#212121", 11 | }, 12 | secondary: { 13 | main: "#F48FB1", 14 | contrastText: "#212121", 15 | }, 16 | editor: { 17 | background: "#FCE4EC", 18 | paper: "#f9fbff", 19 | selected: "#F06292", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#880E4F44", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/light.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const lightTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#f5f5f5", 10 | contrastText: "#000000", 11 | }, 12 | secondary: { 13 | main: "#3F51B5", 14 | contrastText: "#ffffff", 15 | }, 16 | editor: { 17 | background: "#bcaaa4", 18 | paper: "#f9fbff", 19 | selected: "#d9d7f1", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#00000022", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/ocean.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const oceanTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#1A237E", 10 | contrastText: "#E0E0E0", 11 | }, 12 | secondary: { 13 | main: "#283593", 14 | contrastText: "#FAFAFA", 15 | }, 16 | editor: { 17 | background: "#3F51B5", 18 | paper: "#f9fbff", 19 | selected: "#3949AB", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#E0F7FA55", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/orange.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const orangeTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#F57C00", 10 | contrastText: "#E0F7FA", 11 | }, 12 | secondary: { 13 | main: "#E65100", 14 | contrastText: "#E0F7FA", 15 | }, 16 | editor: { 17 | background: "#FFCC80", 18 | paper: "#f9fbff", 19 | selected: "#F4511E", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#E0F7FA88", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/theme/options/purple.ts: -------------------------------------------------------------------------------- 1 | // theme.ts 2 | import { DefaultTheme } from "styled-components" 3 | import { baseTheme } from "theme/baseTheme" 4 | 5 | export const purpleTheme: DefaultTheme = { 6 | ...baseTheme, 7 | palette: { 8 | primary: { 9 | main: "#4A148C", 10 | contrastText: "#F3E5F5", 11 | }, 12 | secondary: { 13 | main: "#5E35B1", 14 | contrastText: "#F3E5F5", 15 | }, 16 | editor: { 17 | background: "#7E57C2", 18 | paper: "#f9fbff", 19 | selected: "#8E24AA", 20 | }, 21 | common: { 22 | warning: "#ff0000", 23 | rule: "#F3E5F555", 24 | }, 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /src/hooks/useInitialDialog.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { useParams } from "react-router-dom" 3 | import { menu } from "state/menu" 4 | import { DialogState } from "state/menu/state/index.types" 5 | 6 | let isFirstLoad = true 7 | 8 | /** 9 | * Set the suitable initial dialog on first load 10 | */ 11 | export const useInitialDialog = () => { 12 | const { sessionId } = useParams() 13 | 14 | useEffect(() => { 15 | if (!isFirstLoad) return 16 | isFirstLoad = false 17 | 18 | if (sessionId) { 19 | menu.setDialogState(DialogState.OnlineJoin) 20 | } else { 21 | menu.setDialogState(DialogState.InitialSelectionFirstLoad) 22 | } 23 | }, [sessionId]) 24 | } 25 | -------------------------------------------------------------------------------- /config/jest/babelTransform.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const babelJest = require("babel-jest").default 4 | 5 | const hasJsxRuntime = (() => { 6 | if (process.env.DISABLE_NEW_JSX_TRANSFORM === "true") { 7 | return false 8 | } 9 | 10 | try { 11 | require.resolve("react/jsx-runtime") 12 | return true 13 | } catch (e) { 14 | return false 15 | } 16 | })() 17 | 18 | module.exports = babelJest.createTransformer({ 19 | presets: [ 20 | [ 21 | require.resolve("babel-preset-react-app"), 22 | { 23 | runtime: hasJsxRuntime ? "automatic" : "classic", 24 | }, 25 | ], 26 | ], 27 | babelrc: false, 28 | configFile: false, 29 | }) 30 | -------------------------------------------------------------------------------- /public/electron.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { app, BrowserWindow, Menu } = require("electron") 3 | const path = require("path") 4 | const isDev = require("electron-is-dev") 5 | 6 | function createWindow() { 7 | // Create the browser window. 8 | const win = new BrowserWindow({ 9 | width: 800, 10 | height: 600, 11 | webPreferences: { nodeIntegration: false }, 12 | }) 13 | win.webContents.openDevTools() 14 | 15 | win.loadURL( 16 | isDev 17 | ? "http://localhost:3000" 18 | : `file://${path.join(__dirname, "../build/index.html")}` 19 | ) 20 | 21 | Menu.setApplicationMenu(null) 22 | } 23 | 24 | app.on("ready", createWindow) 25 | -------------------------------------------------------------------------------- /src/View/Shortcuts/Shortcut/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const Item = styled.li` 4 | list-style: none; 5 | color: ${({ theme }) => theme.palette.primary.contrastText}; 6 | display: flex; 7 | justify-content: space-between; 8 | align-items: center; 9 | padding: 0; 10 | margin: 0; 11 | gap: 1rem; 12 | ` 13 | 14 | export const Title = styled.span`` 15 | export const Keys = styled.span` 16 | ${({ theme }) => css` 17 | text-overflow: ellipsis; 18 | background: ${theme.palette.editor.selected}; 19 | padding: ${theme.menuButton.padding}; 20 | margin: ${theme.menuButton.margin}; 21 | border-radius: ${theme.borderRadius}; 22 | `} 23 | ` 24 | -------------------------------------------------------------------------------- /src/components/Drawer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { MouseEventHandler, ReactNode } from "react" 2 | import { DrawerBox, DrawerBackground } from "./index.styled" 3 | 4 | interface DrawerProps { 5 | position?: "left" | "right" 6 | open: boolean 7 | onClose: MouseEventHandler 8 | children: ReactNode 9 | className?: string 10 | } 11 | 12 | const Drawer: React.FC = ({ 13 | position = "left", 14 | open, 15 | onClose, 16 | children, 17 | className, 18 | }) => ( 19 | <> 20 | 21 | 22 | {children} 23 | 24 | 25 | ) 26 | 27 | export default Drawer 28 | -------------------------------------------------------------------------------- /src/state/subscription/usePageLayer.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react" 2 | import { PageId } from "state/board/state/index.types" 3 | import { subscriptionState } from "." 4 | import { board } from "../board/state" 5 | import { PageLayer } from "./index.types" 6 | 7 | export const usePageLayer = (pageLayer: PageLayer, pageId: PageId) => { 8 | const [, render] = useState({}) 9 | const trigger = useCallback(() => render({}), []) 10 | 11 | useEffect(() => { 12 | subscriptionState.subscribePage(pageLayer, pageId, trigger) 13 | 14 | return () => { 15 | subscriptionState.unsubscribePage(pageLayer, pageId) 16 | } 17 | }, [pageLayer, pageId, trigger]) 18 | 19 | return board.getState() 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNEXT", 4 | "module": "esnext", 5 | "jsx": "react-jsx", 6 | "outDir": "build", 7 | "rootDir": "src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "lib": [ 13 | "dom", 14 | "dom.iterable", 15 | "esnext" 16 | ], 17 | "allowJs": true, 18 | "allowSyntheticDefaultImports": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "baseUrl": "./src" 25 | }, 26 | "include": [ 27 | "src", 28 | "src/components/Svg/svgs.d.ts" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Platform** 11 | - Browser/Runtime [e.g. chrome (electron), firefox, safari] 12 | - Version [e.g. 22] 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. Go to '...' 20 | 2. Click on '....' 21 | 3. Scroll down to '....' 22 | 4. See error 23 | 24 | **Expected behavior** 25 | A clear and concise description of what you expected to happen. 26 | 27 | **Screenshots** 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const StylePickerWrap = styled.div` 4 | ${({ theme }) => css` 5 | z-index: ${theme.zIndex.toolRing}; 6 | position: absolute; 7 | right: 100%; 8 | top: 0; 9 | display: flex; 10 | margin-right: ${theme.toolbar.margin}; 11 | display: flex; 12 | gap: 6px; 13 | padding: 6px; 14 | height: 15rem; 15 | min-width: 18rem; 16 | width: 75vw; 17 | max-width: 25rem; 18 | 19 | background: ${theme.palette.primary.main}; 20 | border-radius: ${theme.borderRadius}; 21 | box-shadow: ${theme.toolbar.boxShadow}; 22 | 23 | button { 24 | margin: 2px; 25 | } 26 | `} 27 | ` 28 | -------------------------------------------------------------------------------- /src/state/online/state/index.types.ts: -------------------------------------------------------------------------------- 1 | import { SerializedVersionState } from "state/types" 2 | 3 | interface State { 4 | user: User 5 | token?: string 6 | } 7 | 8 | export interface OnlineState extends State { 9 | session: Session 10 | isAuthorized: boolean 11 | } 12 | 13 | export type SerializedOnlineState = SerializedVersionState 14 | 15 | export type Session = { 16 | config?: SessionConfig 17 | secret?: string 18 | socket?: WebSocket 19 | users?: ConnectedUsers 20 | } 21 | 22 | export type ConnectedUsers = Record 23 | 24 | export type SessionConfig = { 25 | id: string 26 | host: string 27 | maxUsers: number 28 | readOnly: boolean 29 | password: string 30 | } 31 | 32 | export type User = { 33 | id?: string 34 | alias: string 35 | color: string 36 | } 37 | -------------------------------------------------------------------------------- /src/components/ToolTip/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode } from "react" 2 | import { HoverTrigger, ToolTipBox, ToolTipText, Wrapper } from "./index.styled" 3 | import { Position } from "./index.types" 4 | 5 | interface TooltipProps { 6 | children: ReactNode 7 | text: JSX.Element 8 | position: Position 9 | deactivate?: boolean 10 | } 11 | 12 | const ToolTip: React.FC = ({ 13 | children, 14 | text, 15 | position, 16 | deactivate = false, 17 | }) => ( 18 | 19 | 20 | {children} 21 | {!deactivate && ( 22 | 23 | {text} 24 | 25 | )} 26 | 27 | 28 | ) 29 | 30 | export default ToolTip 31 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/SessionButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { OnlineIcon } from "components" 2 | import React, { memo } from "react" 3 | import { useGState } from "state" 4 | import { online } from "state/online" 5 | import { SessionStatus, StyledMainMenuButton } from "./index.styled" 6 | 7 | const SessionButton: React.FC> = 8 | memo((props) => { 9 | useGState("Session") 10 | 11 | return ( 12 | 17 | 18 | 19 | {online.getNumberOfUsers() || "+"} 20 | 21 | 22 | ) 23 | }) 24 | export default SessionButton 25 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/pan.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/View/Oauth2/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | import { useNavigate, useSearchParams } from "react-router-dom" 3 | import { online } from "state/online" 4 | import { notification } from "state/notification" 5 | import { ROUTE } from "App/routes" 6 | 7 | const Callback: React.FC = () => { 8 | const navigate = useNavigate() 9 | const [searchParams] = useSearchParams() 10 | const token = searchParams.get("token") 11 | const error = searchParams.get("error") 12 | 13 | useEffect(() => { 14 | if (error || !token || searchParams.get("token_type") !== "bearer") { 15 | notification.create("Notification.Session.OauthFlowFailed") 16 | navigate(ROUTE.HOME) 17 | return 18 | } 19 | online.setToken(token).then(() => { 20 | navigate(ROUTE.HOME) 21 | }) 22 | }) 23 | 24 | return null 25 | } 26 | 27 | export default Callback 28 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Content/useRender.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react" 2 | import { Page } from "state/board/state/index.types" 3 | import { useGState, usePageLayer } from "state" 4 | import { draw, drawErased } from "util/render/shapes" 5 | 6 | export const useRender = ( 7 | page: Page, 8 | canvasRef: React.RefObject 9 | ) => { 10 | usePageLayer("content", page.pageId) 11 | 12 | const { erasedStrokes } = useGState("PageContent").drawing 13 | 14 | useEffect(() => { 15 | const canvas = canvasRef.current 16 | const ctx = canvas?.getContext("2d") 17 | if (!ctx) return 18 | 19 | Object.values(page.strokes).forEach((stroke) => { 20 | if (erasedStrokes[stroke.id]) { 21 | drawErased(ctx, stroke) 22 | } else { 23 | draw(ctx, stroke) 24 | } 25 | // drawHitboxRects(ctx, stroke) // Hitbox debugging 26 | }) 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /src/state/view/util/zoomTo.ts: -------------------------------------------------------------------------------- 1 | import { ZOOM_SCALE_MAX, ZOOM_SCALE_MIN } from "consts" 2 | import { Point } from "drawing/stroke/index.types" 3 | import { ViewTransform } from "state/view/state/index.types" 4 | import { applyBound } from "./bounds" 5 | 6 | interface ZoomToProps { 7 | viewTransform: ViewTransform 8 | zoomPoint: Point 9 | zoomScale: number 10 | } 11 | 12 | export const zoomTo = ({ 13 | viewTransform, 14 | zoomPoint, 15 | zoomScale, 16 | }: ZoomToProps): ViewTransform => { 17 | const scale1 = viewTransform.scale 18 | const scale2 = applyBound({ 19 | value: zoomScale * scale1, 20 | min: ZOOM_SCALE_MIN, 21 | max: ZOOM_SCALE_MAX, 22 | }) 23 | 24 | return { 25 | xOffset: 26 | viewTransform.xOffset + zoomPoint.x / scale2 - zoomPoint.x / scale1, 27 | yOffset: 28 | viewTransform.yOffset + zoomPoint.y / scale2 - zoomPoint.y / scale1, 29 | scale: scale2, 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/SessionButton/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | import { MainMenuButton } from "../index.styled" 3 | 4 | export const SessionStatus = styled.div` 5 | position: absolute; 6 | bottom: 0; 7 | right: -0.5rem; 8 | display: flex; 9 | touch-action: none; 10 | pointer-events: none; 11 | text-align: center; 12 | bottom: -0.2rem; 13 | right: -0.45rem; 14 | padding: 0 0.3rem; 15 | 16 | color: ${({ theme }) => theme.palette.secondary.contrastText}; 17 | background: ${({ theme }) => theme.palette.secondary.main}; 18 | border-radius: ${({ theme }) => theme.borderRadius}; 19 | filter: opacity(75%); 20 | box-shadow: ${({ theme }) => theme.boxShadow}; 21 | ` 22 | 23 | export const StyledMainMenuButton = styled(MainMenuButton)` 24 | ${({ theme }) => css` 25 | cursor: pointer; 26 | width: ${theme.iconButton.size}; 27 | padding: ${theme.iconButton.padding}; 28 | `} 29 | ` 30 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Svg" 2 | export { default as IconButton } from "./IconButton" 3 | export { default as ToolButton } from "./ToolButton" 4 | export { default as Button } from "./Button" 5 | export { default as Popup } from "./Popup" 6 | export { default as ToolTip } from "./ToolTip" 7 | export { Position } from "./ToolTip/index.types" // Cleaner imports 8 | export { default as Dialog } from "./Dialog" 9 | export { default as Drawer } from "./Drawer" 10 | export { 11 | DialogTitle, 12 | DialogContent, 13 | DialogOptions, 14 | } from "./Dialog/index.styled" 15 | export { DrawerTitle, DrawerContent } from "./Drawer/index.styled" 16 | export { HorizontalRule } from "./HorizontalRule/index.styled" 17 | export { VerticalRule } from "./VerticalRule/index.styled" 18 | 19 | // Formik components 20 | export { default as FormikInput } from "./FormikInput" 21 | export { default as FormikColorInput } from "./FormikColorInput" 22 | export { FormikLabel } from "./FormikInput/index.styled" 23 | -------------------------------------------------------------------------------- /src/theme/globalStyles.ts: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle, css } from "styled-components" 2 | 3 | const GlobalStyles = createGlobalStyle` 4 | ${({ theme }) => css` 5 | body { 6 | /* --- Font --- */ 7 | font-family: "Lato", sans-serif; 8 | font-size: 16px; 9 | font-weight: 400; 10 | 11 | body, 12 | input, 13 | button, 14 | select, 15 | textarea, 16 | ul, 17 | li { 18 | font-family: inherit; 19 | font-size: inherit; 20 | font-weight: inherit; 21 | } 22 | 23 | /* --- Color --- */ 24 | color: ${theme.palette.primary.contrastText}; 25 | 26 | svg:not(.external-icon) { 27 | stroke: ${theme.palette.primary.contrastText}; 28 | stroke-width: ${theme.iconButton.strokeWidth}; 29 | } 30 | } 31 | `} 32 | ` 33 | 34 | export default GlobalStyles 35 | -------------------------------------------------------------------------------- /src/components/FormikColorInput/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { FieldProps } from "formik" 3 | import { getRandomColor } from "util/color" 4 | import { FormattedMessage } from "language" 5 | import ToolTip from "../ToolTip" 6 | import { Position } from "../ToolTip/index.types" 7 | import { ColorButton } from "./index.styled" 8 | 9 | const FormikColorInput: React.FC = ({ form, field }) => { 10 | const onNewColor = useCallback(() => { 11 | form.setFieldValue(field.name, getRandomColor()) 12 | }, [field, form]) 13 | 14 | return ( 15 | } 18 | > 19 | 25 | 26 | ) 27 | } 28 | export default FormikColorInput 29 | -------------------------------------------------------------------------------- /src/View/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useInitialDialog, useKeyboardShortcuts, useWindowResize } from "hooks" 3 | import Board from "./Board" 4 | import { ViewWrap } from "./index.styled" 5 | import ToolRing from "./ToolRing" 6 | import FavoriteTools from "./FavoriteTools" 7 | import Loading from "./Loading" 8 | import Dialog from "./Dialog" 9 | import MainMenu from "./MainMenu" 10 | import Notification from "./Notification" 11 | import Shortcuts from "./Shortcuts" 12 | import TextfieldSettings from "./TextfieldSettings" 13 | 14 | const View: React.FC = () => { 15 | useKeyboardShortcuts() 16 | useInitialDialog() 17 | useWindowResize() 18 | 19 | return ( 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default View 35 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Content/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { useGState } from "state" 3 | import { useLayerConfig } from "hooks" 4 | import { Canvas } from "../index.styled" 5 | import { useRender } from "./useRender" 6 | import { PageProps } from "../index.types" 7 | 8 | export const Content: React.FC = memo(({ page, pageOffset }) => { 9 | const canvasRef = React.useRef(null) 10 | const { layerConfig } = useGState("LayerConfig").view 11 | 12 | useLayerConfig(canvasRef) 13 | useRender(page, canvasRef) 14 | 15 | return ( 16 | 27 | ) 28 | }) 29 | -------------------------------------------------------------------------------- /src/state/drawing/serializers/__test__/stateV1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "tool": { 4 | "type": 2, 5 | "style": { "color": "#0211a3", "width": 1.5, "opacity": 0.5 } 6 | }, 7 | "pageMeta": { 8 | "size": { "width": 620, "height": 877 }, 9 | "background": { 10 | "paper": "blank" 11 | } 12 | }, 13 | "textfieldAttributes": { 14 | "color": "#000000", 15 | "font": "Lato, sans-serif", 16 | "fontSize": 16, 17 | "fontWeight": 400, 18 | "hAlign": "left", 19 | "lineHeight": 20, 20 | "text": "", 21 | "vAlign": "top" 22 | }, 23 | "favoriteTools": [ 24 | { 25 | "type": 1, 26 | "style": { "color": "#000000", "width": 2, "opacity": 1 } 27 | }, 28 | { 29 | "type": 1, 30 | "style": { "color": "#0211a3", "width": 3, "opacity": 1 } 31 | }, 32 | { "type": 1, "style": { "color": "#ff0000", "width": 4, "opacity": 1 } } 33 | ], 34 | "erasedStrokes": {} 35 | } 36 | -------------------------------------------------------------------------------- /src/state/drawing/state/default.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DEFAULT_COLOR, 3 | DEFAULT_FAV_TOOLS, 4 | DEFAULT_TOOL, 5 | DEFAULT_WIDTH, 6 | PAGE_SIZE, 7 | } from "consts" 8 | import { Paper } from "state/board/state/index.types" 9 | import { DrawingState } from "./index.types" 10 | 11 | export const getDefaultDrawingState = (): DrawingState => ({ 12 | tool: { 13 | type: DEFAULT_TOOL, 14 | latestDrawType: DEFAULT_TOOL, 15 | style: { 16 | color: DEFAULT_COLOR, 17 | width: DEFAULT_WIDTH, 18 | opacity: 1, 19 | }, 20 | }, 21 | textfieldAttributes: { 22 | text: "", 23 | color: "#000000", 24 | hAlign: "left", 25 | vAlign: "top", 26 | font: "Lato, sans-serif", 27 | fontWeight: 400, 28 | fontSize: 16, 29 | lineHeight: 16 * 1.25, 30 | }, 31 | pageMeta: { 32 | background: { paper: Paper.Blank }, 33 | size: PAGE_SIZE.A4_PORTRAIT, 34 | }, 35 | favoriteTools: DEFAULT_FAV_TOOLS, 36 | erasedStrokes: {}, 37 | }) 38 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Transformer/bounds.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "drawing/stroke/index.types" 2 | import { Page } from "state/board/state/index.types" 3 | 4 | export const applyBounds = (point: Point, page: Page) => { 5 | let newX = point.x 6 | let newY = point.y 7 | 8 | if (newX > page.meta.size.width) newX = page.meta.size.width 9 | if (newX < 0) newX = 0 10 | if (newY > page.meta.size.height) newY = page.meta.size.height 11 | if (newY < 0) newY = 0 12 | 13 | return { 14 | x: newX, 15 | y: newY, 16 | } 17 | } 18 | 19 | const SNAP_DISTANCE = 10 20 | 21 | export const applyLeaveBounds = (point: Point, page: Page) => { 22 | let newX = point.x 23 | let newY = point.y 24 | 25 | if (newX > page.meta.size.width - SNAP_DISTANCE) newX = page.meta.size.width 26 | if (newX < SNAP_DISTANCE) newX = 0 27 | if (newY > page.meta.size.height - SNAP_DISTANCE) 28 | newY = page.meta.size.height 29 | if (newY < SNAP_DISTANCE) newY = 0 30 | 31 | return { 32 | x: newX, 33 | y: newY, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/Session/SessionSettings/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { TickIcon } from "components" 3 | import { useGState } from "state" 4 | import { online } from "state/online" 5 | import { Session } from "state/online/state/index.types" 6 | import { FormattedMessage } from "language" 7 | import { SubMenuWrap } from "View/MainMenu/index.styled" 8 | import MenuItem from "View/MainMenu/MenuItem" 9 | 10 | const onClickReadOnly = (session: Session | undefined) => () => 11 | online.updateConfig({ readOnly: !session?.config?.readOnly }) 12 | 13 | const SessionSettingsMenu = () => { 14 | const { session } = useGState("Session").online 15 | 16 | return ( 17 | 18 | 21 | } 22 | onClick={onClickReadOnly(session)} 23 | icon={session?.config?.readOnly && } 24 | /> 25 | 26 | ) 27 | } 28 | export default SessionSettingsMenu 29 | -------------------------------------------------------------------------------- /src/View/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import React, { useCallback } from "react" 3 | import { loading } from "state/loading" 4 | import { useGState } from "state" 5 | import { DialogContent } from "components" 6 | import { StyledDialog, Dot, LoadingDots } from "./index.styled" 7 | 8 | const Loading: React.FC = () => { 9 | const { isLoading, messageId } = useGState("Loading").loading 10 | 11 | const onClose = useCallback(() => { 12 | // Abort loading animation on close 13 | loading.endLoading() 14 | }, []) 15 | 16 | return ( 17 | 18 | 19 |

20 | 21 |

22 | 23 | 24 | 25 | 26 | 27 |
28 |
29 | ) 30 | } 31 | 32 | export default Loading 33 | -------------------------------------------------------------------------------- /src/View/MainMenu/MenuItem/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ItemWrap, ItemButton, TextWrap } from "./index.styled" 3 | 4 | interface MenuItemProps extends React.ButtonHTMLAttributes { 5 | text: JSX.Element | string 6 | icon?: JSX.Element | null | boolean 7 | expandMenu?: () => void 8 | warning?: boolean 9 | } 10 | 11 | const MenuItem: React.FC = ({ 12 | text, 13 | icon, 14 | expandMenu, 15 | warning = false, 16 | onClick, 17 | children, 18 | ...restProps 19 | }) => { 20 | return ( 21 | 22 | { 26 | expandMenu?.() 27 | onClick?.(e) 28 | }} 29 | onMouseEnter={() => expandMenu?.()} 30 | > 31 | {text} 32 | {icon} 33 | 34 | {children} 35 | 36 | ) 37 | } 38 | 39 | export default MenuItem 40 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/General/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | import MenuItem from "View/MainMenu/MenuItem" 3 | 4 | const opacityGradient1 = 0.5 5 | const opacityGradient2 = 1 6 | 7 | export const AuthenticatedMenuItem = styled(MenuItem)` 8 | background: linear-gradient( 9 | 108deg, 10 | rgba(255, 215, 0, ${opacityGradient1}) 0%, 11 | rgba(255, 124, 30, ${opacityGradient1}) 20%, 12 | rgba(255, 61, 161, ${opacityGradient1}) 40%, 13 | rgba(255, 0, 247, ${opacityGradient1}) 60%, 14 | rgba(176, 0, 254, ${opacityGradient1}) 80%, 15 | rgba(82, 0, 254, ${opacityGradient1}) 100% 16 | ), 17 | linear-gradient( 18 | 45deg, 19 | rgba(255, 215, 0, ${opacityGradient2}) 0%, 20 | rgba(255, 124, 30, ${opacityGradient2}) 20%, 21 | rgba(255, 61, 161, ${opacityGradient2}) 40%, 22 | rgba(255, 0, 247, ${opacityGradient2}) 60%, 23 | rgba(176, 0, 254, ${opacityGradient2}) 80%, 24 | rgba(82, 0, 254, ${opacityGradient2}) 100% 25 | ); 26 | ` 27 | -------------------------------------------------------------------------------- /src/theme/themes.ts: -------------------------------------------------------------------------------- 1 | import { appleTheme } from "./options/apple" 2 | import { candyTheme } from "./options/candy" 3 | import { darkTheme } from "./options/dark" 4 | import { lightTheme } from "./options/light" 5 | import { oceanTheme } from "./options/ocean" 6 | import { orangeTheme } from "./options/orange" 7 | import { purpleTheme } from "./options/purple" 8 | import { soilTheme } from "./options/soil" 9 | import { sunTheme } from "./options/sun" 10 | import { tealTheme } from "./options/teal" 11 | 12 | export enum ThemeOption { 13 | Apple, 14 | Candy, 15 | Dark, 16 | Light, 17 | Ocean, 18 | Orange, 19 | Purple, 20 | Soil, 21 | Sun, 22 | Teal, 23 | } 24 | 25 | export const themes = { 26 | [ThemeOption.Apple]: appleTheme, 27 | [ThemeOption.Candy]: candyTheme, 28 | [ThemeOption.Dark]: darkTheme, 29 | [ThemeOption.Light]: lightTheme, 30 | [ThemeOption.Ocean]: oceanTheme, 31 | [ThemeOption.Orange]: orangeTheme, 32 | [ThemeOption.Purple]: purpleTheme, 33 | [ThemeOption.Soil]: soilTheme, 34 | [ThemeOption.Sun]: sunTheme, 35 | [ThemeOption.Teal]: tealTheme, 36 | } 37 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/General/Settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { TickIcon } from "components" 3 | import { FormattedMessage } from "language" 4 | import { settings } from "state/settings" 5 | import { useGState } from "state" 6 | import { SubMenuWrap } from "View/MainMenu/index.styled" 7 | import MenuItem from "View/MainMenu/MenuItem" 8 | 9 | const SettingsMenu = () => { 10 | const { keepCentered, directDraw } = useGState("Settings").settings 11 | 12 | return ( 13 | 14 | } 16 | onClick={() => settings.toggleShouldCenter()} 17 | icon={keepCentered && } 18 | /> 19 | 22 | } 23 | onClick={() => settings.toggleDirectDraw()} 24 | icon={directDraw && } 25 | /> 26 | 27 | ) 28 | } 29 | export default SettingsMenu 30 | -------------------------------------------------------------------------------- /.github/workflows/testing.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Testing 5 | 6 | on: 7 | pull_request: 8 | paths: 9 | - "src/**" 10 | - "public/**" 11 | - ".github/workflows/**" 12 | - ".env" 13 | - "package.json" 14 | - "yarn.lock" 15 | jobs: 16 | build: 17 | name: Lint, test and build 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: "16" # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 26 | cache: "yarn" 27 | - name: Install dependencies 28 | run: yarn --frozen-lockfile 29 | - name: Check with ESLint 30 | run: yarn lint 31 | - name: Run unit tests 32 | run: yarn test --watchAll=false 33 | - name: Build 34 | run: yarn build 35 | -------------------------------------------------------------------------------- /src/View/Loading/index.styled.ts: -------------------------------------------------------------------------------- 1 | import { Dialog } from "components" 2 | import styled from "styled-components" 3 | 4 | export const StyledDialog = styled(Dialog)` 5 | width: min(20rem, 90vw); 6 | ` 7 | 8 | export const LoadingDots = styled.div` 9 | margin-top: 2rem; 10 | display: flex; 11 | gap: 1rem; 12 | justify-content: center; 13 | ` 14 | export const Dot = styled.div<{ delay: string }>` 15 | color: ${({ theme }) => theme.palette.secondary.main}; 16 | background: ${({ theme }) => theme.palette.secondary.main}; 17 | box-shadow: 0 0 0.2rem 0; 18 | width: 0.5rem; 19 | height: 0.5rem; 20 | border-radius: 100%; 21 | animation-name: loading-animation; 22 | animation-duration: 1.5s; 23 | animation-delay: ${({ delay }) => delay}; 24 | animation-iteration-count: infinite; 25 | animation-direction: alternate; 26 | animation-timing-function: ease-in-out; 27 | 28 | @keyframes loading-animation { 29 | 0% { 30 | opacity: 0; 31 | } 32 | 40% { 33 | opacity: 0; 34 | } 35 | 100% { 36 | opacity: 1; 37 | } 38 | } 39 | ` 40 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/PageButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import React, { memo } from "react" 3 | import { useGState } from "state" 4 | import { view } from "state/view" 5 | import { MainMenuButton } from "../index.styled" 6 | 7 | const PageButton: React.FC> = 8 | memo((props) => { 9 | const { pageRank } = useGState("MenuPageButton").board 10 | 11 | const currentPage = view.getPageIndex() + 1 12 | const numberPages = pageRank.length 13 | 14 | return ( 15 | 20 | {numberPages > 0 ? ( 21 | 25 | ) : ( 26 | 27 | )} 28 | 29 | ) 30 | }) 31 | export default PageButton 32 | -------------------------------------------------------------------------------- /src/state/loading/state/index.ts: -------------------------------------------------------------------------------- 1 | import { IntlMessageId } from "language" 2 | import { subscriptionState } from "state/subscription" 3 | import { GlobalState } from "state/types" 4 | import { LoadingState } from "./index.types" 5 | 6 | export class Loading implements GlobalState { 7 | state: LoadingState = { 8 | isLoading: false, 9 | messageId: "Loading.ExportingPdf", 10 | } 11 | 12 | getState(): LoadingState { 13 | return this.state 14 | } 15 | 16 | setState(newState: LoadingState) { 17 | this.state = newState 18 | return this 19 | } 20 | 21 | /** 22 | * Open a dialog with a loading animation and a specified text 23 | * @param messageId intl message id of the loading text 24 | */ 25 | startLoading(messageId: IntlMessageId): void { 26 | this.setState({ isLoading: true, messageId }) 27 | subscriptionState.render("Loading") 28 | } 29 | 30 | /** 31 | * Close the loading dialog 32 | */ 33 | endLoading(): void { 34 | this.state.isLoading = false 35 | subscriptionState.render("Loading") 36 | } 37 | } 38 | 39 | export const loading = new Loading() 40 | -------------------------------------------------------------------------------- /src/components/Button/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | interface Props { 4 | $withIcon: boolean 5 | } 6 | 7 | export const StyledButton = styled.button` 8 | display: flex; 9 | align-items: center; 10 | cursor: pointer; 11 | color: ${({ theme }) => theme.palette.secondary.contrastText}; 12 | background: ${({ theme }) => theme.palette.secondary.main}; 13 | margin: 6px 0; 14 | padding: 6px 1.5rem; 15 | border-width: 0; 16 | border-radius: ${({ theme }) => theme.borderRadius}; 17 | transition: all 100ms ease-in-out; 18 | box-shadow: ${({ theme }) => theme.boxShadow}; 19 | height: min-content; 20 | width: 100%; 21 | 22 | &:hover { 23 | filter: brightness(120%); 24 | } 25 | 26 | &:disabled { 27 | cursor: not-allowed; 28 | filter: brightness(40%); 29 | } 30 | 31 | ${({ $withIcon }) => ($withIcon ? iconStyle : noIconStyle)}; 32 | 33 | svg { 34 | height: 1rem; 35 | width: 1rem; 36 | } 37 | ` 38 | 39 | const iconStyle = css` 40 | gap: 1rem; 41 | ` 42 | 43 | const noIconStyle = css` 44 | display: inline-block; 45 | text-align: center; 46 | ` 47 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/View/GoTo/index.tsx: -------------------------------------------------------------------------------- 1 | import { HorizontalRule } from "components" 2 | import { FormattedMessage } from "language" 3 | import React from "react" 4 | import { view } from "state/view" 5 | import { SubMenuWrap } from "View/MainMenu/index.styled" 6 | import MenuItem from "View/MainMenu/MenuItem" 7 | 8 | const GoToMenu = () => { 9 | return ( 10 | 11 | } 13 | onClick={() => view.jumpToPrevPage()} 14 | /> 15 | } 17 | onClick={() => view.jumpToNextPage()} 18 | /> 19 | 20 | } 22 | onClick={() => view.jumpToFirstPage()} 23 | /> 24 | } 26 | onClick={() => view.jumpToLastPage()} 27 | /> 28 | 29 | ) 30 | } 31 | export default GoToMenu 32 | -------------------------------------------------------------------------------- /src/hooks/useLayerConfig.ts: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | import { Page, Paper } from "state/board/state/index.types" 3 | import { view } from "state/view" 4 | import { drawBackground } from "util/render/backgrounds" 5 | 6 | export const useLayerConfig = ( 7 | canvasRef: React.RefObject, 8 | page?: Page 9 | ) => { 10 | useEffect(() => { 11 | const canvas = canvasRef.current 12 | if (!canvas) return 13 | 14 | const ctx = canvas.getContext("2d") 15 | if (!ctx) return 16 | 17 | ctx.clearRect(0, 0, canvas.width, canvas.height) 18 | 19 | if (!page) { 20 | ctx.setTransform(1, 0, 0, 1, 0, 0) // reset last transform 21 | const { pixelScale } = view.getState().layerConfig 22 | ctx.scale(pixelScale, pixelScale) 23 | } else { 24 | if (page.meta.background.paper !== Paper.Doc) { 25 | ctx.setTransform(1, 0, 0, 1, 0, 0) // reset last transform 26 | const { pixelScale } = view.getState().layerConfig 27 | ctx.scale(pixelScale, pixelScale) 28 | } 29 | 30 | drawBackground(ctx, page.meta) 31 | } 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /src/state/view/util/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Point } from "drawing/stroke/index.types" 2 | import { ViewState, ViewTransform } from "state/view/state/index.types" 3 | 4 | type GetViewCenterXProps = { 5 | viewTransform: ViewTransform 6 | } & Pick 7 | 8 | export const getViewCenterX = ({ 9 | viewTransform, 10 | innerWidth, 11 | }: GetViewCenterXProps): number => 12 | applyTransform1D(innerWidth / 2, viewTransform.scale, viewTransform.xOffset) 13 | 14 | type GetViewCenterYProps = { 15 | viewTransform: ViewTransform 16 | } & Pick 17 | 18 | export const getViewCenterY = ({ 19 | viewTransform, 20 | innerHeight, 21 | }: GetViewCenterYProps): number => 22 | applyTransform1D( 23 | innerHeight / 2, 24 | viewTransform.scale, 25 | viewTransform.yOffset 26 | ) 27 | 28 | export const applyTransform1D = (x: number, scale: number, offset: number) => 29 | x - scale * offset 30 | 31 | export const applyTransformToPoint = ( 32 | point: Point, 33 | transform: ViewTransform 34 | ): Point => ({ 35 | x: applyTransform1D(point.x, transform.scale, transform.xOffset), 36 | y: applyTransform1D(point.y, transform.scale, transform.yOffset), 37 | }) 38 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | boardsite.io 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/General/Edit/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { FormattedMessage } from "language" 3 | import { useGState } from "state" 4 | import { RedoIcon, UndoIcon } from "components" 5 | import { SubMenuWrap } from "View/MainMenu/index.styled" 6 | import MenuItem from "View/MainMenu/MenuItem" 7 | import { action } from "state/action" 8 | 9 | const EditMenu = () => { 10 | const { undoStack, redoStack } = useGState("EditMenu").action 11 | const disableUndoStack = undoStack.length === 0 12 | const disableRedoStack = redoStack.length === 0 13 | 14 | const undo = () => { 15 | action.undo() 16 | } 17 | 18 | const redo = () => { 19 | action.redo() 20 | } 21 | 22 | return ( 23 | 24 | } 27 | icon={} 28 | onClick={undo} 29 | /> 30 | } 33 | icon={} 34 | onClick={redo} 35 | /> 36 | 37 | ) 38 | } 39 | 40 | export default EditMenu 41 | -------------------------------------------------------------------------------- /src/View/Dialog/OnlineLeave/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { FormattedMessage } from "language" 3 | import { Button, DialogContent, DialogTitle } from "components" 4 | import { online } from "state/online" 5 | import { notification } from "state/notification" 6 | 7 | const OnlineLeave: React.FC = () => { 8 | const leaveSession = useCallback(() => { 9 | online.disconnect() 10 | notification.create("Notification.Session.Leave", 3000) 11 | }, []) 12 | 13 | return ( 14 | <> 15 | 16 | 17 | 18 | 19 |

20 | {Object.keys(online.getState().session.users ?? {}) 21 | .length === 1 ? ( 22 | 23 | ) : ( 24 | 25 | )} 26 |

27 | 30 |
31 | 32 | ) 33 | } 34 | 35 | export default OnlineLeave 36 | -------------------------------------------------------------------------------- /src/View/Dialog/InitialSelection/index.styled.ts: -------------------------------------------------------------------------------- 1 | import { Button } from "components" 2 | import styled, { css } from "styled-components" 3 | 4 | export const Presets = styled.div` 5 | ${({ theme }) => css` 6 | margin: 6px 0; 7 | display: flex; 8 | align-items: center; 9 | box-shadow: ${theme.boxShadow}; 10 | border-radius: ${theme.borderRadius}; 11 | 12 | button:first-child { 13 | border-top-left-radius: ${theme.borderRadius}; 14 | border-bottom-left-radius: ${theme.borderRadius}; 15 | } 16 | button:last-child { 17 | border-top-right-radius: ${theme.borderRadius}; 18 | border-bottom-right-radius: ${theme.borderRadius}; 19 | } 20 | `} 21 | ` 22 | 23 | export const CreateButtons = styled.div` 24 | display: grid; 25 | gap: 6px; 26 | grid-template-columns: repeat(2, 1fr); 27 | ` 28 | 29 | export const PresetButton = styled(Button)<{ active: boolean }>` 30 | :focus { 31 | z-index: 1; /* prevent other buttons from overlapping focus outline */ 32 | } 33 | margin: 0; 34 | padding: 6px; 35 | border-radius: 0; 36 | box-shadow: none; 37 | ${({ active }) => 38 | active 39 | ? css` 40 | filter: brightness(140%); 41 | ` 42 | : undefined} 43 | ` 44 | -------------------------------------------------------------------------------- /src/drawing/helpers/mocks.ts: -------------------------------------------------------------------------------- 1 | import { BoardState, Paper } from "state/board/state/index.types" 2 | 3 | export const MOCK_STROKE_ID_1 = "2vl2xtzkgldbsatk" 4 | export const MOCK_PAGE_ID_1 = "jtWj1Y7e" 5 | export const MOCK_ID_2 = "rtzuixxy" 6 | 7 | export const MOCK_PAGE_1 = { 8 | pageId: MOCK_PAGE_ID_1, 9 | strokes: { 10 | [MOCK_STROKE_ID_1]: { 11 | id: [MOCK_STROKE_ID_1], 12 | type: 1, 13 | style: { color: "#000000", width: 3, opacity: 1 }, 14 | pageId: MOCK_PAGE_ID_1, 15 | x: 0, 16 | y: 0, 17 | scaleX: 1, 18 | scaleY: 1, 19 | points: [ 20 | 196, 180, 196, 179.5, 196.94, 179.03, 197.47, 179.02, 21 | 197.73000000000002, 178.51, 199, 178, 202, 178, 211.5, 178, 214, 22 | 178, 214.5, 178.5, 215.69, 178.94, 217, 180, 217, 182, 23 | ], 24 | }, 25 | }, 26 | meta: { 27 | background: { 28 | paper: Paper.Blank, 29 | attachId: "", 30 | documentPageNum: 0, 31 | }, 32 | size: { 33 | width: 620, 34 | height: 877, 35 | }, 36 | }, 37 | } 38 | 39 | export const mockBoardState1 = { 40 | pageCollection: { 41 | [MOCK_PAGE_ID_1]: MOCK_PAGE_1, 42 | }, 43 | } as unknown as BoardState 44 | -------------------------------------------------------------------------------- /src/storage/util/index.ts: -------------------------------------------------------------------------------- 1 | import { IntlMessageId } from "language" 2 | import { loading } from "state/loading" 3 | 4 | export const readFileAsUint8Array = async (file: File): Promise => 5 | new Promise((resolve, reject) => { 6 | const fileReader = new FileReader() 7 | fileReader.onloadend = () => { 8 | if (fileReader.readyState === FileReader.DONE) { 9 | resolve(new Uint8Array(fileReader.result as ArrayBuffer)) 10 | } 11 | } 12 | fileReader.onerror = (err) => { 13 | reject(err) 14 | } 15 | fileReader.readAsArrayBuffer(file) 16 | }) 17 | 18 | export const startBackgroundJob = async ( 19 | userMessage: IntlMessageId, 20 | job: () => Promise 21 | ) => { 22 | loading.startLoading(userMessage) 23 | return new Promise((resolve, reject) => { 24 | // For some reason the loading dialog doesnt show if the pdf render 25 | // isnt delayed a little. Probably the state updates are combined and 26 | // then the pdf render blocks any updates until its finished. 27 | setTimeout(async () => { 28 | try { 29 | const value = await job() 30 | resolve(value) 31 | } catch (err) { 32 | reject(err) 33 | } 34 | loading.endLoading() 35 | }, 50) 36 | }) 37 | } 38 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/Session/UserOptions/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { online } from "state/online" 3 | import { notification } from "state/notification" 4 | import { useGState } from "state" 5 | import { FormattedMessage } from "language" 6 | import { SubMenuWrap } from "View/MainMenu/index.styled" 7 | import MenuItem from "View/MainMenu/MenuItem" 8 | 9 | type UserOptionsProps = { 10 | userId: string 11 | isHost: boolean 12 | userIsYou: boolean 13 | } 14 | 15 | const UserOptions = ({ userId, isHost, userIsYou }: UserOptionsProps) => { 16 | useGState("Session") 17 | 18 | const onKickUser = useCallback(async () => { 19 | try { 20 | await online.kickUser({ id: userId }) 21 | } catch (error) { 22 | notification.create("Notification.Session.KickUserFailed", 3000) 23 | } 24 | }, [userId]) 25 | 26 | return ( 27 | 28 | 33 | } 34 | onClick={onKickUser} 35 | /> 36 | {/* TODO: Change user name / color, give admin rights, etc */} 37 | 38 | ) 39 | } 40 | export default UserOptions 41 | -------------------------------------------------------------------------------- /src/state/subscription/index.types.ts: -------------------------------------------------------------------------------- 1 | import { PageId } from "state/board/state/index.types" 2 | 3 | export type Subscription = 4 | // Board 5 | | "RenderNG" 6 | | "MenuPageButton" 7 | | "EditMenu" 8 | | "SettingsMenu" 9 | // Drawing 10 | | "PageBackgroundSetting" 11 | | "PageSizeSetting" 12 | | "ActiveTool" 13 | | "ColorPicker" 14 | | "WidthPicker" 15 | | "ToolRing" 16 | | "FavoriteTools" 17 | | "ShapeTools" 18 | | "PageSizeMenu" 19 | | "PageStyleMenu" 20 | | "UseViewControl" 21 | | "UseLiveStroke" 22 | | "PageContent" 23 | // Loading 24 | | "Loading" 25 | // Menu 26 | | "DialogState" 27 | | "MainMenu" 28 | | "ShortcutsOpen" 29 | | "TextfieldSettings" 30 | | "SubscribeOpen" 31 | // Notification 32 | | "Notification" 33 | // Online 34 | | "Session" 35 | // Settings 36 | | "Theme" 37 | | "Settings" 38 | // View 39 | | "LayerConfig" 40 | | "ViewTransform" 41 | // Textfield 42 | | "Textfield" 43 | 44 | export type RenderTrigger = React.Dispatch> 45 | export type Subscribers = Record 46 | 47 | export type PageLayer = "background" | "content" | "transformer" 48 | export type PageLayerTriggers = Record 49 | export type PageSubscribers = Record< 50 | PageId, 51 | Partial | undefined 52 | > 53 | -------------------------------------------------------------------------------- /src/state/subscription/useGState.ts: -------------------------------------------------------------------------------- 1 | // Global States 2 | import { action } from "state/action" 3 | import { board } from "state/board" 4 | import { drawing } from "state/drawing" 5 | import { loading } from "state/loading" 6 | import { menu } from "state/menu" 7 | import { notification } from "state/notification" 8 | import { online } from "state/online" 9 | import { settings } from "state/settings" 10 | import { view } from "state/view" 11 | 12 | import { subscriptionState } from "state/subscription" 13 | import { useCallback, useEffect, useState } from "react" 14 | import { Subscription } from "./index.types" 15 | 16 | const getGlobalState = () => ({ 17 | action: action.getState(), 18 | board: board.getState(), 19 | drawing: drawing.getState(), 20 | loading: loading.getState(), 21 | menu: menu.getState(), 22 | notification: notification.getState(), 23 | online: online.getState(), 24 | settings: settings.getState(), 25 | view: view.getState(), 26 | }) 27 | 28 | export const useGState = (subscription: Subscription) => { 29 | const [, render] = useState({}) 30 | const trigger = useCallback(() => render({}), []) 31 | 32 | useEffect(() => { 33 | subscriptionState.subscribe(subscription, trigger) 34 | 35 | return () => { 36 | subscriptionState.unsubscribe(subscription, trigger) 37 | } 38 | }, [subscription, trigger]) 39 | 40 | return getGlobalState() 41 | } 42 | -------------------------------------------------------------------------------- /src/View/MainMenu/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { Popup } from "components" 3 | import { menu } from "state/menu" 4 | import { MainMenuState } from "state/menu/state/index.types" 5 | import { useGState } from "state" 6 | import { MainMenuDropdown } from "./index.styled" 7 | import ViewMenu from "./menu/View" 8 | import PageMenu from "./menu/Page" 9 | import MainMenuBar from "./MainMenuBar" 10 | import GeneralMenu from "./menu/General" 11 | import SessionMenu from "./menu/Session" 12 | 13 | const onClickBackground = () => { 14 | menu.closeMainMenu() 15 | } 16 | 17 | const MainMenu: React.FC = memo(() => { 18 | const { mainMenuState } = useGState("MainMenu").menu 19 | 20 | return ( 21 | <> 22 | 23 | 27 | 28 | {mainMenuState === MainMenuState.General && } 29 | {mainMenuState === MainMenuState.View && } 30 | {mainMenuState === MainMenuState.Page && } 31 | {mainMenuState === MainMenuState.Session && } 32 | 33 | 34 | 35 | ) 36 | }) 37 | 38 | export default MainMenu 39 | -------------------------------------------------------------------------------- /src/state/notification/state/index.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_NOTIFICATION_DURATION } from "consts" 2 | import { IntlMessageId } from "language" 3 | import { subscriptionState } from "state/subscription" 4 | import { GlobalState } from "state/types" 5 | import { NotificationState } from "./index.types" 6 | 7 | export class Notification implements GlobalState { 8 | state: NotificationState = { notifications: [] } 9 | 10 | getState(): NotificationState { 11 | return this.state 12 | } 13 | 14 | setState(newState: NotificationState) { 15 | this.state = newState 16 | return this 17 | } 18 | 19 | /** 20 | * Create a notification with a specified duration 21 | * @param id intl message id of notification message 22 | * @param duration notification duration in milliseconds 23 | */ 24 | create(id: IntlMessageId, duration = DEFAULT_NOTIFICATION_DURATION): void { 25 | this.addNotification(id) 26 | 27 | setTimeout(() => { 28 | this.removeNotification() 29 | }, duration) 30 | } 31 | 32 | private addNotification(id: IntlMessageId): void { 33 | this.state.notifications.unshift(id) 34 | subscriptionState.render("Notification") 35 | } 36 | 37 | private removeNotification(): void { 38 | this.state.notifications.pop() 39 | subscriptionState.render("Notification") 40 | } 41 | } 42 | 43 | export const notification = new Notification() 44 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/WidthPicker/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import React, { memo } from "react" 3 | import { drawing } from "state/drawing" 4 | import { useGState } from "state" 5 | import { STROKE_WIDTH_PRESETS } from "consts" 6 | import { Position, ToolTip } from "components" 7 | import { nanoid } from "nanoid" 8 | import { Preset, WidthPresetInnerDot, WidthPresets } from "./index.styled" 9 | 10 | const WidthPicker: React.FC = memo(() => { 11 | const { width } = useGState("WidthPicker").drawing.tool.style 12 | 13 | return ( 14 | 15 | {STROKE_WIDTH_PRESETS.map((strokeWidth) => ( 16 | 24 | } 25 | > 26 | drawing.setWidth(strokeWidth)} 29 | > 30 | 31 | 32 | 33 | ))} 34 | 35 | ) 36 | }) 37 | 38 | export default WidthPicker 39 | -------------------------------------------------------------------------------- /config/jest/fileTransform.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | const path = require("path") 4 | const camelcase = require("camelcase") 5 | 6 | // This is a custom Jest transformer turning file imports into filenames. 7 | // http://facebook.github.io/jest/docs/en/webpack.html 8 | 9 | module.exports = { 10 | process(src, filename) { 11 | const assetFilename = JSON.stringify(path.basename(filename)) 12 | 13 | if (filename.match(/\.svg$/)) { 14 | // Based on how SVGR generates a component name: 15 | // https://github.com/smooth-code/svgr/blob/01b194cf967347d43d4cbe6b434404731b87cf27/packages/core/src/state.js#L6 16 | const pascalCaseFilename = camelcase(path.parse(filename).name, { 17 | pascalCase: true, 18 | }) 19 | const componentName = `Svg${pascalCaseFilename}` 20 | return `const React = require('react'); 21 | module.exports = { 22 | __esModule: true, 23 | default: ${assetFilename}, 24 | ReactComponent: React.forwardRef(function ${componentName}(props, ref) { 25 | return { 26 | $$typeof: Symbol.for('react.element'), 27 | type: 'svg', 28 | ref: ref, 29 | key: null, 30 | props: Object.assign({}, props, { 31 | children: ${assetFilename} 32 | }) 33 | }; 34 | }), 35 | };` 36 | } 37 | 38 | return `module.exports = ${assetFilename};` 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /src/View/Notification/index.styled.ts: -------------------------------------------------------------------------------- 1 | import { NOTIFICATION_TRANSITION } from "consts" 2 | import styled, { css } from "styled-components" 3 | 4 | export const NotificationWrap = styled.div` 5 | ${({ theme }) => css` 6 | z-index: ${theme.zIndex.notifications}; 7 | pointer-events: none; 8 | position: fixed; 9 | top: 3rem; 10 | right: 50%; 11 | max-width: 80vw; 12 | display: flex; 13 | flex-direction: column; 14 | gap: 0; 15 | box-shadow: ${theme.toolbar.boxShadow}; 16 | color: ${theme.palette.primary.contrastText}; 17 | background: ${theme.palette.primary.main}; 18 | border-radius: ${theme.borderRadius}; 19 | transition: all ${NOTIFICATION_TRANSITION}ms ease-in-out; 20 | transform-origin: center; 21 | transform: translate(50%, 0%); 22 | 23 | &.notification-enter { 24 | opacity: 0; 25 | } 26 | 27 | &.notification-enter-active { 28 | opacity: 1; 29 | } 30 | 31 | &.notification-exit { 32 | opacity: 1; 33 | transform: translate(50%, 0%); 34 | } 35 | 36 | &.notification-exit-active { 37 | opacity: 0; 38 | transform: translate(50%, -300%); 39 | } 40 | `} 41 | ` 42 | 43 | export const Message = styled.p` 44 | ${({ theme }) => css` 45 | padding: ${theme.menuButton.padding}; 46 | margin: ${theme.menuButton.margin}; 47 | `} 48 | ` 49 | -------------------------------------------------------------------------------- /src/components/FormikInput/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const FormikLabel = styled.label<{ 4 | textAlign: "left" | "center" 5 | fullWidth?: boolean 6 | }>` 7 | display: flex; 8 | flex-direction: column; 9 | margin: 0.5rem 0; 10 | color: ${({ theme }) => theme.palette.primary.contrastText}AA; 11 | 12 | ${({ fullWidth }) => 13 | fullWidth && 14 | css` 15 | width: 100%; 16 | `} 17 | ${({ textAlign }) => 18 | textAlign && 19 | css` 20 | text-align: ${textAlign}; 21 | 22 | ${Input} { 23 | text-align: ${textAlign}; 24 | } 25 | `} 26 | ` 27 | 28 | export const Input = styled.input<{ isValid: boolean }>` 29 | padding: 5px 0; 30 | color: ${({ theme }) => theme.palette.primary.contrastText}; 31 | background: transparent; 32 | outline: none; 33 | border: none; 34 | border-bottom: 1px solid; 35 | border-radius: 0; 36 | border-color: ${({ isValid, theme }) => 37 | isValid 38 | ? theme.palette.secondary.main 39 | : theme.palette.primary.contrastText}; 40 | 41 | :hover { 42 | cursor: text; 43 | } 44 | 45 | :disabled { 46 | filter: opacity(40%); 47 | cursor: not-allowed; 48 | } 49 | ` 50 | 51 | export const ValidationError = styled.span` 52 | color: ${({ theme }) => theme.palette.common.warning}; 53 | font-size: small; 54 | ` 55 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/ColorPicker/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | import { HexColorPicker } from "react-colorful" 3 | 4 | export const CustomColorPicker = styled(HexColorPicker)` 5 | ${({ theme }) => css` 6 | /* Override the 200px height and width */ 7 | width: auto !important; 8 | height: auto !important; 9 | flex-grow: 1; 10 | 11 | .react-colorful__saturation-pointer, 12 | .react-colorful__alpha-pointer, 13 | .react-colorful__hue-pointer { 14 | border: 2px solid; 15 | border-color: ${theme.palette.primary.contrastText}; 16 | } 17 | 18 | .react-colorful__saturation-pointer, 19 | .react-colorful__alpha-pointer { 20 | width: ${theme.colorPicker.hue.width}; 21 | height: ${theme.colorPicker.hue.width}; 22 | border-radius: 50%; 23 | } 24 | 25 | .react-colorful__hue-pointer { 26 | width: ${theme.colorPicker.hue.width}; 27 | height: ${theme.colorPicker.hue.height}; 28 | border-radius: calc(${theme.colorPicker.hue.width} / 2); 29 | } 30 | 31 | .react-colorful__hue { 32 | height: ${theme.colorPicker.hue.height}; 33 | border-radius: 0 0 ${theme.borderRadius} ${theme.borderRadius}; 34 | &:active { 35 | /* override component style */ 36 | height: ${theme.colorPicker.hue.height}; 37 | } 38 | } 39 | `} 40 | ` 41 | -------------------------------------------------------------------------------- /src/state/view/serializers/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { cloneDeep } from "lodash" 3 | import { VIEW_VERSION } from "." 4 | import { View } from "../state" 5 | import { getDefaultViewState } from "../state/default" 6 | import { SerializedViewState } from "../state/index.types" 7 | import stateV1 from "./__test__/stateV1.json" 8 | 9 | describe("board reducer state", () => { 10 | it("should serialize the default state", () => { 11 | const got = new View().serialize() 12 | const want: SerializedViewState = { 13 | version: VIEW_VERSION, 14 | pageIndex: getDefaultViewState().pageIndex, 15 | } 16 | expect(got).toStrictEqual(want) 17 | }) 18 | 19 | it("should deserialize the state version 1.0", async () => { 20 | const boardState = await new View().deserialize( 21 | cloneDeep(stateV1) 22 | ) 23 | const got = new View().setState(boardState).serialize() 24 | const want = stateV1 25 | 26 | expect(got).toStrictEqual(want) 27 | }) 28 | 29 | it("throws an error for unknown or missing version", async () => { 30 | await expect(new View().deserialize({} as any)).rejects.toThrow( 31 | "cannot deserialize state, missing version" 32 | ) 33 | 34 | await expect( 35 | new View().deserialize({ version: "0.1" } as any) 36 | ).rejects.toThrow("cannot deserialize state, unknown version 0.1") 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Background/index.tsx: -------------------------------------------------------------------------------- 1 | import { MAX_PIXEL_SCALE } from "consts" 2 | import React, { memo } from "react" 3 | import { useLayerConfig } from "hooks" 4 | import { useGState, usePageLayer } from "state" 5 | import { Paper } from "state/board/state/index.types" 6 | import { isEqual } from "lodash" 7 | import { PageProps } from "../index.types" 8 | import { CanvasBG } from "./index.styled" 9 | 10 | const backgroundEq = (prev: PageProps, next: PageProps) => { 11 | return ( 12 | isEqual(prev.page.meta, next.page.meta) && 13 | isEqual(prev.pageOffset, next.pageOffset) 14 | ) 15 | } 16 | 17 | export const Background: React.FC = memo(({ page, pageOffset }) => { 18 | const canvasRef = React.useRef(null) 19 | const { layerConfig } = useGState("LayerConfig").view 20 | const pxlScale = 21 | page.meta.background.paper === Paper.Doc 22 | ? MAX_PIXEL_SCALE 23 | : layerConfig.pixelScale 24 | 25 | useLayerConfig(canvasRef, page) 26 | usePageLayer("background", page.pageId) 27 | 28 | return ( 29 | 40 | ) 41 | }, backgroundEq) 42 | -------------------------------------------------------------------------------- /src/drawing/hitbox/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Stroke } from "drawing/stroke/index.types" 2 | import { getEllipseOutline, getTransformedPoints } from "." 3 | 4 | describe("getEllipseOutline", () => { 5 | it("returns the correct outline points for a circle", () => { 6 | const e = { x: 5, y: 5, rx: 1, ry: 1, segmentsPerQuarter: 2 } 7 | 8 | const outlinePoints = getEllipseOutline(e) 9 | const offset = Math.sqrt(e.rx / 2) // Since we are testing a circle 10 | 11 | expect(outlinePoints.length).toEqual(8) 12 | expect(outlinePoints).toEqual([ 13 | { x: e.x + e.rx, y: e.y }, 14 | { x: e.x + offset, y: e.y + offset }, 15 | { x: e.x, y: e.y + e.ry }, 16 | { x: e.x - offset, y: e.y + offset }, 17 | { x: e.x - e.rx, y: e.y }, 18 | { x: e.x - offset, y: e.y - offset }, 19 | { x: e.x, y: e.y - e.ry }, 20 | { x: e.x + offset, y: e.y - offset }, 21 | ]) 22 | }) 23 | }) 24 | 25 | describe("getTransformedPoints", () => { 26 | it("applies the transform correctly", () => { 27 | const stroke = { 28 | x: -5, 29 | y: 10, 30 | scaleX: 1, 31 | scaleY: 0.5, 32 | points: [0, 0, -5, -5, 10, 10], 33 | } 34 | 35 | const transformedPoints = getTransformedPoints(stroke as Stroke) 36 | 37 | expect(transformedPoints.length).toEqual(stroke.points.length) 38 | expect(transformedPoints).toEqual([-5, 5, -10, 2.5, 5, 10]) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /src/storage/local/index.ts: -------------------------------------------------------------------------------- 1 | import localforage from "localforage" 2 | import { debounce } from "lodash" 3 | 4 | const NAMESPACE = "boardsite" 5 | const DEBOUNCE_LOCAL_STORAGE = 500 6 | const DEBOUNCE_INDEXED_DB = 500 7 | 8 | localforage.config({ 9 | name: NAMESPACE, 10 | driver: [localforage.INDEXEDDB, localforage.LOCALSTORAGE], 11 | }) 12 | 13 | type StateInLocalStorage = "drawing" | "online" | "settings" | "view" 14 | 15 | type StateInIndexedDB = "board" 16 | 17 | export const saveLocalStorage = debounce( 18 | (name: StateInLocalStorage, serializer: () => object): void => { 19 | localStorage.setItem( 20 | `${NAMESPACE}_${name}`, 21 | JSON.stringify(serializer()) 22 | ) 23 | }, 24 | DEBOUNCE_LOCAL_STORAGE 25 | ) 26 | 27 | export const saveIndexedDB = debounce( 28 | async (name: StateInIndexedDB, serializer: () => object): Promise => { 29 | await localforage.setItem(`${NAMESPACE}_${name}`, serializer()) 30 | }, 31 | DEBOUNCE_INDEXED_DB 32 | ) 33 | 34 | export const loadLocalStorage = async ( 35 | name: StateInLocalStorage 36 | ): Promise => { 37 | const data = localStorage.getItem(`${NAMESPACE}_${name}`) 38 | if (!data) return undefined 39 | return JSON.parse(data) as T 40 | } 41 | 42 | export const loadIndexedDB = async ( 43 | name: StateInIndexedDB 44 | ): Promise => { 45 | const data = await localforage.getItem(`${NAMESPACE}_${name}`) 46 | if (!data) return undefined 47 | return data as T 48 | } 49 | -------------------------------------------------------------------------------- /src/View/MainMenu/MainMenuBar/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const MainMenuBarWrap = styled.nav` 4 | ${({ theme }) => css` 5 | z-index: ${theme.zIndex.mainMenu}; 6 | position: fixed; 7 | display: flex; 8 | align-items: center; 9 | justify-content: center; 10 | margin-top: ${theme.toolbar.margin}; 11 | margin-left: ${theme.toolbar.margin}; 12 | box-shadow: ${theme.toolbar.boxShadow}; 13 | padding: ${theme.toolbar.padding}; 14 | top: 0; 15 | left: 0; 16 | background: ${theme.palette.primary.main}; 17 | border-radius: ${theme.borderRadius}; 18 | `} 19 | ` 20 | 21 | export const MainMenuButton = styled.button` 22 | ${({ theme }) => css` 23 | cursor: pointer; 24 | position: relative; 25 | display: flex; 26 | align-items: center; 27 | justify-content: center; 28 | border: none; 29 | color: ${theme.palette.primary.contrastText}; 30 | background: ${theme.palette.primary.main}; 31 | border-radius: ${theme.borderRadius}; 32 | width: fit-content; 33 | height: ${theme.iconButton.size}; 34 | margin: ${theme.iconButton.margin}; 35 | padding: 0 0.2rem; 36 | transition: all 100ms ease-in-out; 37 | 38 | &:hover { 39 | filter: ${theme.menuButton.hoverFilter}; 40 | } 41 | 42 | svg { 43 | height: 80%; 44 | width: 80%; 45 | } 46 | `} 47 | ` 48 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/Page/PageSize/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { FormattedMessage } from "language" 3 | import { TickIcon } from "components" 4 | import { PAGE_SIZE } from "consts" 5 | import { drawing } from "state/drawing" 6 | import { PageSize } from "state/board/state/index.types" 7 | import MenuItem from "View/MainMenu/MenuItem" 8 | import { SubMenuWrap } from "View/MainMenu/index.styled" 9 | import { useGState } from "state" 10 | 11 | const onClickA4landscape = () => { 12 | drawing.setPageSize(PAGE_SIZE.A4_LANDSCAPE) 13 | } 14 | const onClickA4portrait = () => { 15 | drawing.setPageSize(PAGE_SIZE.A4_PORTRAIT) 16 | } 17 | 18 | const PageSizeMenu = () => { 19 | const { width, height } = useGState("PageSizeMenu").drawing.pageMeta.size 20 | 21 | const isMatch = useCallback( 22 | (size: PageSize) => width === size.width && height === size.height, 23 | [width, height] 24 | ) 25 | 26 | return ( 27 | 28 | } 30 | icon={isMatch(PAGE_SIZE.A4_PORTRAIT) && } 31 | onClick={onClickA4portrait} 32 | /> 33 | } 35 | icon={isMatch(PAGE_SIZE.A4_LANDSCAPE) && } 36 | onClick={onClickA4landscape} 37 | /> 38 | 39 | ) 40 | } 41 | export default PageSizeMenu 42 | -------------------------------------------------------------------------------- /src/state/drawing/serializers/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { cloneDeep } from "lodash" 3 | import { CURRENT_DRAWING_VERSION } from "." 4 | import { getDefaultDrawingState } from "../state/default" 5 | import stateV1 from "./__test__/stateV1.json" 6 | import { SerializedDrawingState } from "../state/index.types" 7 | import { Drawing } from "../state" 8 | 9 | describe("board reducer state", () => { 10 | it("should serialize the default state", () => { 11 | const got = new Drawing().serialize() 12 | const want = { 13 | version: CURRENT_DRAWING_VERSION, 14 | ...getDefaultDrawingState(), 15 | } 16 | 17 | expect(got).toStrictEqual(want) 18 | }) 19 | 20 | it("should deserialize the state version 1.0", async () => { 21 | const drawingState = await new Drawing().deserialize( 22 | cloneDeep(stateV1) as SerializedDrawingState 23 | ) 24 | const got = new Drawing().setState(drawingState).serialize() 25 | const want = stateV1 26 | 27 | expect(got).toStrictEqual(want) 28 | }) 29 | 30 | it("throws an error for unknown or missing version", async () => { 31 | await expect(new Drawing().deserialize({} as any)).rejects.toThrow( 32 | "cannot deserialize state, missing version" 33 | ) 34 | 35 | await expect( 36 | new Drawing().deserialize({ version: "0.1" } as any) 37 | ).rejects.toThrow("cannot deserialize state, unknown version 0.1") 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/View/Dialog/InitialSelection/PaperSize/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import React, { useCallback } from "react" 3 | import { useGState } from "state" 4 | import { PageSize } from "state/board/state/index.types" 5 | import { PAGE_SIZE } from "consts" 6 | import { drawing } from "state/drawing" 7 | import { PresetButton, Presets } from "../index.styled" 8 | 9 | enum Orientation { 10 | Portrait, 11 | Landscape, 12 | } 13 | 14 | export interface FormValues { 15 | orientation: Orientation 16 | } 17 | 18 | const PaperSize: React.FC = () => { 19 | const { width, height } = useGState("PageSizeMenu").drawing.pageMeta.size 20 | const isMatch = useCallback( 21 | (size: PageSize) => width === size.width && height === size.height, 22 | [width, height] 23 | ) 24 | const isPortrait = isMatch(PAGE_SIZE.A4_PORTRAIT) 25 | 26 | return ( 27 | 28 | drawing.setPageSize(PAGE_SIZE.A4_PORTRAIT)} 30 | active={isPortrait} 31 | > 32 | 33 | 34 | drawing.setPageSize(PAGE_SIZE.A4_LANDSCAPE)} 36 | active={!isPortrait} 37 | > 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | export default PaperSize 45 | -------------------------------------------------------------------------------- /src/state/settings/serializers/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { cloneDeep } from "lodash" 3 | import { CURRENT_THEME_VERSION } from "." 4 | import stateV1 from "./__test__/stateV1.json" 5 | import { SerializedSettingsState } from "../state/index.types" 6 | import { getDefaultSettingsState } from "../state/default" 7 | import { SettingsClass } from "../state" 8 | 9 | describe("settings serialize state", () => { 10 | it("should serialize the default state", () => { 11 | const got = new SettingsClass().serialize() 12 | const want = { 13 | version: CURRENT_THEME_VERSION, 14 | ...getDefaultSettingsState(), 15 | } 16 | 17 | expect(got).toStrictEqual(want) 18 | }) 19 | 20 | it("should deserialize the state version 1.0", async () => { 21 | const state = await new SettingsClass().deserialize( 22 | cloneDeep(stateV1) 23 | ) 24 | const got = new SettingsClass().setState(state).serialize() 25 | const want = stateV1 26 | 27 | expect(got).toStrictEqual(want) 28 | }) 29 | 30 | it("throws an error for unknown or missing version", async () => { 31 | await expect( 32 | new SettingsClass().deserialize({} as any) 33 | ).rejects.toThrow("cannot deserialize state, missing version") 34 | 35 | await expect( 36 | new SettingsClass().deserialize({ version: "0.1" } as any) 37 | ).rejects.toThrow("cannot deserialize state, unknown version 0.1") 38 | }) 39 | }) 40 | -------------------------------------------------------------------------------- /src/View/Notification/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage, IntlMessageId } from "language" 2 | import React, { Fragment } from "react" 3 | import { useGState } from "state" 4 | import { HorizontalRule } from "components" 5 | import { CSSTransition } from "react-transition-group" 6 | import { NOTIFICATION_TRANSITION } from "consts" 7 | import { nanoid } from "nanoid" 8 | import { Message, NotificationWrap } from "./index.styled" 9 | 10 | let lastNonEmptyState: IntlMessageId[] = [] 11 | 12 | const Notification: React.FC = () => { 13 | const { notifications } = useGState("Notification").notification 14 | 15 | // Keep last non empty state for fade out animation 16 | if (notifications.length > 0) { 17 | lastNonEmptyState = notifications 18 | } 19 | 20 | return ( 21 | 0} 23 | unmountOnExit 24 | timeout={NOTIFICATION_TRANSITION} 25 | classNames="notification" 26 | > 27 | 28 | {lastNonEmptyState.map((id, i) => { 29 | return ( 30 | 31 | {i > 0 && } 32 | 33 | 34 | 35 | 36 | ) 37 | })} 38 | 39 | 40 | ) 41 | } 42 | 43 | export default Notification 44 | -------------------------------------------------------------------------------- /src/View/ToolRing/StylePicker/WidthPicker/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | export const WidthPresets = styled.div` 4 | display: grid; 5 | grid-template-columns: repeat(2, 1fr); 6 | align-content: space-between; 7 | align-items: center; 8 | border-radius: ${({ theme }) => theme.borderRadius}; 9 | ` 10 | 11 | interface PresetProps { 12 | $active: boolean 13 | } 14 | 15 | export const Preset = styled.button` 16 | ${({ theme, $active }) => css` 17 | cursor: pointer; 18 | display: flex; 19 | justify-content: center; 20 | align-items: center; 21 | border: none; 22 | border-radius: ${theme.borderRadius}; 23 | height: ${theme.iconButton.size}; 24 | width: ${theme.iconButton.size}; 25 | transition: all ease-in-out 250ms; 26 | 27 | ${$active 28 | ? css` 29 | background: ${({ theme }) => theme.palette.editor.selected}; 30 | ` 31 | : css` 32 | background: transparent; 33 | `}; 34 | `} 35 | ` 36 | 37 | interface StrokeWidth { 38 | $strokeWidth: number 39 | } 40 | 41 | export const WidthPresetInnerDot = styled.div` 42 | ${({ $strokeWidth, theme }) => css` 43 | display: flex; 44 | background: ${theme.palette.primary.contrastText}; 45 | height: ${$strokeWidth}px; 46 | width: ${$strokeWidth}px; 47 | min-width: ${$strokeWidth}px; /* Adjustment for mobile */ 48 | border-radius: 50%; 49 | `}; 50 | ` 51 | -------------------------------------------------------------------------------- /scripts/test.js: -------------------------------------------------------------------------------- 1 | "use strict" 2 | 3 | // Do this as the first thing so that any code reading it knows the right env. 4 | process.env.BABEL_ENV = "test" 5 | process.env.NODE_ENV = "test" 6 | process.env.PUBLIC_URL = "" 7 | 8 | // Makes the script crash on unhandled rejections instead of silently 9 | // ignoring them. In the future, promise rejections that are not handled will 10 | // terminate the Node.js process with a non-zero exit code. 11 | process.on("unhandledRejection", (err) => { 12 | throw err 13 | }) 14 | 15 | // Ensure environment variables are read. 16 | require("../config/env") 17 | 18 | const jest = require("jest") 19 | const execSync = require("child_process").execSync 20 | let argv = process.argv.slice(2) 21 | 22 | function isInGitRepository() { 23 | try { 24 | execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }) 25 | return true 26 | } catch (e) { 27 | return false 28 | } 29 | } 30 | 31 | function isInMercurialRepository() { 32 | try { 33 | execSync("hg --cwd . root", { stdio: "ignore" }) 34 | return true 35 | } catch (e) { 36 | return false 37 | } 38 | } 39 | 40 | // Watch unless on CI or explicitly running all tests 41 | if ( 42 | !process.env.CI && 43 | argv.indexOf("--watchAll") === -1 && 44 | argv.indexOf("--watchAll=false") === -1 45 | ) { 46 | // https://github.com/facebook/create-react-app/issues/5210 47 | const hasSourceControl = isInGitRepository() || isInMercurialRepository() 48 | argv.push(hasSourceControl ? "--watch" : "--watchAll") 49 | } 50 | 51 | jest.run(argv) 52 | -------------------------------------------------------------------------------- /src/components/IconButton/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | interface Props { 4 | $deactivated?: boolean 5 | $active?: boolean 6 | } 7 | 8 | export const StyledIconButton = styled.button` 9 | ${({ theme, $active, $deactivated }) => css` 10 | cursor: pointer; 11 | display: flex; 12 | align-items: center; 13 | justify-content: center; 14 | height: ${theme.iconButton.size}; 15 | width: ${theme.iconButton.size}; 16 | margin: ${theme.iconButton.margin}; 17 | padding: ${theme.iconButton.padding}; 18 | border: none; 19 | border-radius: ${theme.borderRadius}; 20 | background: ${theme.palette.primary.main}; 21 | 22 | /* color for non custom svgs */ 23 | color: ${theme.palette.primary.contrastText}; 24 | svg { 25 | transition: all 100ms ease-in-out; 26 | height: 80%; 27 | width: 80%; 28 | } 29 | 30 | ${$active 31 | ? css` 32 | background: ${theme.palette.editor.selected}; 33 | ` 34 | : css` 35 | &:hover { 36 | svg { 37 | transform: scale(1.2, 1.2); 38 | } 39 | } 40 | `}; 41 | ${$deactivated 42 | ? css` 43 | cursor: not-allowed; 44 | svg { 45 | stroke: ${theme.palette.primary.main}; 46 | } 47 | ` 48 | : null}; 49 | `} 50 | ` 51 | -------------------------------------------------------------------------------- /src/View/Board/Observer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useViewControl } from "hooks" 2 | import React from "react" 3 | import { useGState } from "state" 4 | import { Content, ViewControl, ViewBackground } from "./index.styled" 5 | 6 | type ViewTransformerProps = { children: React.ReactNode } 7 | 8 | const Observer: React.FC = ({ children }) => { 9 | const { 10 | isPanMode, 11 | onMouseDown, 12 | onMouseMove, 13 | onMouseUp, 14 | onTouchStart, 15 | onTouchMove, 16 | onTouchEnd, 17 | onTouchCancel, 18 | onWheel, 19 | onScroll, 20 | } = useViewControl() 21 | 22 | const { scale, xOffset, yOffset } = 23 | useGState("ViewTransform").view.viewTransform 24 | 25 | return ( 26 | 37 | 44 | {children} 45 | 46 | 47 | ) 48 | } 49 | 50 | export default Observer 51 | -------------------------------------------------------------------------------- /src/View/MainMenu/MenuItem/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | // Ellipsis (...) text overflow for UserNames 4 | export const TextWrap = styled.span` 5 | max-width: 11.3rem; 6 | display: inline-block; 7 | text-align: start; 8 | text-overflow: ellipsis; 9 | overflow: hidden; 10 | white-space: nowrap; 11 | ` 12 | 13 | export const ItemWrap = styled.li` 14 | position: relative; 15 | display: flex; 16 | justify-content: space-between; 17 | width: 100%; 18 | list-style: none; 19 | svg { 20 | stroke: black; 21 | height: 1rem; 22 | width: 1rem; 23 | } 24 | ` 25 | 26 | export const ItemButton = styled.button<{ $warning: boolean }>` 27 | ${({ theme, $warning }) => css` 28 | cursor: pointer; 29 | display: flex; 30 | flex-grow: 1; 31 | align-items: center; 32 | justify-content: space-between; 33 | gap: ${theme.menuButton.gap}; 34 | padding: ${theme.menuButton.padding}; 35 | margin: ${theme.menuButton.margin}; 36 | border: none; 37 | border-radius: ${theme.borderRadius}; 38 | transition: all 200ms ease; 39 | color: ${theme.palette.primary.contrastText}; 40 | background: ${theme.palette.primary.main}; 41 | 42 | &:hover { 43 | filter: ${theme.menuButton.hoverFilter}; 44 | } 45 | 46 | &:disabled { 47 | cursor: no-drop; 48 | filter: opacity(20%); 49 | } 50 | 51 | ${$warning && 52 | css` 53 | color: ${theme.palette.common.warning}; 54 | `} 55 | `} 56 | ` 57 | -------------------------------------------------------------------------------- /src/View/Dialog/InitialSelection/PaperBackground/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import React from "react" 3 | import { useGState } from "state" 4 | import { Paper } from "state/board/state/index.types" 5 | import { drawing } from "state/drawing" 6 | import { PresetButton, Presets } from "../index.styled" 7 | 8 | enum Orientation { 9 | Portrait, 10 | Landscape, 11 | } 12 | 13 | export interface FormValues { 14 | orientation: Orientation 15 | } 16 | 17 | const PaperBackground: React.FC = () => { 18 | const { paper } = useGState("PageBackgroundSetting").drawing.pageMeta 19 | .background 20 | 21 | return ( 22 | 23 | drawing.setPageBackground(Paper.Blank)} 25 | active={paper === Paper.Blank} 26 | > 27 | 28 | 29 | drawing.setPageBackground(Paper.Checkered)} 31 | active={paper === Paper.Checkered} 32 | > 33 | 34 | 35 | drawing.setPageBackground(Paper.Ruled)} 37 | active={paper === Paper.Ruled} 38 | > 39 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default PaperBackground 46 | -------------------------------------------------------------------------------- /src/state/board/serializers/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { cloneDeep } from "lodash" 3 | import { BOARD_VERSION } from "." 4 | import { getDefaultBoardState } from "../state/default" 5 | import stateV1 from "./__test__/stateV1.json" 6 | import { SerializedBoardState } from "../state/index.types" 7 | import { Board } from "../state" 8 | 9 | describe("board reducer state", () => { 10 | it("should serialize the default state", () => { 11 | const got = new Board().serialize() 12 | const want: SerializedBoardState = { 13 | version: BOARD_VERSION, 14 | ...getDefaultBoardState(), 15 | } 16 | delete want.transformPagePosition 17 | delete want.transformStrokes 18 | delete want.activeTextfield 19 | 20 | expect(got).toStrictEqual(want) 21 | }) 22 | 23 | it("should deserialize the state version 1.0", async () => { 24 | const boardState = await new Board().deserialize( 25 | cloneDeep(stateV1) as SerializedBoardState 26 | ) 27 | const got = new Board().setState(boardState).serialize() 28 | const want = stateV1 29 | 30 | expect(got).toStrictEqual(want) 31 | }) 32 | 33 | it("throws an error for unknown or missing version", async () => { 34 | await expect(new Board().deserialize({} as any)).rejects.toThrow( 35 | "cannot deserialize state, missing version" 36 | ) 37 | 38 | await expect( 39 | new Board().deserialize({ version: "0.1" } as any) 40 | ).rejects.toThrow("cannot deserialize state, unknown version 0.1") 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/ActiveTextField/helpers.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from "lodash" 2 | import { action } from "state/action" 3 | import { board } from "state/board" 4 | import { drawing } from "state/drawing" 5 | import { getUnflippedRect } from "util/render/shapes" 6 | 7 | export const onFinishTextEdit = ( 8 | inputRef: React.RefObject 9 | ) => { 10 | const { textfieldAttributes } = drawing.getState() 11 | const { activeTextfield } = board.getState() 12 | if (!activeTextfield) return 13 | 14 | const { left, top } = getUnflippedRect(activeTextfield.points) 15 | 16 | const input = inputRef.current 17 | if (input) { 18 | const { value, offsetWidth, offsetHeight, offsetLeft, offsetTop } = 19 | input 20 | 21 | if (value) { 22 | activeTextfield.textfield = cloneDeep(textfieldAttributes) 23 | activeTextfield.points = [ 24 | left + offsetLeft, 25 | top + offsetTop, 26 | left + offsetLeft + offsetWidth, 27 | top + offsetTop + Math.max(offsetHeight, input.scrollHeight), 28 | ] 29 | activeTextfield.calculateHitbox() // Update Hitbox 30 | 31 | if (activeTextfield.isUpdate) { 32 | action.updateStrokes([activeTextfield]) 33 | } else { 34 | action.addStrokes([activeTextfield]) 35 | } 36 | } else if (value === "" && activeTextfield.isUpdate) { 37 | // Delete edited textfield if now empty 38 | action.deleteStrokes([activeTextfield]) 39 | } 40 | } 41 | 42 | board.clearActiveTextfield() 43 | } 44 | -------------------------------------------------------------------------------- /src/View/Dialog/Subscribe/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components" 2 | 3 | export const BenefitList = styled.ul` 4 | align-items: left; 5 | position: relative; 6 | list-style: none; 7 | padding: 0 1rem; 8 | margin: 0; 9 | ` 10 | 11 | export const BenefitItem = styled.li` 12 | display: flex; 13 | align-items: center; 14 | gap: 1rem; 15 | width: fit-content; 16 | margin: 1rem 0; 17 | 18 | --svg-color: #34b233; 19 | 20 | .external-icon { 21 | height: 1rem; 22 | width: 1rem; 23 | fill: var(--svg-color); 24 | stroke: var(--svg-color); 25 | stroke-width: 15; 26 | } 27 | 28 | svg:not(.external-icon) { 29 | height: 1rem; 30 | width: 1rem; 31 | transform: scale(1.2); 32 | stroke: var(--svg-color) !important; 33 | stroke-width: 15 !important; 34 | } 35 | ` 36 | 37 | export const SubscribeButton = styled.a` 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | cursor: pointer; 42 | color: ${({ theme }) => theme.palette.secondary.contrastText}; 43 | background: ${({ theme }) => theme.palette.secondary.main}; 44 | margin: 6px 0; 45 | padding: 10px 1.5rem; 46 | border-width: 0; 47 | border-radius: ${({ theme }) => theme.borderRadius}; 48 | transition: all 100ms ease-in-out; 49 | box-shadow: ${({ theme }) => theme.boxShadow}; 50 | height: min-content; 51 | 52 | &:hover { 53 | filter: brightness(120%); 54 | } 55 | 56 | &:disabled { 57 | cursor: not-allowed; 58 | filter: brightness(40%); 59 | } 60 | 61 | text-decoration: none; 62 | ` 63 | -------------------------------------------------------------------------------- /src/state/board/serializers/__test__/stateV1.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "pageRank": ["jtWj1Y7e"], 4 | "pageCollection": { 5 | "jtWj1Y7e": { 6 | "pageId": "jtWj1Y7e", 7 | "strokes": { 8 | "2vl2xtzkgldbsatk": { 9 | "textfield": { 10 | "text": "", 11 | "color": "#000000", 12 | "hAlign": "left", 13 | "vAlign": "top", 14 | "font": "Arial, Helvetica, sans-serif", 15 | "fontWeight": 400, 16 | "fontSize": 16, 17 | "lineHeight": 20 18 | }, 19 | "type": 8, 20 | "style": { 21 | "color": "#000000", 22 | "width": 3, 23 | "opacity": 1 24 | }, 25 | "id": "2vl2xtzkgldbsatk", 26 | "pageId": "jtWj1Y7e", 27 | "x": 0, 28 | "y": 0, 29 | "scaleX": 1, 30 | "scaleY": 1, 31 | "points": [120, 160, 240, 400] 32 | } 33 | }, 34 | "meta": { 35 | "background": { 36 | "paper": "blank", 37 | "attachId": "", 38 | "documentPageNum": 0 39 | }, 40 | "size": { 41 | "width": 620, 42 | "height": 877 43 | } 44 | } 45 | } 46 | }, 47 | "attachments": {} 48 | } 49 | -------------------------------------------------------------------------------- /src/theme/baseTheme.ts: -------------------------------------------------------------------------------- 1 | import { DefaultTheme } from "styled-components" 2 | 3 | export const baseTheme: Omit = { 4 | tools: { 5 | selection: { 6 | fill: "#00a2ff38", 7 | handle: { 8 | borderRadius: "2px", 9 | color: "#00245366", 10 | size: "0.75rem", 11 | }, 12 | }, 13 | eraser: { stroke: "#77110511" }, 14 | }, 15 | breakpoint: { 16 | sm: "576px", 17 | md: "768px", 18 | lg: "992px", 19 | xl: "1200px", 20 | xxl: "1400px", 21 | }, 22 | borderRadius: "4px", 23 | boxShadow: "0 1px 6px #00000033, 0 1px 4px #00000033", 24 | iconButton: { 25 | size: "2rem", 26 | margin: "0.25rem 0.5rem", 27 | padding: "0", 28 | strokeWidth: "5", 29 | }, 30 | menuButton: { 31 | gap: "10px", 32 | padding: "7px 10px", 33 | margin: "3px", 34 | hoverFilter: "brightness(80%)", 35 | }, 36 | toolbar: { 37 | gap: "0", 38 | padding: "0", 39 | margin: "0.2rem", 40 | boxShadow: "0px 0px 2px 0px #00000088", 41 | }, 42 | colorPicker: { 43 | hue: { 44 | width: "1.2rem", 45 | height: "2rem", 46 | }, 47 | }, 48 | dialog: { 49 | background: "#000000aa", 50 | }, 51 | zIndex: { 52 | notifications: "9999", 53 | dialog: "1000", 54 | dialogBG: "999", 55 | toolTip: "888", 56 | toolRing: "777", 57 | drawer: "81", 58 | drawerBG: "80", 59 | mainMenu: "70", 60 | favoriteTools: "60", 61 | popupBG: "10", 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | paths: 6 | - "src/**" 7 | - "public/**" 8 | - ".github/workflows/**" 9 | - ".env" 10 | - "package.json" 11 | - "yarn.lock" 12 | branches: [master] 13 | 14 | jobs: 15 | build: 16 | name: Test, build and deploy 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v3 22 | 23 | - name: Use Node.js 19 24 | uses: actions/setup-node@v2 25 | with: 26 | node-version: "19.x" 27 | 28 | - name: Install dependencies 29 | run: yarn --frozen-lockfile 30 | 31 | - name: Check with ESLint 32 | run: yarn lint 33 | 34 | - name: Run unit tests 35 | run: yarn test --watchAll=false 36 | 37 | - name: Build 38 | run: yarn build 39 | 40 | - name: Configure AWS credentials 41 | uses: aws-actions/configure-aws-credentials@v1 42 | with: 43 | aws-access-key-id: ${{ secrets.AWS_ID }} 44 | aws-secret-access-key: ${{ secrets.AWS_ACCESS_KEY }} 45 | aws-region: ${{ secrets.AWS_REGION }} 46 | 47 | - name: Upload files to the bucket 48 | run: | 49 | aws s3 sync ./build s3://${{ secrets.AWS_S3_BUCKET_NAME }} --delete 50 | 51 | - name: Clear Cloudfront cache 52 | run: | 53 | aws cloudfront create-invalidation --distribution-id ${{ secrets.AWS_CLOUDFRONT_ID }} --paths "/*" 54 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/Live/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo } from "react" 2 | import { useGState } from "state" 3 | import { useLiveStroke, useLayerConfig } from "hooks" 4 | import { Canvas } from "../index.styled" 5 | import { PageProps } from "../index.types" 6 | 7 | export const Live: React.FC = memo(({ page, pageOffset }) => { 8 | const canvasRef = React.useRef(null) 9 | 10 | const { 11 | isPanMode, 12 | onMouseDown, 13 | onMouseMove, 14 | onMouseUp, 15 | onMouseLeave, 16 | onTouchStart, 17 | onTouchMove, 18 | onTouchEnd, 19 | onTouchCancel, 20 | } = useLiveStroke(page.pageId, canvasRef, pageOffset) 21 | const { layerConfig } = useGState("LayerConfig").view 22 | useLayerConfig(canvasRef) 23 | 24 | return ( 25 | 46 | ) 47 | }) 48 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/Page/ActiveTextField/index.styled.ts: -------------------------------------------------------------------------------- 1 | import { IconButton } from "components" 2 | import type { TextfieldAttrs as TextfieldObject } from "drawing/stroke/index.types" 3 | import styled from "styled-components" 4 | 5 | type TextareaProps = { 6 | width: number 7 | height: number 8 | } 9 | 10 | export const Textarea = styled.textarea` 11 | background: ${({ theme }) => theme.palette.editor.paper}ee; 12 | box-shadow: ${({ theme }) => theme.boxShadow}; 13 | position: absolute; 14 | outline: none; 15 | border-radius: ${({ theme }) => theme.borderRadius}; 16 | border: none; 17 | width: ${({ width }) => width}px; 18 | height: ${({ height }) => height}px; 19 | ` 20 | 21 | export const TextfieldSettingsButton = styled(IconButton)` 22 | margin: 0; 23 | padding: 2px; 24 | position: absolute; 25 | top: 0px; 26 | right: 2px; 27 | height: 2rem; 28 | width: 2rem; 29 | box-shadow: ${({ theme }) => theme.boxShadow}; 30 | ` 31 | 32 | export const TEXTFIELD_PADDING = 2 33 | 34 | export const AttributesProvider = styled.div>` 35 | position: absolute; 36 | 37 | textarea { 38 | text-align: ${({ hAlign }) => hAlign}; 39 | font-weight: ${({ fontWeight }) => fontWeight}; 40 | font-family: ${({ font }) => font}; 41 | font-size: ${({ fontSize }) => fontSize}px; 42 | line-height: ${({ lineHeight }) => lineHeight}px; 43 | padding: ${TEXTFIELD_PADDING}px; 44 | color: ${({ color }) => color}; 45 | } 46 | ` 47 | 48 | export const TextfieldBackground = styled.button` 49 | position: absolute; 50 | background: transparent; 51 | outline: none; 52 | border: none; 53 | ` 54 | -------------------------------------------------------------------------------- /src/components/Svg/svgs/background/ruledlandscape.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/state/online/serializers/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { cloneDeep } from "lodash" 3 | import { CURRENT_ONLINE_VERSION } from "./index" 4 | import stateV1 from "./__test__/stateV1.json" 5 | import { Online } from "../state" 6 | import { getDefaultOnlineState } from "../state/default" 7 | import { SerializedOnlineState } from "../state/index.types" 8 | 9 | describe("online serialize state", () => { 10 | it("should serialize the default state", () => { 11 | const got = new Online().serialize() 12 | const want: SerializedOnlineState = { 13 | version: CURRENT_ONLINE_VERSION, 14 | ...getDefaultOnlineState(), 15 | } 16 | 17 | expect(got.user.alias).toBeTruthy() 18 | expect(got.user.color).toBeTruthy() 19 | 20 | // set due to rng naming 21 | got.user.alias = want.user.alias 22 | got.user.color = want.user.color 23 | expect(got.user).toStrictEqual(want.user) 24 | expect(got.token).toBeUndefined() 25 | }) 26 | 27 | it("should deserialize the state version 1.0", async () => { 28 | const state = await new Online().deserialize( 29 | cloneDeep(stateV1) 30 | ) 31 | const got = new Online().setState(state).serialize() 32 | const want = stateV1 33 | 34 | expect(got).toStrictEqual(want) 35 | }) 36 | 37 | it("throws an error for unknown or missing version", async () => { 38 | await expect(new Online().deserialize({} as any)).rejects.toThrow( 39 | "cannot deserialize state, missing version" 40 | ) 41 | 42 | await expect( 43 | new Online().deserialize({ version: "0.1" } as any) 44 | ).rejects.toThrow("cannot deserialize state, unknown version 0.1") 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/View/ToolRing/ActiveTool/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import { Popup, ToolTip, Position, ToolIcons, ToolButton } from "components" 3 | import React, { memo, useState } from "react" 4 | import { useGState } from "state" 5 | import { notification } from "state/notification" 6 | import { action } from "state/action" 7 | import { menu } from "state/menu" 8 | import { isDrawType } from "util/drawing" 9 | import StylePicker from "../StylePicker" 10 | 11 | const ActiveTool: React.FC = memo(() => { 12 | const [open, setOpen] = useState(false) 13 | 14 | const { style, type, latestDrawType } = useGState("ActiveTool").drawing.tool 15 | 16 | const isDraw = isDrawType(type) 17 | const ToolIcon = ToolIcons[latestDrawType ?? type] 18 | 19 | const onClick = () => { 20 | if (isDraw) { 21 | setOpen(true) 22 | menu.closeMainMenu() 23 | } else { 24 | action.setTool({ type: latestDrawType }) 25 | notification.create("Notification.Tool.Active") 26 | } 27 | } 28 | 29 | return ( 30 | <> 31 | } 34 | > 35 | } 38 | active={isDraw} 39 | onClick={onClick} 40 | toolColor={style.color} 41 | toolWidth={style.width} 42 | /> 43 | 44 | setOpen(false)}> 45 | 46 | 47 | 48 | ) 49 | }) 50 | 51 | export default ActiveTool 52 | -------------------------------------------------------------------------------- /src/state/view/util/bounds.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SCROLL_LIMIT_FIRST_PAGE, 3 | SCROLL_LIMIT_HORIZONTAL, 4 | SCROLL_LIMIT_LAST_PAGE, 5 | } from "consts" 6 | import { ViewState, ViewTransform } from "state/view/state/index.types" 7 | 8 | type GetHorizontalBoundsProps = { 9 | newTransform: ViewTransform 10 | pageWidth: number 11 | keepCentered: boolean 12 | } & Pick 13 | 14 | export const getHorizontalBounds = ({ 15 | newTransform, 16 | pageWidth, 17 | keepCentered, 18 | innerWidth, 19 | }: GetHorizontalBoundsProps) => { 20 | const limit = keepCentered ? 1 : SCROLL_LIMIT_HORIZONTAL 21 | const offset = (innerWidth * limit) / newTransform.scale - pageWidth / 2 22 | return { 23 | leftBound: offset, 24 | rightBound: innerWidth / newTransform.scale - offset, 25 | } 26 | } 27 | 28 | type GetUpperBoundProps = { 29 | newTransform: ViewTransform 30 | } & Pick 31 | 32 | export const getUpperBound = ({ 33 | newTransform, 34 | innerHeight, 35 | }: GetUpperBoundProps): number => { 36 | return (innerHeight * SCROLL_LIMIT_FIRST_PAGE) / newTransform.scale 37 | } 38 | 39 | type GetLowerBoundProps = { 40 | newTransform: ViewTransform 41 | pageHeight: number 42 | } & Pick 43 | 44 | export const getLowerBound = ({ 45 | newTransform, 46 | pageHeight, 47 | innerHeight, 48 | }: GetLowerBoundProps): number => { 49 | return ( 50 | (innerHeight * SCROLL_LIMIT_LAST_PAGE) / newTransform.scale - pageHeight 51 | ) 52 | } 53 | 54 | interface ApplyBoundProps { 55 | value: number 56 | min: number 57 | max: number 58 | } 59 | 60 | export const applyBound = ({ value, min, max }: ApplyBoundProps): number => { 61 | if (value > max) return max 62 | if (value < min) return min 63 | return value 64 | } 65 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/Page/PageStyle/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { HorizontalRule, TickIcon } from "components" 3 | import { FormattedMessage } from "language" 4 | import { drawing } from "state/drawing" 5 | import { SubMenuWrap } from "View/MainMenu/index.styled" 6 | import MenuItem from "View/MainMenu/MenuItem" 7 | import { useGState } from "state" 8 | import { Paper } from "state/board/state/index.types" 9 | import { action } from "state/action" 10 | 11 | const onClickBlank = () => { 12 | drawing.setPageBackground(Paper.Blank) 13 | } 14 | const onClickCheckered = () => { 15 | drawing.setPageBackground(Paper.Checkered) 16 | } 17 | const onClickRuled = () => { 18 | drawing.setPageBackground(Paper.Ruled) 19 | } 20 | const onClickApply = () => { 21 | action.applyPageBackground() 22 | } 23 | const PageStyle = () => { 24 | const { paper } = useGState("PageStyleMenu").drawing.pageMeta.background 25 | 26 | return ( 27 | 28 | } 30 | icon={paper === Paper.Blank && } 31 | onClick={onClickBlank} 32 | /> 33 | } 35 | icon={paper === Paper.Checkered && } 36 | onClick={onClickCheckered} 37 | /> 38 | } 40 | icon={paper === Paper.Ruled && } 41 | onClick={onClickRuled} 42 | /> 43 | 44 | } 46 | onClick={onClickApply} 47 | /> 48 | 49 | ) 50 | } 51 | export default PageStyle 52 | -------------------------------------------------------------------------------- /src/View/Board/RenderNG/index.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_PAGE_GAP } from "consts" 2 | import React, { memo, useCallback } from "react" 3 | import { useGState } from "state" 4 | import { view } from "state/view" 5 | import Page from "./Page" 6 | import { PageOffset } from "./Page/index.types" 7 | 8 | const RenderNG = memo(() => { 9 | const { pageRank, pageCollection } = useGState("RenderNG").board 10 | 11 | // TODO: improve undefined typing 12 | const pages = new Array(3).fill(undefined).map((_, i) => { 13 | const pageId = pageRank[view.getPageIndex() + i - 1] 14 | return pageCollection[pageId] 15 | }) 16 | 17 | const getPageY = useCallback( 18 | (i: number) => { 19 | if (i === 2) { 20 | return pages[1].meta.size.height + DEFAULT_PAGE_GAP 21 | } 22 | return i ? 0 : -(pages[0].meta.size.height + DEFAULT_PAGE_GAP) 23 | }, 24 | [pages] 25 | ) 26 | 27 | const getPageInfo = useCallback( 28 | (i: number): PageOffset => ({ 29 | left: -pages[i].meta.size.width / 2, 30 | top: getPageY(i), 31 | }), 32 | [pages, getPageY] 33 | ) 34 | 35 | const isValid = useCallback( 36 | (i: number): boolean => !!pages[1] && !!pages[i], 37 | [pages] 38 | ) 39 | 40 | if (!pages[1]) return null 41 | 42 | return ( 43 | <> 44 | {pages.map((page, i) => { 45 | if (!isValid(i)) return null 46 | 47 | const pageInfo = getPageInfo(i) 48 | return ( 49 | isValid(i) && ( 50 | 55 | ) 56 | ) 57 | })} 58 | 59 | ) 60 | }) 61 | 62 | export default RenderNG 63 | -------------------------------------------------------------------------------- /src/View/FavoriteTools/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import { nanoid } from "nanoid" 3 | import React from "react" 4 | import { IconButton, PlusIcon, ToolTip, Position, ToolIcons } from "components" 5 | import { MAX_FAVORITE_TOOLS_FREE } from "consts" 6 | import { useGState } from "state" 7 | import { drawing } from "state/drawing" 8 | import { online } from "state/online" 9 | import { FavToolsStyled } from "./index.styled" 10 | import FavToolButton from "./FavoriteToolButton" 11 | 12 | // add current draw settings as new fav tool 13 | const addFavoriteTool = () => { 14 | drawing.addFavoriteTool() 15 | } 16 | 17 | const FavoriteTools: React.FC = () => { 18 | useGState("Session") 19 | const { favoriteTools } = useGState("FavoriteTools").drawing 20 | const canAddFavoriteTool = 21 | online.isAuthorized() || favoriteTools.length < MAX_FAVORITE_TOOLS_FREE 22 | 23 | return ( 24 | 25 | {favoriteTools.map((tool, i) => { 26 | const ToolIcon = ToolIcons[tool.type] 27 | 28 | return ( 29 | } 34 | /> 35 | ) 36 | })} 37 | {canAddFavoriteTool && ( 38 | } 40 | position={Position.Right} 41 | > 42 | } 45 | onClick={addFavoriteTool} 46 | /> 47 | 48 | )} 49 | 50 | ) 51 | } 52 | 53 | export default FavoriteTools 54 | -------------------------------------------------------------------------------- /src/components/Dialog/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | interface DialogProps { 4 | open: boolean 5 | } 6 | 7 | export const DialogBox = styled.div` 8 | ${({ theme, open }) => css` 9 | z-index: ${theme.zIndex.dialog}; 10 | display: flex; 11 | flex-direction: column; 12 | position: fixed; 13 | background: ${theme.palette.primary.main}; 14 | left: 50%; 15 | top: 50%; 16 | transform: translate(-50%, -50%); 17 | width: min(90vw, 25rem); 18 | max-height: 90vh; 19 | border-radius: ${theme.borderRadius}; 20 | box-shadow: ${theme.boxShadow}; 21 | transition: 250ms ease-in-out; 22 | ${open 23 | ? css` 24 | opacity: 1; 25 | ` 26 | : css` 27 | visibility: hidden; 28 | opacity: 0; 29 | pointer-events: none; 30 | `}; 31 | `} 32 | ` 33 | 34 | export const DialogBackground = styled.div` 35 | ${({ theme, open }) => css` 36 | z-index: ${theme.zIndex.dialogBG}; 37 | position: fixed; 38 | background: ${theme.dialog.background}; 39 | inset: 0; 40 | transition: 250ms ease-in-out; 41 | ${open 42 | ? css` 43 | opacity: 1; 44 | ` 45 | : css` 46 | visibility: hidden; 47 | opacity: 0; 48 | pointer-events: none; 49 | `}; 50 | `} 51 | ` 52 | 53 | export const DialogContent = styled.div` 54 | display: flex; 55 | flex-direction: column; 56 | padding: 0.5rem 1rem; 57 | ` 58 | 59 | export const DialogTitle = styled.h1` 60 | margin: 1rem; 61 | ` 62 | 63 | export const DialogOptions = styled.div` 64 | display: flex; 65 | justify-content: flex-end; 66 | gap: 1rem; 67 | margin: 0.5rem; 68 | ` 69 | -------------------------------------------------------------------------------- /src/state/view/util/multiTouch.ts: -------------------------------------------------------------------------------- 1 | import { ViewTransform } from "state/view/state/index.types" 2 | import { zoomTo } from "./zoomTo" 3 | 4 | type Point = { x: number; y: number } 5 | 6 | let lastZoomPoint: Point | null = null 7 | let lastDistance = 0 8 | 9 | interface MultiTouchMove { 10 | viewTransform: ViewTransform 11 | p1: Point 12 | p2: Point 13 | } 14 | 15 | export const multiTouchMove = ({ 16 | viewTransform, 17 | p1, 18 | p2, 19 | }: MultiTouchMove): ViewTransform => { 20 | if (!lastZoomPoint) { 21 | lastZoomPoint = getCenter(p1, p2) 22 | return viewTransform 23 | } 24 | 25 | const distance = getDistance(p1, p2) 26 | 27 | if (!lastDistance) { 28 | lastDistance = distance 29 | } 30 | 31 | const zoomPoint = getCenter(p1, p2) 32 | const zoomScale = distance / lastDistance 33 | const newViewTransform = zoomTo({ 34 | viewTransform, 35 | zoomPoint, 36 | zoomScale, 37 | }) 38 | 39 | newViewTransform.xOffset += 40 | (zoomPoint.x - lastZoomPoint.x) / viewTransform.scale 41 | newViewTransform.yOffset += 42 | (zoomPoint.y - lastZoomPoint.y) / viewTransform.scale 43 | 44 | lastDistance = distance 45 | lastZoomPoint = zoomPoint 46 | 47 | return newViewTransform 48 | } 49 | 50 | export const multiTouchEnd = (): void => { 51 | lastDistance = 0 52 | lastZoomPoint = null 53 | } 54 | 55 | /** 56 | * Helper export const for calculating the distance between 2 touch points 57 | * @param {*} p1 58 | * @param {*} p2 59 | */ 60 | export const getDistance = (p1: Point, p2: Point): number => 61 | Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2) 62 | 63 | /** 64 | * Helper export const for calculating the center between 2 touch points 65 | * @param {*} p1 66 | * @param {*} p2 67 | */ 68 | export const getCenter = (p1: Point, p2: Point): Point => ({ 69 | x: (p1.x + p2.x) / 2, 70 | y: (p1.y + p2.y) / 2, 71 | }) 72 | -------------------------------------------------------------------------------- /src/drawing/stroke/index.types.ts: -------------------------------------------------------------------------------- 1 | import { Polygon } from "sat" 2 | import { Serializer } from "state/types" 3 | 4 | export type Hitbox = { 5 | v1: Point 6 | v2: Point 7 | v3: Point 8 | v4: Point 9 | } 10 | 11 | export type Point = { 12 | x: number 13 | y: number 14 | } 15 | 16 | export type Scale = { 17 | x: number 18 | y: number 19 | } 20 | 21 | // Explicit numbers to avoid breaking save files 22 | export enum ToolType { 23 | Eraser = 0, 24 | Pen = 1, 25 | Line = 2, 26 | Circle = 3, 27 | Rectangle = 4, 28 | Select = 5, 29 | Pan = 6, 30 | Highlighter = 7, 31 | Textfield = 8, 32 | } 33 | 34 | export type ToolStyle = { 35 | color: string 36 | width: number 37 | opacity: number 38 | } 39 | 40 | export interface Tool { 41 | type: ToolType 42 | latestDrawType?: ToolType 43 | style: ToolStyle 44 | } 45 | 46 | export type HAlign = "center" | "left" | "right" 47 | export type VAlign = "middle" | "top" | "bottom" 48 | 49 | export interface TextfieldAttrs { 50 | text: string 51 | color: string 52 | hAlign: HAlign 53 | vAlign: VAlign 54 | font: string 55 | fontWeight: number 56 | fontSize: number 57 | lineHeight: number 58 | } 59 | 60 | export interface SerializedStroke extends Tool { 61 | id: string 62 | pageId: string 63 | x: number 64 | y: number 65 | scaleX: number 66 | scaleY: number 67 | points: number[] 68 | hitboxes?: Polygon[] 69 | textfield?: TextfieldAttrs 70 | } 71 | 72 | export interface Stroke 73 | extends SerializedStroke, 74 | Serializer { 75 | isHidden: boolean 76 | 77 | update: (strokeUpdate: Partial) => Stroke 78 | 79 | getPosition(): Point 80 | 81 | getScale(): Scale 82 | 83 | calculateHitbox: () => void 84 | } 85 | 86 | export type StrokeCollection = Record 87 | export type StrokeHitbox = Record 88 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/General/File/index.tsx: -------------------------------------------------------------------------------- 1 | import { FormattedMessage } from "language" 2 | import React from "react" 3 | import { HorizontalRule } from "components" 4 | import { useGState } from "state" 5 | import { online } from "state/online" 6 | import { handleExportWorkspace, handleImportWorkspace } from "storage/workspace" 7 | import { handleExportPdf, handleImportPdf } from "storage/pdf" 8 | import { SubMenuWrap } from "View/MainMenu/index.styled" 9 | import MenuItem from "View/MainMenu/MenuItem" 10 | import { action } from "state/action" 11 | 12 | const onClickOpen = async () => { 13 | handleImportWorkspace() 14 | } 15 | 16 | const onClickSave = () => { 17 | handleExportWorkspace() 18 | } 19 | 20 | const onClickImportPdf = async () => { 21 | handleImportPdf() 22 | } 23 | 24 | const onClickExportPdf = () => { 25 | handleExportPdf() 26 | } 27 | 28 | const FileMenu = () => { 29 | useGState("Session") 30 | 31 | return ( 32 | 33 | } 35 | onClick={action.newWorkspace} 36 | /> 37 | } 39 | onClick={onClickOpen} 40 | /> 41 | } 43 | onClick={onClickSave} 44 | /> 45 | 46 | } 48 | disabled={online.isConnected() && !online.isAuthorized()} 49 | onClick={onClickImportPdf} 50 | /> 51 | } 53 | onClick={onClickExportPdf} 54 | /> 55 | 56 | ) 57 | } 58 | 59 | export default FileMenu 60 | -------------------------------------------------------------------------------- /src/View/MainMenu/index.styled.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components" 2 | 3 | interface MainMenuProps { 4 | open: boolean 5 | } 6 | 7 | export const MainMenuDropdown = styled.div` 8 | ${({ theme, open }) => css` 9 | z-index: ${theme.zIndex.mainMenu}; 10 | position: absolute; 11 | display: flex; 12 | flex-direction: column; 13 | left: ${theme.toolbar.margin}; 14 | top: 3rem; 15 | height: fit-content; 16 | 17 | ${open 18 | ? css` 19 | opacity: 1; 20 | ` 21 | : css` 22 | opacity: 0; 23 | pointer-events: none; 24 | `}; 25 | `} 26 | ` 27 | 28 | const menuStyles = css` 29 | ${({ theme }) => css` 30 | display: flex; 31 | flex-direction: column; 32 | align-items: center; 33 | justify-content: center; 34 | margin: 0; 35 | padding: 0; 36 | box-shadow: ${theme.toolbar.boxShadow}; 37 | background: ${theme.palette.primary.main}; 38 | border-radius: ${theme.borderRadius}; 39 | 40 | width: max-content; 41 | height: max-content; 42 | `} 43 | ` 44 | 45 | export const MainMenuWrap = styled.ul` 46 | ${menuStyles}; 47 | ` 48 | 49 | export const SubMenuWrap = styled.ul` 50 | ${({ theme }) => css` 51 | ${menuStyles}; 52 | z-index: -1; /* make transition animation go below */ 53 | position: absolute; 54 | top: 0; 55 | left: 100%; 56 | margin-left: ${theme.toolbar.margin}; 57 | transition: all 300ms ease; 58 | 59 | &.menu-enter { 60 | transform: translateX(-300%); 61 | } 62 | 63 | &.menu-enter-active { 64 | transform: translateX(0%); 65 | } 66 | 67 | &.menu-exit { 68 | transform: translateX(0%); 69 | } 70 | 71 | &.menu-exit-active { 72 | transform: translateX(-300%); 73 | } 74 | `} 75 | ` 76 | -------------------------------------------------------------------------------- /src/state/settings/state/index.ts: -------------------------------------------------------------------------------- 1 | import { subscriptionState } from "state/subscription" 2 | import { ThemeOption, themes } from "theme/themes" 3 | import { GlobalState } from "state/types" 4 | import { DefaultTheme } from "styled-components" 5 | import { SettingsSerializer } from "../serializers" 6 | import { SettingsState } from "./index.types" 7 | 8 | export class SettingsClass 9 | extends SettingsSerializer 10 | implements GlobalState 11 | { 12 | getState(): SettingsState { 13 | return this.state 14 | } 15 | 16 | setState(newState: SettingsState) { 17 | this.state = newState 18 | subscriptionState.render("Theme", "Settings") 19 | this.saveToLocalStorage() 20 | return this 21 | } 22 | 23 | override async loadFromLocalStorage(): Promise { 24 | const state = await super.loadFromLocalStorage() 25 | subscriptionState.render("Theme", "Settings") 26 | return state 27 | } 28 | 29 | /** 30 | * Get the current theme 31 | * @returns the current theme 32 | */ 33 | getTheme(): DefaultTheme { 34 | return themes[this.state.theme] 35 | } 36 | 37 | /** 38 | * Set the global theme 39 | * @param theme new theme to be set 40 | */ 41 | setTheme(theme: ThemeOption) { 42 | this.state.theme = theme 43 | subscriptionState.render("Theme") 44 | this.saveToLocalStorage() 45 | } 46 | 47 | /** 48 | * Toggle keep view centered setting 49 | */ 50 | toggleShouldCenter(): void { 51 | this.state.keepCentered = !this.state.keepCentered 52 | subscriptionState.render("Settings") 53 | this.saveToLocalStorage() 54 | } 55 | 56 | /** 57 | * Toggle direct draw setting 58 | */ 59 | toggleDirectDraw() { 60 | this.state.directDraw = !this.state.directDraw 61 | subscriptionState.render("Settings") 62 | this.saveToLocalStorage() 63 | } 64 | } 65 | 66 | export const settings = new SettingsClass() 67 | -------------------------------------------------------------------------------- /src/View/Dialog/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react" 2 | import { Dialog } from "components" 3 | import { useGState } from "state" 4 | import { menu } from "state/menu" 5 | import { DialogState } from "state/menu/state/index.types" 6 | import { useNavigate } from "react-router-dom" 7 | import { ROUTE } from "App/routes" 8 | import { online } from "state/online" 9 | import OnlineCreate from "./OnlineCreate" 10 | import InitialSelection from "./InitialSelection" 11 | import OnlineJoin from "./OnlineJoin" 12 | import OnlineChangeAlias from "./OnlineChangeAlias" 13 | import OnlineChangePassword from "./OnlineChangePassword" 14 | import OnlineEnterPassword from "./OnlineEnterPassword" 15 | import OnlineLeave from "./OnlineLeave" 16 | import Subscribe from "./Subscribe" 17 | 18 | const contents = { 19 | [DialogState.Closed]: null, 20 | [DialogState.InitialSelection]: , 21 | [DialogState.InitialSelectionFirstLoad]: , 22 | [DialogState.OnlineCreate]: , 23 | [DialogState.OnlineJoin]: , 24 | [DialogState.OnlineEnterPassword]: , 25 | [DialogState.OnlineChangeAlias]: , 26 | [DialogState.OnlineChangePassword]: , 27 | [DialogState.OnlineLeave]: , 28 | [DialogState.Subscribe]: , 29 | } 30 | 31 | const DialogMenu: React.FC = () => { 32 | const { dialogState } = useGState("DialogState").menu 33 | const navigate = useNavigate() 34 | 35 | const onCloseDialog = useCallback(() => { 36 | menu.setDialogState(DialogState.Closed) 37 | if (!online.isConnected()) { 38 | navigate(ROUTE.HOME) 39 | } 40 | }, [navigate]) 41 | 42 | return ( 43 | 47 | {contents[dialogState]} 48 | 49 | ) 50 | } 51 | 52 | export default DialogMenu 53 | -------------------------------------------------------------------------------- /src/App/index.tsx: -------------------------------------------------------------------------------- 1 | import { locale, Locales, translations } from "language" 2 | import React, { useCallback, useEffect, useState } from "react" 3 | import { IntlProvider } from "react-intl" 4 | import { BrowserRouter } from "react-router-dom" 5 | import Theme from "theme" 6 | import { settings } from "state/settings" 7 | import { drawing } from "state/drawing" 8 | import { online } from "state/online" 9 | import { view } from "state/view" 10 | import { board } from "state/board" 11 | import { action } from "state/action" 12 | import ElectronWrapper from "./electron" 13 | import Routes from "./router" 14 | 15 | const App = () => { 16 | const [loading, setLoading] = useState(true) 17 | 18 | const loadLocalStates = useCallback(async () => { 19 | await Promise.all([ 20 | drawing.loadFromLocalStorage(), 21 | online.loadFromLocalStorage(), 22 | settings.loadFromLocalStorage(), 23 | view.loadFromLocalStorage(), 24 | ]) 25 | 26 | // Set a default workspace without saving to localStorage. 27 | // This prevents overwriting a previous save state and 28 | // provides a default first page if the user closes the 29 | // dialog or creates an online session. 30 | board.localStoreEnabled = false 31 | action.newWorkspace() 32 | board.localStoreEnabled = true 33 | 34 | setLoading(false) 35 | }, []) 36 | 37 | useEffect(() => { 38 | loadLocalStates() 39 | }, [loadLocalStates]) 40 | 41 | // Wait for localStorage load to complete to prevent 42 | // switching theme on mount which looks horrendous 43 | if (loading) return null 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | export default App 59 | -------------------------------------------------------------------------------- /src/drawing/page/index.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid" 2 | import { BoardStroke } from "drawing/stroke" 3 | import { reduceRecord } from "util/lib" 4 | import { Page, PageMeta, SerializedPage } from "state/board/state/index.types" 5 | import { assign, cloneDeep, pick } from "lodash" 6 | import { drawing } from "state/drawing" 7 | import { 8 | SerializedStroke, 9 | Stroke, 10 | StrokeCollection, 11 | } from "../stroke/index.types" 12 | 13 | export class BoardPage implements Page { 14 | constructor(page?: Page) { 15 | if (page) { 16 | this.pageId = page.pageId 17 | this.strokes = page.strokes 18 | this.meta = page.meta 19 | } else { 20 | this.pageId = nanoid(8) 21 | this.strokes = {} 22 | this.meta = cloneDeep(drawing.getState().pageMeta) 23 | } 24 | } 25 | 26 | pageId: string 27 | strokes: StrokeCollection 28 | meta: PageMeta 29 | 30 | setID(pageId: string): BoardPage { 31 | this.pageId = pageId 32 | return this 33 | } 34 | 35 | clear(): void { 36 | this.strokes = {} 37 | } 38 | 39 | updateMeta(meta: PageMeta): BoardPage { 40 | this.meta = meta 41 | return this 42 | } 43 | 44 | addStrokes(strokes: (Stroke | SerializedStroke)[]): BoardPage { 45 | strokes.forEach((stroke) => { 46 | this.strokes[stroke.id] = new BoardStroke(stroke) 47 | }) 48 | return this 49 | } 50 | 51 | serialize(): SerializedPage { 52 | const strokes = reduceRecord(this.strokes, (stroke) => 53 | stroke.serialize() 54 | ) 55 | return { 56 | pageId: this.pageId, 57 | meta: this.meta, 58 | strokes, 59 | } 60 | } 61 | 62 | async deserialize(serialized: SerializedPage): Promise { 63 | assign(this, pick(serialized, ["pageId", "meta"])) 64 | this.strokes = reduceRecord( 65 | serialized.strokes, 66 | (stroke) => new BoardStroke(stroke) 67 | ) 68 | return this 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/util/testing/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { MemoryRouter } from "react-router-dom" 3 | import { IntlProvider } from "react-intl" 4 | import { 5 | FormatMessageArgs, 6 | FormattedMessage, 7 | IntlMessageId, 8 | locale, 9 | Locales, 10 | translations, 11 | } from "language" 12 | import { ThemeProvider } from "styled-components" 13 | import { ThemeOption, themes } from "theme/themes" 14 | import { render } from "@testing-library/react" 15 | 16 | type RenderProps = { 17 | ui: JSX.Element 18 | pathname?: string 19 | initRoutes?: string[] 20 | } 21 | 22 | export const renderWithProviders = ({ 23 | ui, 24 | pathname = "/", 25 | initRoutes = ["/"], 26 | }: RenderProps) => { 27 | const theme = themes[ThemeOption.Dark] 28 | const messages = translations[Locales.EN] 29 | 30 | return render( 31 | 32 | 33 | 37 | {ui} 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | /** 45 | * Render a formattedMessage to extract its string which also 46 | * allows testing of a FormattedMessage which uses dynamic values. 47 | */ 48 | export const formatMessage = ( 49 | id: IntlMessageId, 50 | values?: FormatMessageArgs[1] 51 | ) => { 52 | const messages = translations[Locales.EN] 53 | 54 | const { unmount, getByTestId } = render( 55 | 56 |
57 | 58 |
59 |
60 | ) 61 | 62 | // Extract text 63 | const message = getByTestId("message-container").textContent 64 | 65 | // Clean up render 66 | unmount() 67 | return message as string // We know here that a string will be found 68 | } 69 | -------------------------------------------------------------------------------- /src/storage/pdf/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | FILE_DESCRIPTION_PDF, 3 | FILE_EXTENSION_PDF, 4 | FILE_NAME_PDF, 5 | MIME_TYPE_PDF, 6 | } from "consts" 7 | import { fileOpen, fileSave } from "browser-fs-access" 8 | import { menu } from "state/menu" 9 | import { notification } from "state/notification" 10 | import { ENCRYPTED_PDF_ERROR, importPdfFile, renderAsPdf } from "./util" 11 | import { startBackgroundJob } from "../util" 12 | 13 | export const handleImportPdf = async () => { 14 | menu.closeMainMenu() 15 | try { 16 | await startBackgroundJob("Loading.ImportingPdf", async () => { 17 | const file = await fileOpen({ 18 | description: FILE_DESCRIPTION_PDF, 19 | mimeTypes: [MIME_TYPE_PDF], 20 | extensions: [FILE_EXTENSION_PDF], 21 | multiple: false, 22 | }) 23 | 24 | if (file.type !== "application/pdf") { 25 | notification.create("Notification.InvalidFileTypePdfImport") 26 | return 27 | } 28 | 29 | await importPdfFile(file) 30 | }) 31 | } catch { 32 | notification.create("Notification.PdfImportFailed") 33 | } 34 | } 35 | 36 | export const handleExportPdf = async (): Promise => { 37 | menu.closeMainMenu() 38 | try { 39 | await startBackgroundJob("Loading.ExportingPdf", async () => { 40 | const pdfBytes = await renderAsPdf() 41 | // Save to file system 42 | await fileSave( 43 | new Blob([pdfBytes], { 44 | type: MIME_TYPE_PDF, 45 | }), 46 | { 47 | fileName: FILE_NAME_PDF, 48 | description: FILE_DESCRIPTION_PDF, 49 | extensions: [FILE_EXTENSION_PDF], 50 | } 51 | ) 52 | }) 53 | } catch (error) { 54 | if ((error as Error)?.message === ENCRYPTED_PDF_ERROR) { 55 | notification.create("Notification.PdfExportFailedEncrypted", 4000) 56 | return 57 | } 58 | notification.create("Notification.PdfExportFailed") 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/View/MainMenu/menu/View/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react" 2 | import { FormattedMessage } from "language" 3 | import { 4 | ExpandIcon, 5 | ShrinkIcon, 6 | ZoomInIcon, 7 | ZoomOutIcon, 8 | HorizontalRule, 9 | ExpandableIcon, 10 | } from "components" 11 | import { view } from "state/view" 12 | import { CSSTransition } from "react-transition-group" 13 | import { cssTransition } from "View/MainMenu/cssTransition" 14 | import { MainMenuWrap } from "View/MainMenu/index.styled" 15 | import MenuItem from "View/MainMenu/MenuItem" 16 | import GoToMenu from "./GoTo" 17 | 18 | enum SubMenu { 19 | Closed, 20 | GoTo, 21 | } 22 | 23 | const ViewMenu = () => { 24 | const [subMenu, setSubMenu] = useState(SubMenu.Closed) 25 | 26 | return ( 27 | 28 | } 30 | expandMenu={() => setSubMenu(SubMenu.GoTo)} 31 | icon={} 32 | > 33 | 34 | 35 | 36 | 37 | 38 | } 40 | icon={} 41 | onClick={() => view.resetViewScale()} 42 | /> 43 | } 45 | icon={} 46 | onClick={() => view.fitToPage()} 47 | /> 48 | } 50 | icon={} 51 | onClick={() => view.zoomCenter(true)} 52 | /> 53 | } 55 | icon={} 56 | onClick={() => view.zoomCenter(false)} 57 | /> 58 | 59 | ) 60 | } 61 | export default ViewMenu 62 | -------------------------------------------------------------------------------- /src/theme/styled.d.ts: -------------------------------------------------------------------------------- 1 | import "styled-components" 2 | 3 | interface IPalette { 4 | main: string 5 | contrastText: string 6 | } 7 | 8 | declare module "styled-components" { 9 | export interface DefaultTheme { 10 | palette: { 11 | primary: IPalette 12 | secondary: IPalette 13 | editor: { 14 | background: string 15 | paper: string 16 | selected: string 17 | } 18 | common: { 19 | warning: string 20 | rule: string 21 | } 22 | } 23 | tools: { 24 | selection: { 25 | fill: string 26 | handle: { color: string; size: string; borderRadius: string } 27 | } 28 | eraser: { stroke: string } 29 | } 30 | breakpoint: { 31 | sm: string 32 | md: string 33 | lg: string 34 | xl: string 35 | xxl: string 36 | } 37 | borderRadius: string 38 | boxShadow: string 39 | iconButton: { 40 | size: string 41 | margin: string 42 | padding: string 43 | strokeWidth: string 44 | } 45 | menuButton: { 46 | gap: string 47 | padding: string 48 | margin: string 49 | hoverFilter: string 50 | } 51 | toolbar: { 52 | gap: string 53 | padding: string 54 | margin: string 55 | boxShadow: string 56 | } 57 | colorPicker: { 58 | hue: { 59 | width: string 60 | height: string 61 | } 62 | } 63 | dialog: { 64 | background: string 65 | } 66 | zIndex: { 67 | notifications: string 68 | dialog: string 69 | dialogBG: string 70 | toolTip: string 71 | toolRing: string 72 | drawer: string 73 | drawerBG: string 74 | mainMenu: string 75 | favoriteTools: string 76 | popupBG: string 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/language/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Props as ReactIntlFormattedMessageProps } from "react-intl/src/components/message" 3 | import { 4 | FormattedMessage as ReactIntlFormattedMessage, 5 | useIntl as useReactIntl, 6 | IntlFormatters, 7 | } from "react-intl" 8 | 9 | // languages 10 | import langEN from "./en.json" 11 | // import langDE from "./de.json" 12 | 13 | // The arguments to the original formatMessage function. 14 | export type FormatMessageArgs = Parameters 15 | 16 | // Our new union type of all available message IDs. 17 | export type IntlMessageId = keyof typeof langEN 18 | 19 | // TODO: annotate type 20 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 21 | type Values = Record 22 | 23 | // Extend the original FormattedMessage props. 24 | type FormattedMessageProps = ReactIntlFormattedMessageProps & { 25 | id?: IntlMessageId 26 | } 27 | 28 | export const FormattedMessage: React.FC = ({ 29 | id, 30 | ...rest 31 | }) => { 32 | return 33 | } 34 | 35 | export const useIntl = () => { 36 | // Pull out the original formatMessage function. 37 | const { formatMessage, ...rest } = useReactIntl() 38 | 39 | // Re-write the formatMessage function but with a strongly-typed id. 40 | const typedFormatMessage = ( 41 | descriptor: FormatMessageArgs[0] & { 42 | id?: IntlMessageId 43 | }, 44 | values?: FormatMessageArgs[1], 45 | options?: FormatMessageArgs[2] 46 | ) => { 47 | return formatMessage(descriptor, values, options) 48 | } 49 | 50 | return { 51 | ...rest, 52 | formatMessage: typedFormatMessage, 53 | } 54 | } 55 | 56 | export enum Locales { 57 | EN = "en", 58 | // DE = "de", 59 | } 60 | 61 | export const translations = { 62 | [Locales.EN]: langEN, 63 | // [Locales.DE]: langDe, 64 | } 65 | 66 | // Default to EN for now 67 | export const locale = Locales.EN // navigator.language.split(/[-_]/)[0] // language without region code 68 | 69 | export const intlTags = { 70 | code: (msg: string) => {msg}, 71 | } 72 | -------------------------------------------------------------------------------- /src/api/types.ts: -------------------------------------------------------------------------------- 1 | import { SerializedStroke } from "drawing/stroke/index.types" 2 | import { PageId, PageMeta, PageRank } from "state/board/state/index.types" 3 | import { SessionConfig, User } from "state/online/state/index.types" 4 | 5 | export enum ErrorCode { 6 | BadRequest = 4000, 7 | RateLimitExceeded = 4001, 8 | MissingIdentifier = 4002, 9 | AttachmentSizeExceeded = 4003, 10 | MaxNumberOfUsersReached = 4004, 11 | BadUsername = 4005, 12 | InvalidPassword = 4006, 13 | } 14 | 15 | type ErrorResponse = { 16 | data: { 17 | code: ErrorCode 18 | message: string 19 | } 20 | } 21 | 22 | export type ErrorBody = { 23 | response?: ErrorResponse 24 | } 25 | 26 | export type StrokeDelete = { 27 | id: string 28 | pageId: string 29 | } 30 | 31 | export enum MessageType { 32 | Error = "error", 33 | Stroke = "stroke", 34 | UserHost = "userhost", 35 | UserConnected = "userconn", 36 | UserSync = "usersync", 37 | UserDisconnected = "userdisc", 38 | UserKick = "userkick", 39 | PageSync = "pagesync", 40 | PageUpdate = "pageupdate", 41 | Config = "config", 42 | } 43 | 44 | export interface Message { 45 | type: MessageType 46 | sender: string 47 | content: T 48 | } 49 | 50 | export interface RequestPostSession { 51 | config?: Partial 52 | } 53 | 54 | export interface ResponsePostSession { 55 | config: SessionConfig 56 | } 57 | 58 | export type SerializedPage = { 59 | pageId: PageId 60 | meta: PageMeta 61 | strokes?: SerializedStroke[] 62 | } 63 | 64 | export interface PageSync { 65 | pageRank: PageRank 66 | pages: Record 67 | } 68 | 69 | export interface UserHost { 70 | secret: string 71 | } 72 | 73 | export interface ConfigMessage { 74 | config: SessionConfig 75 | } 76 | 77 | export interface ResponsePostAttachment { 78 | attachId: string 79 | } 80 | 81 | export interface ResponseGetConfig { 82 | users: Record 83 | config: SessionConfig 84 | } 85 | 86 | export type UpdateUserRequest = { 87 | user: User 88 | } 89 | 90 | export type CreateUserRequest = { 91 | password?: string 92 | user: Pick 93 | } 94 | --------------------------------------------------------------------------------