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:
parent
8104c8525d
commit
79aee53ff6
@ -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>
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
@ -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>
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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)}
|
||||||
|
18
src/components/EditableText.css
Normal file
18
src/components/EditableText.css
Normal 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;
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
46
src/components/ExportDialog.css
Normal file
46
src/components/ExportDialog.css
Normal 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;
|
||||||
|
}
|
149
src/components/ExportDialog.tsx
Normal file
149
src/components/ExportDialog.tsx
Normal 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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
30
src/components/FixedSideContainer.css
Normal file
30
src/components/FixedSideContainer.css
Normal 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;
|
||||||
|
}
|
19
src/components/FixedSideContainer.tsx
Normal file
19
src/components/FixedSideContainer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
7
src/components/Island.css
Normal file
7
src/components/Island.css
Normal 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
16
src/components/Island.tsx
Normal 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
29
src/components/Modal.css
Normal 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
36
src/components/Modal.tsx
Normal 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;
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
12
src/components/Popover.css
Normal file
12
src/components/Popover.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.popover {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover .cover {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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
17
src/components/Stack.css
Normal 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
36
src/components/Stack.tsx
Normal 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
|
||||||
|
};
|
53
src/components/ToolIcon.scss
Normal file
53
src/components/ToolIcon.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
53
src/components/ToolIcon.tsx
Normal file
53
src/components/ToolIcon.tsx
Normal 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
78
src/components/icons.tsx
Normal 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>
|
||||||
|
);
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
212
src/index.tsx
212
src/index.tsx
@ -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 (
|
||||||
|
@ -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[],
|
||||||
|
174
src/styles.scss
174
src/styles.scss
@ -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
11
src/theme.css
Normal 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;
|
||||||
|
}
|
17
src/utils.ts
17
src/utils.ts
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user