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
This commit is contained in:
Timur Khazamov 2020-01-15 20:42:02 +05:00 committed by Christopher Chedeau
parent 8104c8525d
commit 79aee53ff6
37 changed files with 1043 additions and 746 deletions

View File

@ -2,43 +2,46 @@ import React from "react";
import { Action } from "./types"; import { Action } from "./types";
import { ColorPicker } from "../components/ColorPicker"; import { ColorPicker } from "../components/ColorPicker";
import { getDefaultAppState } from "../appState"; import { getDefaultAppState } from "../appState";
import { trash } from "../components/icons";
import { ToolIcon } from "../components/ToolIcon";
export const actionChangeViewBackgroundColor: Action = { export const actionChangeViewBackgroundColor: Action = {
name: "changeViewBackgroundColor", name: "changeViewBackgroundColor",
perform: (elements, appState, value) => { perform: (elements, appState, value) => {
return { appState: { ...appState, viewBackgroundColor: value } }; return { appState: { ...appState, viewBackgroundColor: value } };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => {
<> return (
<h5>Canvas Background Color</h5> <div style={{ position: "relative" }}>
<ColorPicker <ColorPicker
type="canvasBackground" type="canvasBackground"
color={appState.viewBackgroundColor} color={appState.viewBackgroundColor}
onChange={color => updateData(color)} onChange={color => updateData(color)}
/> />
</> </div>
) );
}
}; };
export const actionClearCanvas: Action = { export const actionClearCanvas: Action = {
name: "clearCanvas", name: "clearCanvas",
perform: (elements, appState, value) => { perform: () => {
return { return {
elements: [], elements: [],
appState: getDefaultAppState() appState: getDefaultAppState()
}; };
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<button <ToolIcon
type="button" type="button"
icon={trash}
title="Clear the canvas & reset background color"
aria-label="Clear the canvas & reset background color"
onClick={() => { onClick={() => {
if (window.confirm("This will clear the whole canvas. Are you sure?")) { if (window.confirm("This will clear the whole canvas. Are you sure?")) {
updateData(null); updateData(null);
} }
}} }}
title="Clear the canvas & reset background color" />
>
Clear canvas
</button>
) )
}; };

View File

@ -2,6 +2,8 @@ import React from "react";
import { Action } from "./types"; import { Action } from "./types";
import { EditableText } from "../components/EditableText"; import { EditableText } from "../components/EditableText";
import { saveAsJSON, loadFromJSON } from "../scene"; import { saveAsJSON, loadFromJSON } from "../scene";
import { load, save } from "../components/icons";
import { ToolIcon } from "../components/ToolIcon";
export const actionChangeProjectName: Action = { export const actionChangeProjectName: Action = {
name: "changeProjectName", name: "changeProjectName",
@ -9,15 +11,10 @@ export const actionChangeProjectName: Action = {
return { appState: { ...appState, name: value } }; return { appState: { ...appState, name: value } };
}, },
PanelComponent: ({ appState, updateData }) => ( PanelComponent: ({ appState, updateData }) => (
<>
<h5>Name</h5>
{appState.name && (
<EditableText <EditableText
value={appState.name} value={appState.name || "Unnamed"}
onChange={(name: string) => updateData(name)} onChange={(name: string) => updateData(name)}
/> />
)}
</>
) )
}; };
@ -34,8 +31,8 @@ export const actionChangeExportBackground: Action = {
onChange={e => { onChange={e => {
updateData(e.target.checked); updateData(e.target.checked);
}} }}
/> />{" "}
background With background
</label> </label>
) )
}; };
@ -47,7 +44,13 @@ export const actionSaveScene: Action = {
return {}; return {};
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<button onClick={() => updateData(null)}>Save as...</button> <ToolIcon
type="button"
icon={save}
title="Save"
aria-label="Save"
onClick={() => updateData(null)}
/>
) )
}; };
@ -57,14 +60,16 @@ export const actionLoadScene: Action = {
return { elements: loadedElements }; return { elements: loadedElements };
}, },
PanelComponent: ({ updateData }) => ( PanelComponent: ({ updateData }) => (
<button <ToolIcon
type="button"
icon={load}
title="Load"
aria-label="Load"
onClick={() => { onClick={() => {
loadFromJSON().then(({ elements }) => { loadFromJSON().then(({ elements }) => {
updateData(elements); updateData(elements);
}); });
}} }}
> />
Load file...
</button>
) )
}; };

View File

