From 79aee53ff6750de710b0840081d060361a4fb993 Mon Sep 17 00:00:00 2001 From: Timur Khazamov Date: Wed, 15 Jan 2020 20:42:02 +0500 Subject: [PATCH] Redesign idea (#343) * Redisign idea * Code cleanup * Fixed to right container * Reoredered layout * Reordering panels * Export dialog * Removed redunant code * Fixed not removing temp canvas * Fixed preview not using only selected elements * Returned file name on export * Toggle export selected/all elements * Hide copy to clipboard button if no support of clipboard * Added border to swatches * Fixed modal flickering --- src/actions/actionCanvas.tsx | 35 ++-- src/actions/actionExport.tsx | 37 ++-- src/actions/actionProperties.tsx | 43 +++-- src/actions/types.ts | 2 +- src/components/ColorPicker.css | 18 ++ src/components/ColorPicker.tsx | 4 +- src/components/EditableText.css | 18 ++ src/components/EditableText.tsx | 89 ++++----- src/components/ExportDialog.css | 46 +++++ src/components/ExportDialog.tsx | 149 ++++++++++++++++ src/components/FixedSideContainer.css | 30 ++++ src/components/FixedSideContainer.tsx | 19 ++ src/components/Island.css | 7 + src/components/Island.tsx | 16 ++ src/components/Modal.css | 29 +++ src/components/Modal.tsx | 36 ++++ src/components/Panel.tsx | 43 ----- src/components/Popover.css | 12 ++ src/components/Popover.tsx | 1 + src/components/SidePanel.tsx | 148 --------------- src/components/Stack.css | 17 ++ src/components/Stack.tsx | 36 ++++ src/components/ToolIcon.scss | 53 ++++++ src/components/ToolIcon.tsx | 53 ++++++ src/components/icons.tsx | 78 ++++++++ src/components/panels/PanelCanvas.tsx | 39 ---- src/components/panels/PanelColor.tsx | 27 --- src/components/panels/PanelExport.tsx | 92 ---------- src/components/panels/PanelSelection.tsx | 50 ------ src/components/panels/PanelTools.tsx | 38 ---- src/components/panels/panelExport.scss | 16 -- src/element/textWysiwyg.tsx | 9 +- src/index.tsx | 218 ++++++++++++++++++++--- src/scene/data.ts | 53 ++++++ src/styles.scss | 200 ++++++--------------- src/theme.css | 11 ++ src/utils.ts | 17 ++ 37 files changed, 1043 insertions(+), 746 deletions(-) create mode 100644 src/components/EditableText.css create mode 100644 src/components/ExportDialog.css create mode 100644 src/components/ExportDialog.tsx create mode 100644 src/components/FixedSideContainer.css create mode 100644 src/components/FixedSideContainer.tsx create mode 100644 src/components/Island.css create mode 100644 src/components/Island.tsx create mode 100644 src/components/Modal.css create mode 100644 src/components/Modal.tsx delete mode 100644 src/components/Panel.tsx create mode 100644 src/components/Popover.css delete mode 100644 src/components/SidePanel.tsx create mode 100644 src/components/Stack.css create mode 100644 src/components/Stack.tsx create mode 100644 src/components/ToolIcon.scss create mode 100644 src/components/ToolIcon.tsx create mode 100644 src/components/icons.tsx delete mode 100644 src/components/panels/PanelCanvas.tsx delete mode 100644 src/components/panels/PanelColor.tsx delete mode 100644 src/components/panels/PanelExport.tsx delete mode 100644 src/components/panels/PanelSelection.tsx delete mode 100644 src/components/panels/PanelTools.tsx delete mode 100644 src/components/panels/panelExport.scss create mode 100644 src/theme.css diff --git a/src/actions/actionCanvas.tsx b/src/actions/actionCanvas.tsx index 6ed15b71ea..a2d04d773f 100644 --- a/src/actions/actionCanvas.tsx +++ b/src/actions/actionCanvas.tsx @@ -2,43 +2,46 @@ import React from "react"; import { Action } from "./types"; import { ColorPicker } from "../components/ColorPicker"; import { getDefaultAppState } from "../appState"; +import { trash } from "../components/icons"; +import { ToolIcon } from "../components/ToolIcon"; export const actionChangeViewBackgroundColor: Action = { name: "changeViewBackgroundColor", perform: (elements, appState, value) => { return { appState: { ...appState, viewBackgroundColor: value } }; }, - PanelComponent: ({ appState, updateData }) => ( - <> -
Canvas Background Color
- updateData(color)} - /> - - ) + PanelComponent: ({ appState, updateData }) => { + return ( +
+ updateData(color)} + /> +
+ ); + } }; export const actionClearCanvas: Action = { name: "clearCanvas", - perform: (elements, appState, value) => { + perform: () => { return { elements: [], appState: getDefaultAppState() }; }, PanelComponent: ({ updateData }) => ( - + /> ) }; diff --git a/src/actions/actionExport.tsx b/src/actions/actionExport.tsx index bb2fb7e321..71476b8262 100644 --- a/src/actions/actionExport.tsx +++ b/src/actions/actionExport.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Action } from "./types"; import { EditableText } from "../components/EditableText"; import { saveAsJSON, loadFromJSON } from "../scene"; +import { load, save } from "../components/icons"; +import { ToolIcon } from "../components/ToolIcon"; export const actionChangeProjectName: Action = { name: "changeProjectName", @@ -9,15 +11,10 @@ export const actionChangeProjectName: Action = { return { appState: { ...appState, name: value } }; }, PanelComponent: ({ appState, updateData }) => ( - <> -
Name
- {appState.name && ( - updateData(name)} - /> - )} - + updateData(name)} + /> ) }; @@ -34,8 +31,8 @@ export const actionChangeExportBackground: Action = { onChange={e => { updateData(e.target.checked); }} - /> - background + />{" "} + With background ) }; @@ -47,7 +44,13 @@ export const actionSaveScene: Action = { return {}; }, PanelComponent: ({ updateData }) => ( - + updateData(null)} + /> ) }; @@ -57,14 +60,16 @@ export const actionLoadScene: Action = { return { elements: loadedElements }; }, PanelComponent: ({ updateData }) => ( - + /> ) }; diff --git a/src/actions/actionProperties.tsx b/src/actions/actionProperties.tsx index a3fc095de8..d439ce6339 100644 --- a/src/actions/actionProperties.tsx +++ b/src/actions/actionProperties.tsx @@ -3,8 +3,8 @@ import { Action } from "./types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { getSelectedAttribute } from "../scene"; import { ButtonSelect } from "../components/ButtonSelect"; -import { PanelColor } from "../components/panels/PanelColor"; import { isTextElement, redrawTextBoundingBox } from "../element"; +import { ColorPicker } from "../components/ColorPicker"; const changeProperty = ( elements: readonly ExcalidrawElement[], @@ -31,17 +31,14 @@ export const actionChangeStrokeColor: Action = { }; }, PanelComponent: ({ elements, appState, updateData }) => ( - { - updateData(color); - }} - colorValue={getSelectedAttribute( - elements, - element => element.strokeColor - )} - /> + <> +
Stroke
+ element.strokeColor)} + onChange={updateData} + /> + ) }; @@ -58,17 +55,17 @@ export const actionChangeBackgroundColor: Action = { }; }, PanelComponent: ({ elements, updateData }) => ( - { - updateData(color); - }} - colorValue={getSelectedAttribute( - elements, - element => element.backgroundColor - )} - /> + <> +
Background
+ element.backgroundColor + )} + onChange={updateData} + /> + ) }; diff --git a/src/actions/types.ts b/src/actions/types.ts index 3ebb2b985d..b50e0a90cf 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -52,7 +52,7 @@ export interface ActionsManagerInterface { ) => { label: string; action: () => void }[]; renderAction: ( name: string, - elements: ExcalidrawElement[], + elements: readonly ExcalidrawElement[], appState: AppState, updater: UpdaterFn ) => React.ReactElement | null; diff --git a/src/components/ColorPicker.css b/src/components/ColorPicker.css index febd7bfd12..2bef62517b 100644 --- a/src/components/ColorPicker.css +++ b/src/components/ColorPicker.css @@ -42,6 +42,8 @@ float: left; border-radius: 4px; margin: 0px 6px 6px 0px; + box-sizing: border-box; + border: 1px solid #ddd; } .color-picker-swatch:focus { @@ -87,3 +89,19 @@ float: left; padding-left: 8px; } + +.color-picker-label-swatch { + height: 24px; + width: 24px; + display: inline-block; + margin-right: 4px; +} + +.color-picker-swatch-input { + font-size: 16px; + display: inline-block; + width: 100px; + border-radius: 2px; + padding: 2px 4px; + border: 1px solid #ddd; +} diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index bb7276d047..09209e04e8 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -75,7 +75,7 @@ export function ColorPicker({ return (
+

