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 { ColorPicker } from "../components/ColorPicker";
|
||||
import { getDefaultAppState } from "../appState";
|
||||
import { trash } from "../components/icons";
|
||||
import { ToolIcon } from "../components/ToolIcon";
|
||||
|
||||
export const actionChangeViewBackgroundColor: Action = {
|
||||
name: "changeViewBackgroundColor",
|
||||
perform: (elements, appState, value) => {
|
||||
return { appState: { ...appState, viewBackgroundColor: value } };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<>
|
||||
<h5>Canvas Background Color</h5>
|
||||
PanelComponent: ({ appState, updateData }) => {
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<ColorPicker
|
||||
type="canvasBackground"
|
||||
color={appState.viewBackgroundColor}
|
||||
onChange={color => updateData(color)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const actionClearCanvas: Action = {
|
||||
name: "clearCanvas",
|
||||
perform: (elements, appState, value) => {
|
||||
perform: () => {
|
||||
return {
|
||||
elements: [],
|
||||
appState: getDefaultAppState()
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<button
|
||||
<ToolIcon
|
||||
type="button"
|
||||
icon={trash}
|
||||
title="Clear the canvas & reset background color"
|
||||
aria-label="Clear the canvas & reset background color"
|
||||
onClick={() => {
|
||||
if (window.confirm("This will clear the whole canvas. Are you sure?")) {
|
||||
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 { EditableText } from "../components/EditableText";
|
||||
import { saveAsJSON, loadFromJSON } from "../scene";
|
||||
import { load, save } from "../components/icons";
|
||||
import { ToolIcon } from "../components/ToolIcon";
|
||||
|
||||
export const actionChangeProjectName: Action = {
|
||||
name: "changeProjectName",
|
||||
@ -9,15 +11,10 @@ export const actionChangeProjectName: Action = {
|
||||
return { appState: { ...appState, name: value } };
|
||||
},
|
||||
PanelComponent: ({ appState, updateData }) => (
|
||||
<>
|
||||
<h5>Name</h5>
|
||||
{appState.name && (
|
||||
<EditableText
|
||||
value={appState.name}
|
||||
value={appState.name || "Unnamed"}
|
||||
onChange={(name: string) => updateData(name)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
@ -34,8 +31,8 @@ export const actionChangeExportBackground: Action = {
|
||||
onChange={e => {
|
||||
updateData(e.target.checked);
|
||||
}}
|
||||
/>
|
||||
background
|
||||
/>{" "}
|
||||
With background
|
||||
</label>
|
||||
)
|
||||
};
|
||||
@ -47,7 +44,13 @@ export const actionSaveScene: Action = {
|
||||
return {};
|
||||
},
|
||||
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 };
|
||||
},
|
||||
PanelComponent: ({ updateData }) => (
|
||||
<button
|
||||
<ToolIcon
|
||||
type="button"
|
||||
icon={load}
|
||||
title="Load"
|
||||
aria-label="Load"
|
||||
onClick={() => {
|
||||
loadFromJSON().then(({ elements }) => {
|
||||
updateData(elements);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Load file...
|
||||
</button>
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
@ -3,8 +3,8 @@ import { Action } from "./types";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "../element/types";
|
||||
import { getSelectedAttribute } from "../scene";
|
||||
import { ButtonSelect } from "../components/ButtonSelect";
|
||||
import { PanelColor } from "../components/panels/PanelColor";
|
||||
import { isTextElement, redrawTextBoundingBox } from "../element";
|
||||
import { ColorPicker } from "../components/ColorPicker";
|
||||
|
||||
const changeProperty = (
|
||||
elements: readonly ExcalidrawElement[],
|
||||
@ -31,17 +31,14 @@ export const actionChangeStrokeColor: Action = {
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, appState, updateData }) => (
|
||||
<PanelColor
|
||||
title="Stroke Color"
|
||||
colorType="elementStroke"
|
||||
onColorChange={(color: string) => {
|
||||
updateData(color);
|
||||
}}
|
||||
colorValue={getSelectedAttribute(
|
||||
elements,
|
||||
element => element.strokeColor
|
||||
)}
|
||||
<>
|
||||
<h5>Stroke</h5>
|
||||
<ColorPicker
|
||||
type="elementStroke"
|
||||
color={getSelectedAttribute(elements, element => element.strokeColor)}
|
||||
onChange={updateData}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
@ -58,17 +55,17 @@ export const actionChangeBackgroundColor: Action = {
|
||||
};
|
||||
},
|
||||
PanelComponent: ({ elements, updateData }) => (
|
||||
<PanelColor
|
||||
title="Background Color"
|
||||
colorType="elementBackground"
|
||||
onColorChange={(color: string) => {
|
||||
updateData(color);
|
||||
}}
|
||||
colorValue={getSelectedAttribute(
|
||||
<>
|
||||
<h5>Background</h5>
|
||||
<ColorPicker
|
||||
type="elementBackground"
|
||||
color={getSelectedAttribute(
|
||||
elements,
|
||||
element => element.backgroundColor
|
||||
)}
|
||||
onChange={updateData}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
};
|
||||
|
||||
|
@ -52,7 +52,7 @@ export interface ActionsManagerInterface {
|
||||
) => { label: string; action: () => void }[];
|
||||
renderAction: (
|
||||
name: string,
|
||||
elements: ExcalidrawElement[],
|
||||
elements: readonly ExcalidrawElement[],
|
||||
appState: AppState,
|
||||
updater: UpdaterFn
|
||||
) => React.ReactElement | null;
|
||||
|
@ -42,6 +42,8 @@
|
||||
float: left;
|
||||
border-radius: 4px;
|
||||
margin: 0px 6px 6px 0px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.color-picker-swatch:focus {
|
||||
@ -87,3 +89,19 @@
|
||||
float: left;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.color-picker-label-swatch {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.color-picker-swatch-input {
|
||||
font-size: 16px;
|
||||
display: inline-block;
|
||||
width: 100px;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ export function ColorPicker({
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="swatch"
|
||||
className="color-picker-label-swatch"
|
||||
style={color ? { backgroundColor: color } : undefined}
|
||||
onClick={() => setActive(!isActive)}
|
||||
/>
|
||||
@ -94,7 +94,7 @@ export function ColorPicker({
|
||||
</React.Suspense>
|
||||
<input
|
||||
type="text"
|
||||
className="swatch-input"
|
||||
className="color-picker-swatch-input"
|
||||
value={color || ""}
|
||||
onPaste={e => onChange(e.clipboardData.getData("text"))}
|
||||
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 = {
|
||||
value: string;
|
||||
edit: boolean;
|
||||
};
|
||||
import React, { Component } from "react";
|
||||
import { selectNode, removeSelection } from "../utils";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export class EditableText extends Component<Props, InputState> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
value: props.value,
|
||||
edit: false
|
||||
export class EditableText extends Component<Props> {
|
||||
private handleFocus = (e: React.FocusEvent<HTMLElement>) => {
|
||||
selectNode(e.currentTarget);
|
||||
};
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps(props: Props) {
|
||||
this.setState({ value: props.value });
|
||||
}
|
||||
private handleBlur = (e: React.FocusEvent<HTMLElement>) => {
|
||||
const value = e.currentTarget.innerText.trim();
|
||||
if (value !== this.props.value) this.props.onChange(value);
|
||||
removeSelection();
|
||||
};
|
||||
|
||||
private handleEdit(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
this.setState({ value: e.target.value });
|
||||
}
|
||||
|
||||
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 });
|
||||
private handleKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const { value, edit } = this.state;
|
||||
|
||||
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
|
||||
onClick={() => this.setState({ edit: true })}
|
||||
suppressContentEditableWarning
|
||||
contentEditable="true"
|
||||
data-type="wysiwyg"
|
||||
className="project-name"
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
onFocus={this.handleFocus}
|
||||
>
|
||||
{value}
|
||||
{this.props.value}
|
||||
</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 "./Popover.css";
|
||||
|
||||
type Props = {
|
||||
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 { selectNode } from "../utils";
|
||||
|
||||
type TextWysiwygParams = {
|
||||
initText: string;
|
||||
@ -89,11 +90,5 @@ export function textWysiwyg({
|
||||
window.addEventListener("wheel", stopEvent, true);
|
||||
document.body.appendChild(editable);
|
||||
editable.focus();
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editable);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
selectNode(editable);
|
||||
}
|
||||
|
212
src/index.tsx
212
src/index.tsx
@ -21,17 +21,21 @@ import {
|
||||
saveToLocalStorage,
|
||||
getElementAtPosition,
|
||||
createScene,
|
||||
getElementContainingPosition
|
||||
getElementContainingPosition,
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
hasText,
|
||||
exportCanvas
|
||||
} from "./scene";
|
||||
|
||||
import { renderScene } from "./renderer";
|
||||
import { AppState } from "./types";
|
||||
import { ExcalidrawElement, ExcalidrawTextElement } from "./element/types";
|
||||
|
||||
import { isInputLike, measureText, debounce } from "./utils";
|
||||
import { isInputLike, measureText, debounce, capitalizeString } from "./utils";
|
||||
import { KEYS, META_KEY, isArrowKey } from "./keys";
|
||||
|
||||
import { findShapeByKey, shapesShortcutKeys } from "./shapes";
|
||||
import { findShapeByKey, shapesShortcutKeys, SHAPES } from "./shapes";
|
||||
import { createHistory } from "./history";
|
||||
|
||||
import ContextMenu from "./components/ContextMenu";
|
||||
@ -63,14 +67,18 @@ import {
|
||||
actionCopyStyles,
|
||||
actionPasteStyles
|
||||
} from "./actions";
|
||||
import { SidePanel } from "./components/SidePanel";
|
||||
import { Action, ActionResult } from "./actions/types";
|
||||
import { getDefaultAppState } from "./appState";
|
||||
import { Island } from "./components/Island";
|
||||
import Stack from "./components/Stack";
|
||||
import { FixedSideContainer } from "./components/FixedSideContainer";
|
||||
import { ToolIcon } from "./components/ToolIcon";
|
||||
import { ExportDialog } from "./components/ExportDialog";
|
||||
|
||||
let { elements } = createScene();
|
||||
const { history } = createHistory();
|
||||
|
||||
const CANVAS_WINDOW_OFFSET_LEFT = 250;
|
||||
const CANVAS_WINDOW_OFFSET_LEFT = 0;
|
||||
const CANVAS_WINDOW_OFFSET_TOP = 0;
|
||||
|
||||
function resetCursor() {
|
||||
@ -331,26 +339,197 @@ export class App extends React.Component<{}, AppState> {
|
||||
}
|
||||
};
|
||||
|
||||
public render() {
|
||||
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
|
||||
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
|
||||
private renderSelectedShapeActions(elements: readonly ExcalidrawElement[]) {
|
||||
const selectedElements = elements.filter(el => el.isSelected);
|
||||
if (selectedElements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<SidePanel
|
||||
actionManager={this.actionManager}
|
||||
syncActionResult={this.syncActionResult}
|
||||
appState={{ ...this.state }}
|
||||
elements={elements}
|
||||
onToolChange={value => {
|
||||
<Island padding={4}>
|
||||
<div className="panelColumn">
|
||||
{this.actionManager.renderAction(
|
||||
"changeStrokeColor",
|
||||
elements,
|
||||
this.state,
|
||||
this.syncActionResult
|
||||
)}
|
||||
|
||||
{hasBackground(elements) && (
|
||||
<>
|
||||
{this.actionManager.renderAction(
|
||||
"changeBackgroundColor",
|
||||
elements,
|
||||
this.state,
|
||||
this.syncActionResult
|
||||
)}
|
||||
|
||||
{this.actionManager.renderAction(
|
||||
"changeFillStyle",
|
||||
elements,
|
||||
this.state,
|
||||
this.syncActionResult
|
||||
)}
|
||||
<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 });
|
||||
elements = clearSelection(elements);
|
||||
document.documentElement.style.cursor =
|
||||
value === "text" ? "text" : "crosshair";
|
||||
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
|
||||
id="canvas"
|
||||
style={{
|
||||
@ -374,7 +553,6 @@ export class App extends React.Component<{}, AppState> {
|
||||
});
|
||||
this.removeWheelEventListener = () =>
|
||||
canvas.removeEventListener("wheel", this.handleWheel);
|
||||
|
||||
// Whenever React sets the width/height of the canvas element,
|
||||
// the context loses the scale transform. We need to re-apply it
|
||||
if (
|
||||
|
@ -77,6 +77,59 @@ export function loadFromJSON() {
|
||||
});
|
||||
}
|
||||
|
||||
export function getExportCanvasPreview(
|
||||
elements: readonly ExcalidrawElement[],
|
||||
{
|
||||
exportBackground,
|
||||
exportPadding = 10,
|
||||
viewBackgroundColor
|
||||
}: {
|
||||
exportBackground: boolean;
|
||||
exportPadding?: number;
|
||||
viewBackgroundColor: string;
|
||||
}
|
||||
) {
|
||||
// calculate smallest area to fit the contents in
|
||||
let subCanvasX1 = Infinity;
|
||||
let subCanvasX2 = 0;
|
||||
let subCanvasY1 = Infinity;
|
||||
let subCanvasY2 = 0;
|
||||
|
||||
elements.forEach(element => {
|
||||
const [x1, y1, x2, y2] = getElementAbsoluteCoords(element);
|
||||
subCanvasX1 = Math.min(subCanvasX1, x1);
|
||||
subCanvasY1 = Math.min(subCanvasY1, y1);
|
||||
subCanvasX2 = Math.max(subCanvasX2, x2);
|
||||
subCanvasY2 = Math.max(subCanvasY2, y2);
|
||||
});
|
||||
|
||||
function distance(x: number, y: number) {
|
||||
return Math.abs(x > y ? x - y : y - x);
|
||||
}
|
||||
|
||||
const tempCanvas = document.createElement("canvas");
|
||||
tempCanvas.width = distance(subCanvasX1, subCanvasX2) + exportPadding * 2;
|
||||
tempCanvas.height = distance(subCanvasY1, subCanvasY2) + exportPadding * 2;
|
||||
|
||||
renderScene(
|
||||
elements,
|
||||
rough.canvas(tempCanvas),
|
||||
tempCanvas,
|
||||
{
|
||||
viewBackgroundColor: exportBackground ? viewBackgroundColor : null,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
},
|
||||
{
|
||||
offsetX: -subCanvasX1 + exportPadding,
|
||||
offsetY: -subCanvasY1 + exportPadding,
|
||||
renderScrollbars: false,
|
||||
renderSelection: false
|
||||
}
|
||||
);
|
||||
return tempCanvas;
|
||||
}
|
||||
|
||||
export function exportCanvas(
|
||||
type: ExportType,
|
||||
elements: readonly ExcalidrawElement[],
|
||||
|
172
src/styles.scss
172
src/styles.scss
@ -1,3 +1,5 @@
|
||||
@import "./theme.css";
|
||||
|
||||
/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */
|
||||
@font-face {
|
||||
font-family: "Virgil";
|
||||
@ -8,6 +10,7 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
|
||||
.container {
|
||||
@ -19,48 +22,6 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.sidePanel {
|
||||
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;
|
||||
flex-direction: column;
|
||||
@ -84,74 +45,17 @@ body {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tool {
|
||||
position: relative;
|
||||
|
||||
input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
input[type="radio"] {
|
||||
& + .toolIcon {
|
||||
.divider {
|
||||
width: 1px;
|
||||
background-color: #ddd;
|
||||
|
||||
width: 41px;
|
||||
height: 41px;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 3px;
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
}
|
||||
}
|
||||
&:hover + .toolIcon {
|
||||
background-color: #e7e5e5;
|
||||
}
|
||||
&:checked + .toolIcon {
|
||||
background-color: #bdbebc;
|
||||
}
|
||||
&:focus + .toolIcon {
|
||||
box-shadow: 0 0 0 2px steelblue;
|
||||
}
|
||||
}
|
||||
margin: 1px;
|
||||
}
|
||||
|
||||
label {
|
||||
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 {
|
||||
input:focus {
|
||||
outline: transparent;
|
||||
box-shadow: 0 0 0 2px steelblue;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #ddd;
|
||||
@ -170,8 +74,7 @@ button {
|
||||
border-color: #d6d4d4;
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.active {
|
||||
&:active {
|
||||
background-color: #bdbebc;
|
||||
border-color: #bdbebc;
|
||||
}
|
||||
@ -181,40 +84,39 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
|
||||
.cover {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
.App-menu {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
display: inline;
|
||||
margin-right: 4px;
|
||||
.App-menu_top {
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: flex-start;
|
||||
cursor: default;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.swatch-input {
|
||||
font-size: 16px;
|
||||
display: inline;
|
||||
width: 100px;
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.project-name {
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
.App-menu_top > * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.project-name-input {
|
||||
width: 200px;
|
||||
font: inherit;
|
||||
.App-menu_top > *:first-child {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.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);
|
||||
};
|
||||
}
|
||||
|
||||
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