@ -3,8 +3,8 @@ import { Action } from "./types";
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types"; import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
import { getSelectedAttribute } from "../scene"; import { getSelectedAttribute } from "../scene";
import { ButtonSelect } from "../components/ButtonSelect"; import { ButtonSelect } from "../components/ButtonSelect";
import { PanelColor } from "../components/panels/PanelColor";
import { isTextElement, redrawTextBoundingBox } from "../element"; import { isTextElement, redrawTextBoundingBox } from "../element";
import { ColorPicker } from "../components/ColorPicker";
const changeProperty = ( const changeProperty = (
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
@ -31,17 +31,14 @@ export const actionChangeStrokeColor: Action = {
}; };
}, },
PanelComponent: ({ elements, appState, updateData }) => ( PanelComponent: ({ elements, appState, updateData }) => (
<PanelColor <>
title="Stroke Color" <h5>Stroke</h5>
colorType="elementStroke" <ColorPicker
onColorChange={(color: string) => { type="elementStroke"
updateData(color); color={getSelectedAttribute(elements, element => element.strokeColor)}
}} onChange={updateData}
colorValue={getSelectedAttribute(
elements,
element => element.strokeColor
)}
/> />
</>
) )
}; };
@ -58,17 +55,17 @@ export const actionChangeBackgroundColor: Action = {
}; };
}, },
PanelComponent: ({ elements, updateData }) => ( PanelComponent: ({ elements, updateData }) => (
<PanelColor <>
title="Background Color" <h5>Background</h5>
colorType="elementBackground" <ColorPicker
onColorChange={(color: string) => { type="elementBackground"
updateData(color); color={getSelectedAttribute(
}}
colorValue={getSelectedAttribute(
elements, elements,
element => element.backgroundColor element => element.backgroundColor
)} )}
onChange={updateData}
/> />
</>
) )
}; };

View File

@ -52,7 +52,7 @@ export interface ActionsManagerInterface {
) => { label: string; action: () => void }[]; ) => { label: string; action: () => void }[];
renderAction: ( renderAction: (
name: string, name: string,
elements: ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
updater: UpdaterFn updater: UpdaterFn
) => React.ReactElement | null; ) => React.ReactElement | null;

View File