Export

+
+
+ + onExportToPng(exportedElements)} + /> + + {probablySupportsClipboard && ( + onExportToClipboard(exportedElements)} + /> + )} + + + {actionManager.renderAction( + "changeProjectName", + elements, + appState, + syncActionResult + )} + + {actionManager.renderAction( + "changeExportBackground", + elements, + appState, + syncActionResult + )} + {someElementIsSelected && ( +
+ +
+ )} +
+
+ +
+ + )} + + ); +} diff --git a/src/components/FixedSideContainer.css b/src/components/FixedSideContainer.css new file mode 100644 index 0000000000..e6247189c9 --- /dev/null +++ b/src/components/FixedSideContainer.css @@ -0,0 +1,30 @@ +.FixedSideContainer { + --margin: 5px; + position: fixed; + pointer-events: none; +} + +.FixedSideContainer > * { + pointer-events: all; +} + +.FixedSideContainer_side_top { + left: var(--margin); + top: var(--margin); + right: var(--margin); + z-index: 2; +} + +.FixedSideContainer_side_left { + left: var(--margin); + top: var(--margin); + bottom: var(--margin); + z-index: 1; +} + +.FixedSideContainer_side_right { + right: var(--margin); + top: var(--margin); + bottom: var(--margin); + z-index: 3; +} diff --git a/src/components/FixedSideContainer.tsx b/src/components/FixedSideContainer.tsx new file mode 100644 index 0000000000..c3349426d0 --- /dev/null +++ b/src/components/FixedSideContainer.tsx @@ -0,0 +1,19 @@ +import "./FixedSideContainer.css"; + +import React from "react"; + +type FixedSideContainerProps = { + children: React.ReactNode; + side: "top" | "left" | "right"; +}; + +export function FixedSideContainer({ + children, + side +}: FixedSideContainerProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/Island.css b/src/components/Island.css new file mode 100644 index 0000000000..06e8415ded --- /dev/null +++ b/src/components/Island.css @@ -0,0 +1,7 @@ +.Island { + --padding: 0; + background-color: var(--bg-color-main); + box-shadow: var(--shadow-island); + border-radius: var(--border-radius-m); + padding: calc(var(--padding) * var(--space-factor)); +} diff --git a/src/components/Island.tsx b/src/components/Island.tsx new file mode 100644 index 0000000000..e4d635728f --- /dev/null +++ b/src/components/Island.tsx @@ -0,0 +1,16 @@ +import "./Island.css"; + +import React from "react"; + +type IslandProps = { children: React.ReactNode; padding?: number }; + +export function Island({ children, padding }: IslandProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/Modal.css b/src/components/Modal.css new file mode 100644 index 0000000000..483c1110fd --- /dev/null +++ b/src/components/Modal.css @@ -0,0 +1,29 @@ +.Modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + padding: calc(var(--space-factor) * 10); +} + +.Modal__background { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + background-color: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(2px); +} + +.Modal__content { + position: relative; + z-index: 2; + width: 100%; +} diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx new file mode 100644 index 0000000000..a9de78cf25 --- /dev/null +++ b/src/components/Modal.tsx @@ -0,0 +1,36 @@ +import "./Modal.css"; + +import React, { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +export function Modal(props: { + children: React.ReactNode; + maxWidth?: number; + onCloseRequest(): void; +}) { + const modalRoot = useBodyRoot(); + return createPortal( +
+
+
+ {props.children} +
+
, + modalRoot + ); +} + +function useBodyRoot() { + function createDiv() { + const div = document.createElement("div"); + document.body.appendChild(div); + return div; + } + const [div] = useState(createDiv); + useEffect(() => { + return () => { + document.body.removeChild(div); + }; + }, [div]); + return div; +} diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx deleted file mode 100644 index 9435e59e47..0000000000 --- a/src/components/Panel.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React, { useState } from "react"; - -interface PanelProps { - title: string; - defaultCollapsed?: boolean; - hide?: boolean; -} - -export const Panel: React.FC = ({ - title, - children, - defaultCollapsed = false, - hide = false -}) => { - const [collapsed, setCollapsed] = useState(defaultCollapsed); - - if (hide) return null; - - return ( -
-

{title}

- - {!collapsed &&
{children}
} -
- ); -}; diff --git a/src/components/Popover.css b/src/components/Popover.css new file mode 100644 index 0000000000..ed7544c23e --- /dev/null +++ b/src/components/Popover.css @@ -0,0 +1,12 @@ +.popover { + position: absolute; + z-index: 10; +} + +.popover .cover { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; +} diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx index 613a9d6a3a..1bd7e9ffeb 100644 --- a/src/components/Popover.tsx +++ b/src/components/Popover.tsx @@ -1,4 +1,5 @@ import React, { useLayoutEffect, useRef } from "react"; +import "./Popover.css"; type Props = { top?: number; diff --git a/src/components/SidePanel.tsx b/src/components/SidePanel.tsx deleted file mode 100644 index 428dc4a90b..0000000000 --- a/src/components/SidePanel.tsx +++ /dev/null @@ -1,148 +0,0 @@ -import React from "react"; -import { PanelTools } from "./panels/PanelTools"; -import { Panel } from "./Panel"; -import { PanelSelection } from "./panels/PanelSelection"; -import { - hasBackground, - someElementIsSelected, - hasStroke, - hasText, - exportCanvas -} from "../scene"; -import { ExcalidrawElement } from "../element/types"; -import { PanelCanvas } from "./panels/PanelCanvas"; -import { PanelExport } from "./panels/PanelExport"; -import { ExportType } from "../scene/types"; -import { AppState } from "../types"; -import { ActionManager } from "../actions"; -import { UpdaterFn } from "../actions/types"; - -interface SidePanelProps { - actionManager: ActionManager; - elements: readonly ExcalidrawElement[]; - syncActionResult: UpdaterFn; - appState: AppState; - onToolChange: (elementType: string) => void; - canvas: HTMLCanvasElement; -} - -export const SidePanel: React.FC = ({ - actionManager, - syncActionResult, - elements, - onToolChange, - appState, - canvas -}) => { - return ( -
- { - onToolChange(value); - }} - /> - - - - {actionManager.renderAction( - "changeStrokeColor", - elements, - appState, - syncActionResult - )} - - {hasBackground(elements) && ( - <> - {actionManager.renderAction( - "changeBackgroundColor", - elements, - appState, - syncActionResult - )} - - {actionManager.renderAction( - "changeFillStyle", - elements, - appState, - syncActionResult - )} - - )} - - {hasStroke(elements) && ( - <> - {actionManager.renderAction( - "changeStrokeWidth", - elements, - appState, - syncActionResult - )} - - {actionManager.renderAction( - "changeSloppiness", - elements, - appState, - syncActionResult - )} - - )} - - {hasText(elements) && ( - <> - {actionManager.renderAction( - "changeFontSize", - elements, - appState, - syncActionResult - )} - - {actionManager.renderAction( - "changeFontFamily", - elements, - appState, - syncActionResult - )} - - )} - - {actionManager.renderAction( - "changeOpacity", - elements, - appState, - syncActionResult - )} - - {actionManager.renderAction( - "deleteSelectedElements", - elements, - appState, - syncActionResult - )} - - - { - const exportedElements = elements.some(element => element.isSelected) - ? elements.filter(element => element.isSelected) - : elements; - return exportCanvas(type, exportedElements, canvas, appState); - }} - /> -
- ); -}; diff --git a/src/components/Stack.css b/src/components/Stack.css new file mode 100644 index 0000000000..2d7513aee6 --- /dev/null +++ b/src/components/Stack.css @@ -0,0 +1,17 @@ +.Stack { + --gap: 0; + display: grid; + gap: calc(var(--space-factor) * var(--gap)); +} + +.Stack_vertical { + grid-template-columns: auto; + grid-auto-flow: row; + grid-auto-rows: min-content; +} + +.Stack_horizontal { + grid-template-rows: auto; + grid-auto-flow: column; + grid-auto-columns: min-content; +} diff --git a/src/components/Stack.tsx b/src/components/Stack.tsx new file mode 100644 index 0000000000..7e40b4cb56 --- /dev/null +++ b/src/components/Stack.tsx @@ -0,0 +1,36 @@ +import "./Stack.css"; + +import React from "react"; + +type StackProps = { + children: React.ReactNode; + gap?: number; + align?: "start" | "center" | "end"; +}; + +function RowStack({ children, gap, align }: StackProps) { + return ( +
+ {children} +
+ ); +} + +function ColStack({ children, gap, align }: StackProps) { + return ( +
+ {children} +
+ ); +} + +export default { + Row: RowStack, + Col: ColStack +}; diff --git a/src/components/ToolIcon.scss b/src/components/ToolIcon.scss new file mode 100644 index 0000000000..80cbaf60af --- /dev/null +++ b/src/components/ToolIcon.scss @@ -0,0 +1,53 @@ +.ToolIcon { + display: inline-block; + position: relative; +} + +.ToolIcon__icon { + background-color: #ddd; + + width: 41px; + height: 41px; + + display: flex; + justify-content: center; + align-items: center; + + border-radius: var(--space-factor); + + svg { + height: 1em; + } +} + +.ToolIcon_type_button { + padding: 0; + border: none; + margin: 0; + font-size: inherit; + + &:hover .ToolIcon__icon { + background-color: #e7e5e5; + } + &:active .ToolIcon__icon { + background-color: #bdbebc; + } + &:focus .ToolIcon__icon { + box-shadow: 0 0 0 2px steelblue; + } +} + +.ToolIcon_type_radio { + position: absolute; + opacity: 0; + + &:hover + .ToolIcon__icon { + background-color: #e7e5e5; + } + &:checked + .ToolIcon__icon { + background-color: #bdbebc; + } + &:focus + .ToolIcon__icon { + box-shadow: 0 0 0 2px steelblue; + } +} diff --git a/src/components/ToolIcon.tsx b/src/components/ToolIcon.tsx new file mode 100644 index 0000000000..374149eb8b --- /dev/null +++ b/src/components/ToolIcon.tsx @@ -0,0 +1,53 @@ +import "./ToolIcon.scss"; + +import React from "react"; + +type ToolIconProps = + | { + type: "button"; + icon: React.ReactNode; + "aria-label": string; + title?: string; + name?: string; + id?: string; + onClick?(): void; + } + | { + type: "radio"; + icon: React.ReactNode; + title?: string; + name?: string; + id?: string; + checked: boolean; + onChange?(): void; + }; + +export function ToolIcon(props: ToolIconProps) { + if (props.type === "button") + return ( + + ); + + return ( + + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000000..8fb29dd392 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,78 @@ +// +// All icons are imported from https://fontawesome.com/icons?d=gallery +// Icons are under the license https://fontawesome.com/license +// + +import React from "react"; + +export const save = ( + +); + +export const load = ( + +); + +export const image = ( + +); + +export const clipboard = ( + +); + +export const trash = ( + +); + +export const palete = ( + +); + +export const exportFile = ( + +); + +export const downloadFile = ( + +); diff --git a/src/components/panels/PanelCanvas.tsx b/src/components/panels/PanelCanvas.tsx deleted file mode 100644 index 959dc0f4c1..0000000000 --- a/src/components/panels/PanelCanvas.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from "react"; - -import { Panel } from "../Panel"; -import { ActionManager } from "../../actions"; -import { ExcalidrawElement } from "../../element/types"; -import { AppState } from "../../types"; -import { UpdaterFn } from "../../actions/types"; - -interface PanelCanvasProps { - actionManager: ActionManager; - elements: readonly ExcalidrawElement[]; - appState: AppState; - syncActionResult: UpdaterFn; -} - -export const PanelCanvas: React.FC = ({ - actionManager, - elements, - appState, - syncActionResult -}) => { - return ( - - {actionManager.renderAction( - "changeViewBackgroundColor", - elements, - appState, - syncActionResult - )} - - {actionManager.renderAction( - "clearCanvas", - elements, - appState, - syncActionResult - )} - - ); -}; diff --git a/src/components/panels/PanelColor.tsx b/src/components/panels/PanelColor.tsx deleted file mode 100644 index bd4cd7623e..0000000000 --- a/src/components/panels/PanelColor.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from "react"; -import { ColorPicker } from "../ColorPicker"; - -interface PanelColorProps { - title: string; - colorType: "canvasBackground" | "elementBackground" | "elementStroke"; - colorValue: string | null; - onColorChange: (value: string) => void; -} - -export const PanelColor: React.FC = ({ - title, - colorType, - onColorChange, - colorValue -}) => { - return ( - <> -
{title}
- onColorChange(color)} - /> - - ); -}; diff --git a/src/components/panels/PanelExport.tsx b/src/components/panels/PanelExport.tsx deleted file mode 100644 index 5cc8a87b7c..0000000000 --- a/src/components/panels/PanelExport.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from "react"; -import { Panel } from "../Panel"; -import { ExportType } from "../../scene/types"; - -import "./panelExport.scss"; -import { ActionManager } from "../../actions"; -import { ExcalidrawElement } from "../../element/types"; -import { AppState } from "../../types"; -import { UpdaterFn } from "../../actions/types"; - -interface PanelExportProps { - actionManager: ActionManager; - elements: readonly ExcalidrawElement[]; - appState: AppState; - syncActionResult: UpdaterFn; - onExportCanvas: (type: ExportType) => void; -} - -// fa-clipboard -const ClipboardIcon = () => ( - - - -); - -const probablySupportsClipboard = - "toBlob" in HTMLCanvasElement.prototype && - "clipboard" in navigator && - "write" in navigator.clipboard && - "ClipboardItem" in window; - -export const PanelExport: React.FC = ({ - actionManager, - elements, - appState, - syncActionResult, - onExportCanvas -}) => { - return ( - -
- {actionManager.renderAction( - "changeProjectName", - elements, - appState, - syncActionResult - )} -
Image
-
- - {probablySupportsClipboard && ( - - )} -
- {actionManager.renderAction( - "changeExportBackground", - elements, - appState, - syncActionResult - )} - -
Scene
- {actionManager.renderAction( - "saveScene", - elements, - appState, - syncActionResult - )} - {actionManager.renderAction( - "loadScene", - elements, - appState, - syncActionResult - )} -
-
- ); -}; diff --git a/src/components/panels/PanelSelection.tsx b/src/components/panels/PanelSelection.tsx deleted file mode 100644 index b5683a3204..0000000000 --- a/src/components/panels/PanelSelection.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from "react"; -import { ActionManager } from "../../actions"; -import { ExcalidrawElement } from "../../element/types"; -import { AppState } from "../../types"; -import { UpdaterFn } from "../../actions/types"; - -interface PanelSelectionProps { - actionManager: ActionManager; - elements: readonly ExcalidrawElement[]; - appState: AppState; - syncActionResult: UpdaterFn; -} - -export const PanelSelection: React.FC = ({ - actionManager, - elements, - appState, - syncActionResult -}) => { - return ( -
-
- {actionManager.renderAction( - "bringForward", - elements, - appState, - syncActionResult - )} - {actionManager.renderAction( - "bringToFront", - elements, - appState, - syncActionResult - )} - {actionManager.renderAction( - "sendBackward", - elements, - appState, - syncActionResult - )} - {actionManager.renderAction( - "sendToBack", - elements, - appState, - syncActionResult - )} -
-
- ); -}; diff --git a/src/components/panels/PanelTools.tsx b/src/components/panels/PanelTools.tsx deleted file mode 100644 index 4ab9db1a73..0000000000 --- a/src/components/panels/PanelTools.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from "react"; - -import { SHAPES } from "../../shapes"; -import { capitalizeString } from "../../utils"; -import { Panel } from "../Panel"; - -interface PanelToolsProps { - activeTool: string; - onToolChange: (value: string) => void; -} - -export const PanelTools: React.FC = ({ - activeTool, - onToolChange -}) => { - return ( - -
- {SHAPES.map(({ value, icon }) => ( - - ))} -
-
- ); -}; diff --git a/src/components/panels/panelExport.scss b/src/components/panels/panelExport.scss deleted file mode 100644 index 8a8ed3b380..0000000000 --- a/src/components/panels/panelExport.scss +++ /dev/null @@ -1,16 +0,0 @@ -.panelExport-imageButtons { - display: flex; -} - -.panelExport-exportToPngButton { - flex: 1 1 auto; -} - -.panelExport-exportToClipboardButton { - margin-left: 10px; - padding: 0 15px; - - svg { - width: 15px; - } -} diff --git a/src/element/textWysiwyg.tsx b/src/element/textWysiwyg.tsx index 168ffabbf3..97c34dcacf 100644 --- a/src/element/textWysiwyg.tsx +++ b/src/element/textWysiwyg.tsx @@ -1,4 +1,5 @@ import { KEYS } from "../keys"; +import { selectNode } from "../utils"; type TextWysiwygParams = { initText: string; @@ -89,11 +90,5 @@ export function textWysiwyg({ window.addEventListener("wheel", stopEvent, true); document.body.appendChild(editable); editable.focus(); - const selection = window.getSelection(); - if (selection) { - const range = document.createRange(); - range.selectNodeContents(editable); - selection.removeAllRanges(); - selection.addRange(range); - } + selectNode(editable); } diff --git a/src/index.tsx b/src/index.tsx index 1912fa40c7..55834354c0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -21,17 +21,21 @@ import { saveToLocalStorage, getElementAtPosition, createScene, - getElementContainingPosition + getElementContainingPosition, + hasBackground, + hasStroke, + hasText, + exportCanvas } from "./scene"; import { renderScene } from "./renderer"; import { AppState } from "./types"; import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types"; -import { isInputLike, measureText, debounce } from "./utils"; +import { isInputLike, measureText, debounce, capitalizeString } from "./utils"; import { KEYS, META_KEY, isArrowKey } from "./keys"; -import { findShapeByKey, shapesShortcutKeys } from "./shapes"; +import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes"; import { createHistory } from "./history"; import ContextMenu from "./components/ContextMenu"; @@ -63,14 +67,18 @@ import { actionCopyStyles, actionPasteStyles } from "./actions"; -import { SidePanel } from "./components/SidePanel"; import { Action, ActionResult } from "./actions/types"; import { getDefaultAppState } from "./appState"; +import { Island } from "./components/Island"; +import Stack from "./components/Stack"; +import { FixedSideContainer } from "./components/FixedSideContainer"; +import { ToolIcon } from "./components/ToolIcon"; +import { ExportDialog } from "./components/ExportDialog"; let { elements } = createScene(); const { history } = createHistory(); -const CANVAS_WINDOW_OFFSET_LEFT = 250; +const CANVAS_WINDOW_OFFSET_LEFT = 0; const CANVAS_WINDOW_OFFSET_TOP = 0; function resetCursor() { @@ -331,26 +339,197 @@ export class App extends React.Component<{}, AppState> { } }; + private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) { + const selectedElements = elements.filter(el => el.isSelected); + if (selectedElements.length === 0) { + return null; + } + + return ( + +
+ {this.actionManager.renderAction( + "changeStrokeColor", + elements, + this.state, + this.syncActionResult + )} + + {hasBackground(elements) && ( + <> + {this.actionManager.renderAction( + "changeBackgroundColor", + elements, + this.state, + this.syncActionResult + )} + + {this.actionManager.renderAction( + "changeFillStyle", + elements, + this.state, + this.syncActionResult + )} +
+ + )} + + {hasStroke(elements) && ( + <> + {this.actionManager.renderAction( + "changeStrokeWidth", + elements, + this.state, + this.syncActionResult + )} + + {this.actionManager.renderAction( + "changeSloppiness", + elements, + this.state, + this.syncActionResult + )} +
+ + )} + + {hasText(elements) && ( + <> + {this.actionManager.renderAction( + "changeFontSize", + elements, + this.state, + this.syncActionResult + )} + + {this.actionManager.renderAction( + "changeFontFamily", + elements, + this.state, + this.syncActionResult + )} +
+ + )} + + {this.actionManager.renderAction( + "changeOpacity", + elements, + this.state, + this.syncActionResult + )} + + {this.actionManager.renderAction( + "deleteSelectedElements", + elements, + this.state, + this.syncActionResult + )} +
+
+ ); + } + + private renderShapesSwitcher() { + return ( + <> + {SHAPES.map(({ value, icon }) => ( + { + this.setState({ elementType: value }); + elements = clearSelection(elements); + document.documentElement.style.cursor = + value === "text" ? "text" : "crosshair"; + this.forceUpdate(); + }} + > + ))} + + ); + } + + private renderCanvasActions() { + return ( + + + {this.actionManager.renderAction( + "loadScene", + elements, + this.state, + this.syncActionResult + )} + {this.actionManager.renderAction( + "saveScene", + elements, + this.state, + this.syncActionResult + )} + { + if (this.canvas) + exportCanvas("png", exportedElements, this.canvas, this.state); + }} + onExportToClipboard={exportedElements => { + if (this.canvas) + exportCanvas( + "clipboard", + exportedElements, + this.canvas, + this.state + ); + }} + /> + {this.actionManager.renderAction( + "clearCanvas", + elements, + this.state, + this.syncActionResult + )} + + {this.actionManager.renderAction( + "changeViewBackgroundColor", + elements, + this.state, + this.syncActionResult + )} + + ); + } + public render() { const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP; return (
- { - this.setState({ elementType: value }); - elements = clearSelection(elements); - document.documentElement.style.cursor = - value === "text" ? "text" : "crosshair"; - this.forceUpdate(); - }} - canvas={this.canvas!} - /> + +
+ +
+ {this.renderCanvasActions()} +
+
+ {this.renderSelectedShapeActions(elements)} +
+
+ + + {this.renderShapesSwitcher()} + + +
+
+ { }); this.removeWheelEventListener = () => canvas.removeEventListener("wheel", this.handleWheel); - // Whenever React sets the width/height of the canvas element, // the context loses the scale transform. We need to re-apply it if ( diff --git a/src/scene/data.ts b/src/scene/data.ts index 5808e707e3..f739ea7d17 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -77,6 +77,59 @@ export function loadFromJSON() { }); } +export function getExportCanvasPreview( + elements: readonly ExcalidrawElement[], + { + exportBackground, + exportPadding = 10, + viewBackgroundColor + }: { + exportBackground: boolean; + exportPadding?: number; + viewBackgroundColor: string; + } +) { + // calculate smallest area to fit the contents in + let subCanvasX1 = Infinity; + let subCanvasX2 = 0; + let subCanvasY1 = Infinity; + let subCanvasY2 = 0; + + elements.forEach(element => { + const [x1, y1, x2, y2] = getElementAbsoluteCoords(element); + subCanvasX1 = Math.min(subCanvasX1, x1); + subCanvasY1 = Math.min(subCanvasY1, y1); + subCanvasX2 = Math.max(subCanvasX2, x2); + subCanvasY2 = Math.max(subCanvasY2, y2); + }); + + function distance(x: number, y: number) { + return Math.abs(x > y ? x - y : y - x); + } + + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2; + tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2; + + renderScene( + elements, + rough.canvas(tempCanvas), + tempCanvas, + { + viewBackgroundColor: exportBackground ? viewBackgroundColor : null, + scrollX: 0, + scrollY: 0 + }, + { + offsetX: -subCanvasX1 + exportPadding, + offsetY: -subCanvasY1 + exportPadding, + renderScrollbars: false, + renderSelection: false + } + ); + return tempCanvas; +} + export function exportCanvas( type: ExportType, elements: readonly ExcalidrawElement[], diff --git a/src/styles.scss b/src/styles.scss index 9652ebac29..1eba8a043d 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,3 +1,5 @@ +@import "./theme.css"; + /* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */ @font-face { font-family: "Virgil"; @@ -8,6 +10,7 @@ body { margin: 0; font-family: Arial, Helvetica, sans-serif; + color: var(--text-color-primary); } .container { @@ -19,138 +22,39 @@ body { right: 0; } -.sidePanel { - width: 230px; - background-color: #eee; - padding: 10px; - overflow-y: auto; - position: relative; +.panelColumn { + display: flex; + flex-direction: column; - h4 { - margin: 10px 0 10px 0; + h5 { + margin-top: 4px; + margin-bottom: 4px; + font-size: 12px; + color: #333; } - .panel { - position: relative; - .btn-panel-collapse { - position: absolute; - top: -2px; - right: 5px; - background: none; - margin: 0px; - color: black; - } - - .btn-panel-collapse-icon { - transform: none; - display: inline-block; - } - - .btn-panel-collapse-icon-closed { - transform: rotateZ(90deg); - } + h5:first-child { + margin-top: 0; } - .panelTools { - display: flex; + .buttonList { flex-wrap: wrap; - justify-content: space-between; - - label { - margin: 2px 0; - } - } - - .panelColumn { - display: flex; - flex-direction: column; - - h5 { - margin-top: 4px; - margin-bottom: 4px; - font-size: 12px; - color: #333; - } - - h5:first-child { - margin-top: 0; - } - - .buttonList { - flex-wrap: wrap; - - button { - margin-right: 4px; - } - } - } -} - -.tool { - position: relative; - - input[type="radio"] { - position: absolute; - opacity: 0; - width: 0; - height: 0; - } - - input[type="radio"] { - & + .toolIcon { - background-color: #ddd; - - width: 41px; - height: 41px; - display: flex; - justify-content: center; - align-items: center; - - border-radius: 3px; - - svg { - height: 1em; - } - } - &:hover + .toolIcon { - background-color: #e7e5e5; - } - &:checked + .toolIcon { - background-color: #bdbebc; - } - &:focus + .toolIcon { - box-shadow: 0 0 0 2px steelblue; + button { + margin-right: 4px; } } } -label { - margin-right: 6px; - span { - display: inline-block; - } -} - -input[type="number"] { - width: 30px; -} - -input[type="color"] { - margin: 2px; -} - -input[type="range"] { - width: 230px; +.divider { + width: 1px; + background-color: #ddd; + margin: 1px; } -input { - margin-right: 5px; - - &:focus { - outline: transparent; - box-shadow: 0 0 0 2px steelblue; - } +input:focus { + outline: transparent; + box-shadow: 0 0 0 2px steelblue; } button { @@ -170,8 +74,7 @@ button { border-color: #d6d4d4; } - &:active, - &.active { + &:active { background-color: #bdbebc; border-color: #bdbebc; } @@ -181,40 +84,39 @@ button { } } -.popover { - position: absolute; - z-index: 2; +.App-menu { + display: grid; +} + +.App-menu_top { + grid-template-columns: 1fr auto 1fr; + align-items: flex-start; + cursor: default; + pointer-events: none !important; +} - .cover { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - } +.App-menu_top > * { + pointer-events: all; } -.swatch { - height: 24px; - width: 24px; - display: inline; - margin-right: 4px; +.App-menu_top > *:first-child { + justify-self: flex-start; } -.swatch-input { - font-size: 16px; - display: inline; - width: 100px; - border-radius: 2px; - padding: 2px 4px; - border: 1px solid #ddd; +.App-menu_top > *:last-child { + justify-self: flex-end; } -.project-name { - font-size: 14px; - cursor: pointer; + +.App-menu_left { + grid-template-rows: 1fr auto 1fr; + height: 100%; +} + +.App-menu_right { + grid-template-rows: 1fr; + height: 100%; } -.project-name-input { - width: 200px; - font: inherit; +.App-right-menu { + width: 220px; } diff --git a/src/theme.css b/src/theme.css new file mode 100644 index 0000000000..4e37efe352 --- /dev/null +++ b/src/theme.css @@ -0,0 +1,11 @@ +:root { + --text-color-primary: #333; + + --bg-color-main: white; + + --shadow-island: 0 1px 5px rgba(0, 0, 0, 0.15); + + --border-radius-m: 4px; + + --space-factor: 4px; +} diff --git a/src/utils.ts b/src/utils.ts index 32147dc45d..29b072d1d2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -62,3 +62,20 @@ export function debounce( handle = window.setTimeout(() => fn(...args), timeout); }; } + +export function selectNode(node: Element) { + const selection = window.getSelection(); + if (selection) { + const range = document.createRange(); + range.selectNodeContents(node); + selection.removeAllRanges(); + selection.addRange(range); + } +} + +export function removeSelection() { + const selection = window.getSelection(); + if (selection) { + selection.removeAllRanges(); + } +}