@ -42,6 +42,8 @@
float: left; float: left;
border-radius: 4px; border-radius: 4px;
margin: 0px 6px 6px 0px; margin: 0px 6px 6px 0px;
box-sizing: border-box;
border: 1px solid #ddd;
} }
.color-picker-swatch:focus { .color-picker-swatch:focus {
@ -87,3 +89,19 @@
float: left; float: left;
padding-left: 8px; 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;
}

View File

@ -75,7 +75,7 @@ export function ColorPicker({
return ( return (
<div> <div>
<button <button
className="swatch" className="color-picker-label-swatch"
style={color ? { backgroundColor: color } : undefined} style={color ? { backgroundColor: color } : undefined}
onClick={() => setActive(!isActive)} onClick={() => setActive(!isActive)}
/> />
@ -94,7 +94,7 @@ export function ColorPicker({
</React.Suspense> </React.Suspense>
<input <input
type="text" type="text"
className="swatch-input" className="color-picker-swatch-input"
value={color || ""} value={color || ""}
onPaste={e => onChange(e.clipboardData.getData("text"))} onPaste={e => onChange(e.clipboardData.getData("text"))}
onChange={e => onChange(e.target.value)} onChange={e => onChange(e.target.value)}

View File

@ -0,0 +1,18 @@
.project-name {
display: inline-block;
cursor: pointer;
border: none;
padding: 4px;
margin: -4px;
white-space: nowrap;
border-radius: var(--space-factor);
}
.project-name:hover {
background-color: #eee;
}
.project-name:focus {
outline: none;
box-shadow: 0 0 0 2px steelblue;
}

View File

@ -1,73 +1,44 @@
import React, { Fragment, Component } from "react"; import "./EditableText.css";
type InputState = { import React, { Component } from "react";
value: string; import { selectNode, removeSelection } from "../utils";
edit: boolean;
};
type Props = { type Props = {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
}; };
export class EditableText extends Component<Props, InputState> { export class EditableText extends Component<Props> {
constructor(props: Props) { private handleFocus = (e: React.FocusEvent<HTMLElement>) => {
super(props); selectNode(e.currentTarget);
this.state = {
value: props.value,
edit: false
}; };
}
UNSAFE_componentWillReceiveProps(props: Props) { private handleBlur = (e: React.FocusEvent<HTMLElement>) => {
this.setState({ value: props.value }); const value = e.currentTarget.innerText.trim();
} if (value !== this.props.value) this.props.onChange(value);
removeSelection();
};
private handleEdit(e: React.ChangeEvent<HTMLInputElement>) { private handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
this.setState({ value: e.target.value }); if (e.key === "Enter") {
} e.preventDefault();
e.currentTarget.blur();
private handleBlur() {
const { value } = this.state;
if (!value) {
this.setState({ value: this.props.value, edit: false });
return;
}
this.props.onChange(value);
this.setState({ edit: false });
} }
};
public render() { public render() {
const { value, edit } = this.state;
return ( return (
<Fragment>
{edit ? (
<input
className="project-name-input"
name="name"
maxLength={25}
value={value}
onChange={e => this.handleEdit(e)}
onBlur={() => this.handleBlur()}
onKeyDown={e => {
if (e.key === "Enter") {
this.handleBlur();
}
}}
autoFocus
/>
) : (
<span <span
onClick={() => this.setState({ edit: true })} suppressContentEditableWarning
contentEditable="true"
data-type="wysiwyg"
className="project-name" className="project-name"
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
onFocus={this.handleFocus}
> >
{value} {this.props.value}
</span> </span>
)}
</Fragment>
); );
} }
} }

View File

@ -0,0 +1,46 @@
.ExportDialog__dialog {
/* transition: opacity 0.15s ease-in, transform 0.15s ease-in; */
opacity: 0;
transform: translateY(10px);
animation: ExportDialog__fade-in 0.1s ease-out 0.05s forwards;
position: relative;
}
@keyframes ExportDialog__fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ExportDialog__close {
position: absolute;
right: calc(var(--space-factor) * 5);
top: calc(var(--space-factor) * 5);
}
.ExportDialog__preview {
--preview-padding: calc(var(--space-factor) * 4);
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==")
left center;
text-align: center;
padding: var(--preview-padding);
margin-bottom: calc(var(--space-factor) * 3);
}
.ExportDialog__preview canvas {
max-width: calc(100% - var(--preview-padding) * 2);
max-height: 400px;
}
.ExportDialog__actions {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}

View File

@ -0,0 +1,149 @@
import "./ExportDialog.css";
import React, { useState, useEffect, useRef } from "react";
import { Modal } from "./Modal";
import { ToolIcon } from "./ToolIcon";
import { clipboard, exportFile, downloadFile } from "./icons";
import { Island } from "./Island";
import { ExcalidrawElement } from "../element/types";
import { AppState } from "../types";
import { getExportCanvasPreview } from "../scene/data";
import { ActionsManagerInterface, UpdaterFn } from "../actions/types";
import Stack from "./Stack";
const probablySupportsClipboard =
"toBlob" in HTMLCanvasElement.prototype &&
"clipboard" in navigator &&
"write" in navigator.clipboard &&
"ClipboardItem" in window;
export function ExportDialog({
elements,
appState,
exportPadding = 10,
actionManager,
syncActionResult,
onExportToPng,
onExportToClipboard
}: {
appState: AppState;
elements: readonly ExcalidrawElement[];
exportPadding?: number;
actionManager: ActionsManagerInterface;
syncActionResult: UpdaterFn;
onExportToPng(elements: readonly ExcalidrawElement[]): void;
onExportToClipboard(elements: readonly ExcalidrawElement[]): void;
}) {
const someElementIsSelected = elements.some(element => element.isSelected);
const [modalIsShown, setModalIsShown] = useState(false);
const [exportSelected, setExportSelected] = useState(someElementIsSelected);
const previeRef = useRef<HTMLDivElement>(null);
const { exportBackground, viewBackgroundColor } = appState;
const exportedElements = exportSelected
? elements.filter(element => element.isSelected)
: elements;
useEffect(() => {
setExportSelected(someElementIsSelected);
}, [someElementIsSelected]);
useEffect(() => {
const previewNode = previeRef.current;
const canvas = getExportCanvasPreview(exportedElements, {
exportBackground,
viewBackgroundColor,
exportPadding
});
previewNode?.appendChild(canvas);
return () => {
previewNode?.removeChild(canvas);
};
}, [
modalIsShown,
exportedElements,
exportBackground,
exportPadding,
viewBackgroundColor
]);
function handleClose() {
setModalIsShown(false);
setExportSelected(someElementIsSelected);
}
return (
<>
<ToolIcon
onClick={() => setModalIsShown(true)}
icon={exportFile}
type="button"
aria-label="Show export dialog"
/>
{modalIsShown && (
<Modal maxWidth={640} onCloseRequest={handleClose}>
<div className="ExportDialog__dialog">
<Island padding={4}>
<button className="ExportDialog__close" onClick={handleClose}>
</button>
<h2>Export</h2>
<div className="ExportDialog__preview" ref={previeRef}></div>
<div className="ExportDialog__actions">
<Stack.Row gap={2}>
<ToolIcon
type="button"
icon={downloadFile}
title="Export to PNG"
aria-label="Export to PNG"
onClick={() => onExportToPng(exportedElements)}
/>
{probablySupportsClipboard && (
<ToolIcon
type="button"
icon={clipboard}
title="Copy to clipboard"
aria-label="Copy to clipboard"
onClick={() => onExportToClipboard(exportedElements)}
/>
)}
</Stack.Row>
{actionManager.renderAction(
"changeProjectName",
elements,
appState,
syncActionResult
)}
<Stack.Col gap={1}>
{actionManager.renderAction(
"changeExportBackground",
elements,
appState,
syncActionResult
)}
{someElementIsSelected && (
<div>
<label>
<input
type="checkbox"
checked={exportSelected}
onChange={e =>
setExportSelected(e.currentTarget.checked)
}
/>{" "}
Only selected
</label>
</div>
)}
</Stack.Col>
</div>
</Island>
</div>
</Modal>
)}
</>
);
}

View File

@ -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;
}

View File

@ -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 (
<div className={"FixedSideContainer FixedSideContainer_side_" + side}>
{children}
</div>
);
}

View File

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

16
src/components/Island.tsx Normal file
View File

@ -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 (
<div
className="Island"
style={{ "--padding": padding } as React.CSSProperties}
>
{children}
</div>
);
}

29
src/components/Modal.css Normal file
View File

@ -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%;
}

36
src/components/Modal.tsx Normal file
View File

@ -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(
<div className="Modal">
<div className="Modal__background" onClick={props.onCloseRequest}></div>
<div className="Modal__content" style={{ maxWidth: props.maxWidth }}>
{props.children}
</div>
</div>,
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;
}

View File

@ -1,43 +0,0 @@
import React, { useState } from "react";
interface PanelProps {
title: string;
defaultCollapsed?: boolean;
hide?: boolean;
}
export const Panel: React.FC<PanelProps> = ({
title,
children,
defaultCollapsed = false,
hide = false
}) => {
const [collapsed, setCollapsed] = useState(defaultCollapsed);
if (hide) return null;
return (
<div className="panel">
<h4>{title}</h4>
<button
className="btn-panel-collapse"
type="button"
onClick={e => {
e.preventDefault();
setCollapsed(collapsed => !collapsed);
}}
>
{
<span
className={`btn-panel-collapse-icon ${
collapsed ? "btn-panel-collapse-icon-closed" : ""
}`}
>
</span>
}
</button>
{!collapsed && <div className="panelColumn">{children}</div>}
</div>
);
};

View File

@ -0,0 +1,12 @@
.popover {
position: absolute;
z-index: 10;
}
.popover .cover {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}

View File

@ -1,4 +1,5 @@
import React, { useLayoutEffect, useRef } from "react"; import React, { useLayoutEffect, useRef } from "react";
import "./Popover.css";
type Props = { type Props = {
top?: number; top?: number;

View File

@ -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<SidePanelProps> = ({
actionManager,
syncActionResult,
elements,
onToolChange,
appState,
canvas
}) => {
return (
<div className="sidePanel">
<PanelTools
activeTool={appState.elementType}
onToolChange={value => {
onToolChange(value);
}}
/>
<Panel title="Selection" hide={!someElementIsSelected(elements)}>
<PanelSelection
actionManager={actionManager}
syncActionResult={syncActionResult}
elements={elements}
appState={appState}
/>
{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
)}
</Panel>
<PanelCanvas
actionManager={actionManager}
syncActionResult={syncActionResult}
elements={elements}
appState={appState}
/>
<PanelExport
actionManager={actionManager}
syncActionResult={syncActionResult}
elements={elements}
appState={appState}
onExportCanvas={(type: ExportType) => {
const exportedElements = elements.some(element => element.isSelected)
? elements.filter(element => element.isSelected)
: elements;
return exportCanvas(type, exportedElements, canvas, appState);
}}
/>
</div>
);
};

17
src/components/Stack.css Normal file
View File

@ -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;
}

36
src/components/Stack.tsx Normal file
View File

@ -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 (
<div
className="Stack Stack_horizontal"
style={{ "--gap": gap, alignItems: align } as React.CSSProperties}
>
{children}
</div>
);
}
function ColStack({ children, gap, align }: StackProps) {
return (
<div
className="Stack Stack_vertical"
style={{ "--gap": gap, justifyItems: align } as React.CSSProperties}
>
{children}
</div>
);
}
export default {
Row: RowStack,
Col: ColStack
};

View File

@ -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;
}
}

View File

@ -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 (
<label className="ToolIcon" title={props.title}>
<button
className="ToolIcon_type_button"
aria-label={props["aria-label"]}
type="button"
onClick={props.onClick}
>
<div className="ToolIcon__icon">{props.icon}</div>
</button>
</label>
);
return (
<label className="ToolIcon" title={props.title}>
<input
className="ToolIcon_type_radio"
type="radio"
name={props.name}
id={props.id}
onChange={props.onChange}
checked={props.checked}
/>
<div className="ToolIcon__icon">{props.icon}</div>
</label>
);
}

78
src/components/icons.tsx Normal file
View File

@ -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 = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M433.941 129.941l-83.882-83.882A48 48 0 0 0 316.118 32H48C21.49 32 0 53.49 0 80v352c0 26.51 21.49 48 48 48h352c26.51 0 48-21.49 48-48V163.882a48 48 0 0 0-14.059-33.941zM224 416c-35.346 0-64-28.654-64-64 0-35.346 28.654-64 64-64s64 28.654 64 64c0 35.346-28.654 64-64 64zm96-304.52V212c0 6.627-5.373 12-12 12H76c-6.627 0-12-5.373-12-12V108c0-6.627 5.373-12 12-12h228.52c3.183 0 6.235 1.264 8.485 3.515l3.48 3.48A11.996 11.996 0 0 1 320 111.48z"
/>
</svg>
);
export const load = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512">
<path
fill="currentColor"
d="M572.694 292.093L500.27 416.248A63.997 63.997 0 0 1 444.989 448H45.025c-18.523 0-30.064-20.093-20.731-36.093l72.424-124.155A64 64 0 0 1 152 256h399.964c18.523 0 30.064 20.093 20.73 36.093zM152 224h328v-48c0-26.51-21.49-48-48-48H272l-64-64H48C21.49 64 0 85.49 0 112v278.046l69.077-118.418C86.214 242.25 117.989 224 152 224z"
/>
</svg>
);
export const image = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
<path
fill="currentColor"
d="M384 121.941V128H256V0h6.059a24 24 0 0 1 16.97 7.029l97.941 97.941a24.002 24.002 0 0 1 7.03 16.971zM248 160c-13.2 0-24-10.8-24-24V0H24C10.745 0 0 10.745 0 24v464c0 13.255 10.745 24 24 24h336c13.255 0 24-10.745 24-24V160H248zm-135.455 16c26.51 0 48 21.49 48 48s-21.49 48-48 48-48-21.49-48-48 21.491-48 48-48zm208 240h-256l.485-48.485L104.545 328c4.686-4.686 11.799-4.201 16.485.485L160.545 368 264.06 264.485c4.686-4.686 12.284-4.686 16.971 0L320.545 304v112z"
/>
</svg>
);
export const clipboard = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
<path
fill="currentColor"
d="M384 112v352c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h80c0-35.29 28.71-64 64-64s64 28.71 64 64h80c26.51 0 48 21.49 48 48zM192 40c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24m96 114v-20a6 6 0 0 0-6-6H102a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h180a6 6 0 0 0 6-6z"
/>
</svg>
);
export const trash = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 448 512">
<path
fill="currentColor"
d="M32 464a48 48 0 0 0 48 48h288a48 48 0 0 0 48-48V128H32zm272-256a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zm-96 0a16 16 0 0 1 32 0v224a16 16 0 0 1-32 0zM432 32H312l-9.4-18.7A24 24 0 0 0 281.1 0H166.8a23.72 23.72 0 0 0-21.4 13.3L136 32H16A16 16 0 0 0 0 48v32a16 16 0 0 0 16 16h416a16 16 0 0 0 16-16V48a16 16 0 0 0-16-16z"
/>
</svg>
);
export const palete = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 512 512">
<path
fill="currentColor"
d="M204.3 5C104.9 24.4 24.8 104.3 5.2 203.4c-37 187 131.7 326.4 258.8 306.7 41.2-6.4 61.4-54.6 42.5-91.7-23.1-45.4 9.9-98.4 60.9-98.4h79.7c35.8 0 64.8-29.6 64.9-65.3C511.5 97.1 368.1-26.9 204.3 5zM96 320c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm32-128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128-64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32zm128 64c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"
/>
</svg>
);
export const exportFile = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 576 512">
<path
fill="currentColor"
d="M384 121.9c0-6.3-2.5-12.4-7-16.9L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128zM571 308l-95.7-96.4c-10.1-10.1-27.4-3-27.4 11.3V288h-64v64h64v65.2c0 14.3 17.3 21.4 27.4 11.3L571 332c6.6-6.6 6.6-17.4 0-24zm-379 28v-32c0-8.8 7.2-16 16-16h176V160H248c-13.2 0-24-10.8-24-24V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V352H208c-8.8 0-16-7.2-16-16z"
/>
</svg>
);
export const downloadFile = (
<svg aria-hidden="true" focusable="false" role="img" viewBox="0 0 384 512">
<path
fill="currentColor"
d="M224 136V0H24C10.7 0 0 10.7 0 24v464c0 13.3 10.7 24 24 24h336c13.3 0 24-10.7 24-24V160H248c-13.2 0-24-10.8-24-24zm76.45 211.36l-96.42 95.7c-6.65 6.61-17.39 6.61-24.04 0l-96.42-95.7C73.42 337.29 80.54 320 94.82 320H160v-80c0-8.84 7.16-16 16-16h32c8.84 0 16 7.16 16 16v80h65.18c14.28 0 21.4 17.29 11.27 27.36zM377 105L279.1 7c-4.5-4.5-10.6-7-17-7H256v128h128v-6.1c0-6.3-2.5-12.4-7-16.9z"
/>
</svg>
);

View File

@ -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<PanelCanvasProps> = ({
actionManager,
elements,
appState,
syncActionResult
}) => {
return (
<Panel title="Canvas">
{actionManager.renderAction(
"changeViewBackgroundColor",
elements,
appState,
syncActionResult
)}
{actionManager.renderAction(
"clearCanvas",
elements,
appState,
syncActionResult
)}
</Panel>
);
};

View File

@ -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<PanelColorProps> = ({
title,
colorType,
onColorChange,
colorValue
}) => {
return (
<>
<h5>{title}</h5>
<ColorPicker
type={colorType}
color={colorValue}
onChange={color => onColorChange(color)}
/>
</>
);
};

View File

@ -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 = () => (
<svg viewBox="0 0 384 512">
<path
fill="currentColor"
d="M384 112v352c0 26.51-21.49 48-48 48H48c-26.51 0-48-21.49-48-48V112c0-26.51 21.49-48 48-48h80c0-35.29 28.71-64 64-64s64 28.71 64 64h80c26.51 0 48 21.49 48 48zM192 40c-13.255 0-24 10.745-24 24s10.745 24 24 24 24-10.745 24-24-10.745-24-24-24m96 114v-20a6 6 0 0 0-6-6H102a6 6 0 0 0-6 6v20a6 6 0 0 0 6 6h180a6 6 0 0 0 6-6z"
></path>
</svg>
);
const probablySupportsClipboard =
"toBlob" in HTMLCanvasElement.prototype &&
"clipboard" in navigator &&
"write" in navigator.clipboard &&
"ClipboardItem" in window;
export const PanelExport: React.FC<PanelExportProps> = ({
actionManager,
elements,
appState,
syncActionResult,
onExportCanvas
}) => {
return (
<Panel title="Export">
<div className="panelColumn">
{actionManager.renderAction(
"changeProjectName",
elements,
appState,
syncActionResult
)}
<h5>Image</h5>
<div className="panelExport-imageButtons">
<button
className="panelExport-exportToPngButton"
onClick={() => onExportCanvas("png")}
>
Export to PNG
</button>
{probablySupportsClipboard && (
<button
className="panelExport-exportToClipboardButton"
onClick={() => onExportCanvas("clipboard")}
title="Copy to clipboard (experimental)"
>
<ClipboardIcon />
</button>
)}
</div>
{actionManager.renderAction(
"changeExportBackground",
elements,
appState,
syncActionResult
)}
<h5>Scene</h5>
{actionManager.renderAction(
"saveScene",
elements,
appState,
syncActionResult
)}
{actionManager.renderAction(
"loadScene",
elements,
appState,
syncActionResult
)}
</div>
</Panel>
);
};

View File

@ -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<PanelSelectionProps> = ({
actionManager,
elements,
appState,
syncActionResult
}) => {
return (
<div>
<div className="buttonList">
{actionManager.renderAction(
"bringForward",
elements,
appState,
syncActionResult
)}
{actionManager.renderAction(
"bringToFront",
elements,
appState,
syncActionResult
)}
{actionManager.renderAction(
"sendBackward",
elements,
appState,
syncActionResult
)}
{actionManager.renderAction(
"sendToBack",
elements,
appState,
syncActionResult
)}
</div>
</div>
);
};

View File

@ -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<PanelToolsProps> = ({
activeTool,
onToolChange
}) => {
return (
<Panel title="Shapes">
<div className="panelTools">
{SHAPES.map(({ value, icon }) => (
<label
key={value}
className="tool"
title={`${capitalizeString(value)} - ${capitalizeString(value)[0]}`}
>
<input
type="radio"
checked={activeTool === value}
onChange={() => {
onToolChange(value);
}}
/>
<div className="toolIcon">{icon}</div>
</label>
))}
</div>
</Panel>
);
};

View File

@ -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;
}
}

View File

@ -1,4 +1,5 @@
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { selectNode } from "../utils";
type TextWysiwygParams = { type TextWysiwygParams = {
initText: string; initText: string;
@ -89,11 +90,5 @@ export function textWysiwyg({
window.addEventListener("wheel", stopEvent, true); window.addEventListener("wheel", stopEvent, true);
document.body.appendChild(editable); document.body.appendChild(editable);
editable.focus(); editable.focus();
const selection = window.getSelection(); selectNode(editable);
if (selection) {
const range = document.createRange();
range.selectNodeContents(editable);
selection.removeAllRanges();
selection.addRange(range);
}
} }

View File

@ -21,17 +21,21 @@ import {
saveToLocalStorage, saveToLocalStorage,
getElementAtPosition, getElementAtPosition,
createScene, createScene,
getElementContainingPosition getElementContainingPosition,
hasBackground,
hasStroke,
hasText,
exportCanvas
} from "./scene"; } from "./scene";
import { renderScene } from "./renderer"; import { renderScene } from "./renderer";
import { AppState } from "./types"; import { AppState } from "./types";
import { ExcalidrawElement, ExcalidrawTextElement } from "./element/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 { KEYS, META_KEY, isArrowKey } from "./keys";
import { findShapeByKey, shapesShortcutKeys } from "./shapes"; import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
import { createHistory } from "./history"; import { createHistory } from "./history";
import ContextMenu from "./components/ContextMenu"; import ContextMenu from "./components/ContextMenu";
@ -63,14 +67,18 @@ import {
actionCopyStyles, actionCopyStyles,
actionPasteStyles actionPasteStyles
} from "./actions"; } from "./actions";
import { SidePanel } from "./components/SidePanel";
import { Action, ActionResult } from "./actions/types"; import { Action, ActionResult } from "./actions/types";
import { getDefaultAppState } from "./appState"; 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(); let { elements } = createScene();
const { history } = createHistory(); const { history } = createHistory();
const CANVAS_WINDOW_OFFSET_LEFT = 250; const CANVAS_WINDOW_OFFSET_LEFT = 0;
const CANVAS_WINDOW_OFFSET_TOP = 0; const CANVAS_WINDOW_OFFSET_TOP = 0;
function resetCursor() { function resetCursor() {
@ -331,26 +339,197 @@ export class App extends React.Component<{}, AppState> {
} }
}; };
public render() { private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT; const selectedElements = elements.filter(el => el.isSelected);
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP; if (selectedElements.length === 0) {
return null;
}
return ( return (
<div className="container"> <Island padding={4}>
<SidePanel <div className="panelColumn">
actionManager={this.actionManager} {this.actionManager.renderAction(
syncActionResult={this.syncActionResult} "changeStrokeColor",
appState={{ ...this.state }} elements,
elements={elements} this.state,
onToolChange={value => { this.syncActionResult
)}
{hasBackground(elements) && (
<>
{this.actionManager.renderAction(
"changeBackgroundColor",
elements,
this.state,
this.syncActionResult
)}
{this.actionManager.renderAction(
"changeFillStyle",
elements,
this.state,
this.syncActionResult
)}
<hr />
</>
)}
{hasStroke(elements) && (
<>
{this.actionManager.renderAction(
"changeStrokeWidth",
elements,
this.state,
this.syncActionResult
)}
{this.actionManager.renderAction(
"changeSloppiness",
elements,
this.state,
this.syncActionResult
)}
<hr />
</>
)}
{hasText(elements) && (
<>
{this.actionManager.renderAction(
"changeFontSize",
elements,
this.state,
this.syncActionResult
)}
{this.actionManager.renderAction(
"changeFontFamily",
elements,
this.state,
this.syncActionResult
)}
<hr />
</>
)}
{this.actionManager.renderAction(
"changeOpacity",
elements,
this.state,
this.syncActionResult
)}
{this.actionManager.renderAction(
"deleteSelectedElements",
elements,
this.state,
this.syncActionResult
)}
</div>
</Island>
);
}
private renderShapesSwitcher() {
return (
<>
{SHAPES.map(({ value, icon }) => (
<ToolIcon
key={value}
type="radio"
icon={icon}
checked={this.state.elementType === value}
name="editor-current-shape"
title={`${capitalizeString(value)}${capitalizeString(value)[0]}`}
onChange={() => {
this.setState({ elementType: value }); this.setState({ elementType: value });
elements = clearSelection(elements); elements = clearSelection(elements);
document.documentElement.style.cursor = document.documentElement.style.cursor =
value === "text" ? "text" : "crosshair"; value === "text" ? "text" : "crosshair";
this.forceUpdate(); this.forceUpdate();
}} }}
canvas={this.canvas!} ></ToolIcon>
))}
</>
);
}
private renderCanvasActions() {
return (
<Stack.Col gap={4}>
<Stack.Row gap={1}>
{this.actionManager.renderAction(
"loadScene",
elements,
this.state,
this.syncActionResult
)}
{this.actionManager.renderAction(
"saveScene",
elements,
this.state,
this.syncActionResult
)}
<ExportDialog
elements={elements}
appState={this.state}
actionManager={this.actionManager}
syncActionResult={this.syncActionResult}
onExportToPng={exportedElements => {
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
)}
</Stack.Row>
{this.actionManager.renderAction(
"changeViewBackgroundColor",
elements,
this.state,
this.syncActionResult
)}
</Stack.Col>
);
}
public render() {
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
return (
<div className="container">
<FixedSideContainer side="top">
<div className="App-menu App-menu_top">
<Stack.Col gap={4} align="end">
<div className="App-right-menu">
<Island padding={4}>{this.renderCanvasActions()}</Island>
</div>
<div className="App-right-menu">
{this.renderSelectedShapeActions(elements)}
</div>
</Stack.Col>
<Stack.Col gap={4} align="start">
<Island padding={1}>
<Stack.Row gap={1}>{this.renderShapesSwitcher()}</Stack.Row>
</Island>
</Stack.Col>
<div />
</div>
</FixedSideContainer>
<canvas <canvas
id="canvas" id="canvas"
style={{ style={{
@ -374,7 +553,6 @@ export class App extends React.Component<{}, AppState> {
}); });
this.removeWheelEventListener = () => this.removeWheelEventListener = () =>
canvas.removeEventListener("wheel", this.handleWheel); canvas.removeEventListener("wheel", this.handleWheel);
// Whenever React sets the width/height of the canvas element, // Whenever React sets the width/height of the canvas element,
// the context loses the scale transform. We need to re-apply it // the context loses the scale transform. We need to re-apply it
if ( if (

View File

@ -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( export function exportCanvas(
type: ExportType, type: ExportType,
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],

View File

@ -1,3 +1,5 @@
@import "./theme.css";
/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */ /* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
@font-face { @font-face {
font-family: "Virgil"; font-family: "Virgil";
@ -8,6 +10,7 @@
body { body {
margin: 0; margin: 0;
font-family: Arial, Helvetica, sans-serif; font-family: Arial, Helvetica, sans-serif;
color: var(--text-color-primary);
} }
.container { .container {
@ -19,49 +22,7 @@ body {
right: 0; right: 0;
} }
.sidePanel { .panelColumn {
width: 230px;
background-color: #eee;
padding: 10px;
overflow-y: auto;
position: relative;
h4 {
margin: 10px 0 10px 0;
}
.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);
}
}
.panelTools {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
label {
margin: 2px 0;
}
}
.panelColumn {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -83,74 +44,17 @@ body {
margin-right: 4px; margin-right: 4px;
} }
} }
}
} }
.tool { .divider {
position: relative; width: 1px;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
input[type="radio"] {
& + .toolIcon {
background-color: #ddd; background-color: #ddd;
margin: 1px;
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;
}
}
} }
label { input:focus {
margin-right: 6px;
span {
display: inline-block;
}
}
input[type="number"] {
width: 30px;
}
input[type="color"] {
margin: 2px;
}
input[type="range"] {
width: 230px;
}
input {
margin-right: 5px;
&:focus {
outline: transparent; outline: transparent;
box-shadow: 0 0 0 2px steelblue; box-shadow: 0 0 0 2px steelblue;
}
} }
button { button {
@ -170,8 +74,7 @@ button {
border-color: #d6d4d4; border-color: #d6d4d4;
} }
&:active, &:active {
&.active {
background-color: #bdbebc; background-color: #bdbebc;
border-color: #bdbebc; border-color: #bdbebc;
} }
@ -181,40 +84,39 @@ button {
} }
} }
.popover { .App-menu {
position: absolute; display: grid;
z-index: 2;
.cover {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
} }
.swatch { .App-menu_top {
height: 24px; grid-template-columns: 1fr auto 1fr;
width: 24px; align-items: flex-start;
display: inline; cursor: default;
margin-right: 4px; pointer-events: none !important;
} }
.swatch-input { .App-menu_top > * {
font-size: 16px; pointer-events: all;
display: inline;
width: 100px;
border-radius: 2px;
padding: 2px 4px;
border: 1px solid #ddd;
}
.project-name {
font-size: 14px;
cursor: pointer;
} }
.project-name-input { .App-menu_top > *:first-child {
width: 200px; justify-self: flex-start;
font: inherit; }
.App-menu_top > *:last-child {
justify-self: flex-end;
}
.App-menu_left {
grid-template-rows: 1fr auto 1fr;
height: 100%;
}
.App-menu_right {
grid-template-rows: 1fr;
height: 100%;
}
.App-right-menu {
width: 220px;
} }

11
src/theme.css Normal file
View File

@ -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;
}

View File

@ -62,3 +62,20 @@ export function debounce<T extends any[]>(
handle = window.setTimeout(() => fn(...args), timeout); 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();
}
}