mirror of
https://github.com/excalidraw/excalidraw.git
synced 2025-05-03 10:00:07 -04:00
Merge branch 'master' into paste-in-center
This commit is contained in:
commit
4150589e08
36 changed files with 642 additions and 334 deletions
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
open_collective: excalidraw
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -18,3 +18,6 @@ yarn.lock
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# build files
|
||||||
|
static/
|
||||||
|
|
|
@ -17,6 +17,9 @@ Go to https://www.excalidraw.com to start sketching
|
||||||
|
|
||||||
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
|
<a href="https://twitter.com/kyehohenberger/status/1214288572037025792"><img width="423" src="https://user-images.githubusercontent.com/197597/71851802-34f13880-308c-11ea-9416-191099e6349c.png"></a>
|
||||||
|
|
||||||
|
<a href="https://twitter.com/jordwalke/status/1214858186789806080"><img width="434" src="https://user-images.githubusercontent.com/197597/72036874-07a1b780-3251-11ea-99e8-6bafd93483a0.png"></a>
|
||||||
|
|
||||||
|
<a href="https://twitter.com/lucasazzola/status/1215126440330416128"><img width="429" src="https://user-images.githubusercontent.com/197597/72039003-48e99580-3258-11ea-8daa-85dd055f2a82.png">
|
||||||
|
|
||||||
## Run the code
|
## Run the code
|
||||||
|
|
||||||
|
|
14
package-lock.json
generated
14
package-lock.json
generated
|
@ -1473,6 +1473,7 @@
|
||||||
"version": "24.0.25",
|
"version": "24.0.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.25.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.25.tgz",
|
||||||
"integrity": "sha512-hnP1WpjN4KbGEK4dLayul6lgtys6FPz0UfxMeMQCv0M+sTnzN3ConfiO72jHgLxl119guHgI8gLqDOrRLsyp2g==",
|
"integrity": "sha512-hnP1WpjN4KbGEK4dLayul6lgtys6FPz0UfxMeMQCv0M+sTnzN3ConfiO72jHgLxl119guHgI8gLqDOrRLsyp2g==",
|
||||||
|
"dev": true,
|
||||||
"requires": {
|
"requires": {
|
||||||
"jest-diff": "^24.3.0"
|
"jest-diff": "^24.3.0"
|
||||||
}
|
}
|
||||||
|
@ -1487,6 +1488,14 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||||
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA=="
|
"integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA=="
|
||||||
},
|
},
|
||||||
|
"@types/nanoid": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nanoid/-/nanoid-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
"version": "13.1.2",
|
"version": "13.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-13.1.2.tgz",
|
||||||
|
@ -9848,6 +9857,11 @@
|
||||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz",
|
||||||
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
|
"integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg=="
|
||||||
},
|
},
|
||||||
|
"nanoid": {
|
||||||
|
"version": "2.1.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.9.tgz",
|
||||||
|
"integrity": "sha512-J2X7aUpdmTlkAuSe9WaQ5DsTZZPW1r/zmEWKsGhbADO6Gm9FMd2ZzJ8NhsmP4OtA9oFhXfxNqPlreHEDOGB4sg=="
|
||||||
|
},
|
||||||
"nanomatch": {
|
"nanomatch": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.13",
|
||||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"nanoid": "^2.1.9",
|
||||||
"react": "16.12.0",
|
"react": "16.12.0",
|
||||||
"react-color": "^2.17.3",
|
"react-color": "^2.17.3",
|
||||||
"react-dom": "16.12.0",
|
"react-dom": "16.12.0",
|
||||||
|
@ -14,6 +15,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^24.0.25",
|
"@types/jest": "^24.0.25",
|
||||||
|
"@types/nanoid": "^2.1.0",
|
||||||
"@types/react": "16.9.17",
|
"@types/react": "16.9.17",
|
||||||
"@types/react-color": "^3.0.1",
|
"@types/react-color": "^3.0.1",
|
||||||
"@types/react-dom": "16.9.4",
|
"@types/react-dom": "16.9.4",
|
||||||
|
@ -49,7 +51,7 @@
|
||||||
"git add"
|
"git add"
|
||||||
],
|
],
|
||||||
"*.{js,ts,tsx}": [
|
"*.{js,ts,tsx}": [
|
||||||
"eslint"
|
"eslint --max-warnings 0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
43
src/components/Panel.tsx
Normal file
43
src/components/Panel.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { ColorPicker } from "../ColorPicker";
|
import { ColorPicker } from "../ColorPicker";
|
||||||
|
import { Panel } from "../Panel";
|
||||||
|
|
||||||
interface PanelCanvasProps {
|
interface PanelCanvasProps {
|
||||||
viewBackgroundColor: string;
|
viewBackgroundColor: string;
|
||||||
|
@ -14,22 +15,19 @@ export const PanelCanvas: React.FC<PanelCanvasProps> = ({
|
||||||
onClearCanvas
|
onClearCanvas
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Panel title="Canvas">
|
||||||
<h4>Canvas</h4>
|
<h5>Canvas Background Color</h5>
|
||||||
<div className="panelColumn">
|
<ColorPicker
|
||||||
<h5>Canvas Background Color</h5>
|
color={viewBackgroundColor}
|
||||||
<ColorPicker
|
onChange={color => onViewBackgroundColorChange(color)}
|
||||||
color={viewBackgroundColor}
|
/>
|
||||||
onChange={color => onViewBackgroundColorChange(color)}
|
<button
|
||||||
/>
|
type="button"
|
||||||
<button
|
onClick={onClearCanvas}
|
||||||
type="button"
|
title="Clear the canvas & reset background color"
|
||||||
onClick={onClearCanvas}
|
>
|
||||||
title="Clear the canvas & reset background color"
|
Clear canvas
|
||||||
>
|
</button>
|
||||||
Clear canvas
|
</Panel>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { EditableText } from "../EditableText";
|
import { EditableText } from "../EditableText";
|
||||||
|
import { Panel } from "../Panel";
|
||||||
|
|
||||||
interface PanelExportProps {
|
interface PanelExportProps {
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
@ -21,8 +22,7 @@ export const PanelExport: React.FC<PanelExportProps> = ({
|
||||||
onExportAsPNG
|
onExportAsPNG
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Panel title="Export">
|
||||||
<h4>Export</h4>
|
|
||||||
<div className="panelColumn">
|
<div className="panelColumn">
|
||||||
<h5>Name</h5>
|
<h5>Name</h5>
|
||||||
{projectName && (
|
{projectName && (
|
||||||
|
@ -47,6 +47,6 @@ export const PanelExport: React.FC<PanelExportProps> = ({
|
||||||
<button onClick={onSaveScene}>Save as...</button>
|
<button onClick={onSaveScene}>Save as...</button>
|
||||||
<button onClick={onLoadScene}>Load file...</button>
|
<button onClick={onLoadScene}>Load file...</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -14,8 +14,7 @@ export const PanelSelection: React.FC<PanelSelectionProps> = ({
|
||||||
onSendToBack
|
onSendToBack
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<h4>Selection</h4>
|
|
||||||
<div className="buttonList">
|
<div className="buttonList">
|
||||||
<button type="button" onClick={onBringForward}>
|
<button type="button" onClick={onBringForward}>
|
||||||
Bring forward
|
Bring forward
|
||||||
|
@ -30,6 +29,6 @@ export const PanelSelection: React.FC<PanelSelectionProps> = ({
|
||||||
Send to back
|
Send to back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -2,6 +2,7 @@ import React from "react";
|
||||||
|
|
||||||
import { SHAPES } from "../../shapes";
|
import { SHAPES } from "../../shapes";
|
||||||
import { capitalizeString } from "../../utils";
|
import { capitalizeString } from "../../utils";
|
||||||
|
import { Panel } from "../Panel";
|
||||||
|
|
||||||
interface PanelToolsProps {
|
interface PanelToolsProps {
|
||||||
activeTool: string;
|
activeTool: string;
|
||||||
|
@ -13,8 +14,7 @@ export const PanelTools: React.FC<PanelToolsProps> = ({
|
||||||
onToolChange
|
onToolChange
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<Panel title="Shapes">
|
||||||
<h4>Shapes</h4>
|
|
||||||
<div className="panelTools">
|
<div className="panelTools">
|
||||||
{SHAPES.map(({ value, icon }) => (
|
{SHAPES.map(({ value, icon }) => (
|
||||||
<label
|
<label
|
||||||
|
@ -33,6 +33,6 @@ export const PanelTools: React.FC<PanelToolsProps> = ({
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import { SceneState } from "../scene/types";
|
|
||||||
import { ExcalidrawElement } from "./types";
|
import { ExcalidrawElement } from "./types";
|
||||||
|
import { SceneScroll } from "../scene/types";
|
||||||
|
|
||||||
|
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
|
||||||
|
|
||||||
export function handlerRectangles(
|
export function handlerRectangles(
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
sceneState: SceneState
|
{ scrollX, scrollY }: SceneScroll
|
||||||
) {
|
) {
|
||||||
const elementX1 = element.x;
|
const elementX1 = element.x;
|
||||||
const elementX2 = element.x + element.width;
|
const elementX2 = element.x + element.width;
|
||||||
|
@ -12,22 +14,22 @@ export function handlerRectangles(
|
||||||
|
|
||||||
const margin = 4;
|
const margin = 4;
|
||||||
const minimumSize = 40;
|
const minimumSize = 40;
|
||||||
const handlers: { [handler: string]: number[] } = {};
|
const handlers = {} as { [T in Sides]: number[] };
|
||||||
|
|
||||||
const marginX = element.width < 0 ? 8 : -8;
|
const marginX = element.width < 0 ? 8 : -8;
|
||||||
const marginY = element.height < 0 ? 8 : -8;
|
const marginY = element.height < 0 ? 8 : -8;
|
||||||
|
|
||||||
if (Math.abs(elementX2 - elementX1) > minimumSize) {
|
if (Math.abs(elementX2 - elementX1) > minimumSize) {
|
||||||
handlers["n"] = [
|
handlers["n"] = [
|
||||||
elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4,
|
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
|
||||||
elementY1 - margin + sceneState.scrollY + marginY,
|
elementY1 - margin + scrollY + marginY,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
];
|
];
|
||||||
|
|
||||||
handlers["s"] = [
|
handlers["s"] = [
|
||||||
elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4,
|
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
|
||||||
elementY2 - margin + sceneState.scrollY - marginY,
|
elementY2 - margin + scrollY - marginY,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
];
|
];
|
||||||
|
@ -35,41 +37,41 @@ export function handlerRectangles(
|
||||||
|
|
||||||
if (Math.abs(elementY2 - elementY1) > minimumSize) {
|
if (Math.abs(elementY2 - elementY1) > minimumSize) {
|
||||||
handlers["w"] = [
|
handlers["w"] = [
|
||||||
elementX1 - margin + sceneState.scrollX + marginX,
|
elementX1 - margin + scrollX + marginX,
|
||||||
elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4,
|
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
];
|
];
|
||||||
|
|
||||||
handlers["e"] = [
|
handlers["e"] = [
|
||||||
elementX2 - margin + sceneState.scrollX - marginX,
|
elementX2 - margin + scrollX - marginX,
|
||||||
elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4,
|
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
handlers["nw"] = [
|
handlers["nw"] = [
|
||||||
elementX1 - margin + sceneState.scrollX + marginX,
|
elementX1 - margin + scrollX + marginX,
|
||||||
elementY1 - margin + sceneState.scrollY + marginY,
|
elementY1 - margin + scrollY + marginY,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
]; // nw
|
]; // nw
|
||||||
handlers["ne"] = [
|
handlers["ne"] = [
|
||||||
elementX2 - margin + sceneState.scrollX - marginX,
|
elementX2 - margin + scrollX - marginX,
|
||||||
elementY1 - margin + sceneState.scrollY + marginY,
|
elementY1 - margin + scrollY + marginY,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
]; // ne
|
]; // ne
|
||||||
handlers["sw"] = [
|
handlers["sw"] = [
|
||||||
elementX1 - margin + sceneState.scrollX + marginX,
|
elementX1 - margin + scrollX + marginX,
|
||||||
elementY2 - margin + sceneState.scrollY - marginY,
|
elementY2 - margin + scrollY - marginY,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
]; // sw
|
]; // sw
|
||||||
handlers["se"] = [
|
handlers["se"] = [
|
||||||
elementX2 - margin + sceneState.scrollX - marginX,
|
elementX2 - margin + scrollX - marginX,
|
||||||
elementY2 - margin + sceneState.scrollY - marginY,
|
elementY2 - margin + scrollY - marginY,
|
||||||
8,
|
8,
|
||||||
8
|
8
|
||||||
]; // se
|
]; // se
|
||||||
|
@ -78,7 +80,7 @@ export function handlerRectangles(
|
||||||
return {
|
return {
|
||||||
nw: handlers.nw,
|
nw: handlers.nw,
|
||||||
se: handlers.se
|
se: handlers.se
|
||||||
};
|
} as typeof handlers;
|
||||||
}
|
}
|
||||||
|
|
||||||
return handlers;
|
return handlers;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
export { newElement } from "./newElement";
|
export { newElement, duplicateElement } from "./newElement";
|
||||||
export {
|
export {
|
||||||
getElementAbsoluteCoords,
|
getElementAbsoluteCoords,
|
||||||
getDiamondPoints,
|
getDiamondPoints,
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { randomSeed } from "../random";
|
import { randomSeed } from "../random";
|
||||||
|
import nanoid from "nanoid";
|
||||||
|
|
||||||
export function newElement(
|
export function newElement(
|
||||||
type: string,
|
type: string,
|
||||||
|
@ -14,6 +15,7 @@ export function newElement(
|
||||||
height = 0
|
height = 0
|
||||||
) {
|
) {
|
||||||
const element = {
|
const element = {
|
||||||
|
id: nanoid(),
|
||||||
type,
|
type,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
|
@ -30,3 +32,10 @@ export function newElement(
|
||||||
};
|
};
|
||||||
return element;
|
return element;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function duplicateElement(element: ReturnType<typeof newElement>) {
|
||||||
|
const copy = { ...element };
|
||||||
|
copy.id = nanoid();
|
||||||
|
copy.seed = randomSeed();
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
|
@ -1,32 +1,51 @@
|
||||||
import { ExcalidrawElement } from "./types";
|
import { ExcalidrawElement } from "./types";
|
||||||
import { SceneState } from "../scene/types";
|
|
||||||
|
|
||||||
import { handlerRectangles } from "./handlerRectangles";
|
import { handlerRectangles } from "./handlerRectangles";
|
||||||
|
import { SceneScroll } from "../scene/types";
|
||||||
|
|
||||||
|
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||||
|
|
||||||
export function resizeTest(
|
export function resizeTest(
|
||||||
element: ExcalidrawElement,
|
element: ExcalidrawElement,
|
||||||
x: number,
|
x: number,
|
||||||
y: number,
|
y: number,
|
||||||
sceneState: SceneState
|
{ scrollX, scrollY }: SceneScroll
|
||||||
): string | false {
|
): HandlerRectanglesRet | false {
|
||||||
if (element.type === "text") return false;
|
if (!element.isSelected || element.type === "text") return false;
|
||||||
|
|
||||||
const handlers = handlerRectangles(element, sceneState);
|
const handlers = handlerRectangles(element, { scrollX, scrollY });
|
||||||
|
|
||||||
const filter = Object.keys(handlers).filter(key => {
|
const filter = Object.keys(handlers).filter(key => {
|
||||||
const handler = handlers[key];
|
const handler = handlers[key as HandlerRectanglesRet]!;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
x + sceneState.scrollX >= handler[0] &&
|
x + scrollX >= handler[0] &&
|
||||||
x + sceneState.scrollX <= handler[0] + handler[2] &&
|
x + scrollX <= handler[0] + handler[2] &&
|
||||||
y + sceneState.scrollY >= handler[1] &&
|
y + scrollY >= handler[1] &&
|
||||||
y + sceneState.scrollY <= handler[1] + handler[3]
|
y + scrollY <= handler[1] + handler[3]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filter.length > 0) {
|
if (filter.length > 0) {
|
||||||
return filter[0];
|
return filter[0] as HandlerRectanglesRet;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getElementWithResizeHandler(
|
||||||
|
elements: ExcalidrawElement[],
|
||||||
|
{ x, y }: { x: number; y: number },
|
||||||
|
{ scrollX, scrollY }: SceneScroll
|
||||||
|
) {
|
||||||
|
return elements.reduce((result, element) => {
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const resizeHandle = resizeTest(element, x, y, {
|
||||||
|
scrollX,
|
||||||
|
scrollY
|
||||||
|
});
|
||||||
|
return resizeHandle ? { element, resizeHandle } : null;
|
||||||
|
}, null as { element: ExcalidrawElement; resizeHandle: ReturnType<typeof resizeTest> } | null);
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { KEYS } from "../index";
|
import { KEYS } from "../keys";
|
||||||
|
|
||||||
type TextWysiwygParams = {
|
type TextWysiwygParams = {
|
||||||
initText: string;
|
initText: string;
|
||||||
|
@ -17,25 +17,37 @@ export function textWysiwyg({
|
||||||
font,
|
font,
|
||||||
onSubmit
|
onSubmit
|
||||||
}: TextWysiwygParams) {
|
}: TextWysiwygParams) {
|
||||||
const input = document.createElement("input");
|
// Using contenteditable here as it has dynamic width.
|
||||||
input.value = initText;
|
// But this solution has an issue — it allows to paste
|
||||||
Object.assign(input.style, {
|
// multiline text, which is not currently supported
|
||||||
|
const editable = document.createElement("div");
|
||||||
|
editable.contentEditable = "plaintext-only";
|
||||||
|
editable.tabIndex = 0;
|
||||||
|
editable.innerText = initText;
|
||||||
|
editable.dataset.type = "wysiwyg";
|
||||||
|
|
||||||
|
Object.assign(editable.style, {
|
||||||
color: strokeColor,
|
color: strokeColor,
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
top: y - 8 + "px",
|
top: y + "px",
|
||||||
left: x + "px",
|
left: x + "px",
|
||||||
transform: "translate(-50%, -50%)",
|
transform: "translate(-50%, -50%)",
|
||||||
boxShadow: "none",
|
|
||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
width: (window.innerWidth - x) * 2 + "px",
|
display: "inline-block",
|
||||||
font: font,
|
font: font,
|
||||||
border: "none",
|
padding: "4px",
|
||||||
background: "transparent"
|
outline: "transparent",
|
||||||
|
whiteSpace: "nowrap"
|
||||||
});
|
});
|
||||||
|
|
||||||
input.onkeydown = ev => {
|
editable.onkeydown = ev => {
|
||||||
if (ev.key === KEYS.ESCAPE) {
|
if (ev.key === KEYS.ESCAPE) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
if (initText) {
|
||||||
|
editable.innerText = initText;
|
||||||
|
handleSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
cleanup();
|
cleanup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -44,28 +56,34 @@ export function textWysiwyg({
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.onblur = handleSubmit;
|
editable.onblur = handleSubmit;
|
||||||
|
|
||||||
function stopEvent(ev: Event) {
|
function stopEvent(ev: Event) {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (input.value) {
|
if (editable.innerText) {
|
||||||
onSubmit(input.value);
|
onSubmit(editable.innerText);
|
||||||
}
|
}
|
||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
function cleanup() {
|
function cleanup() {
|
||||||
input.onblur = null;
|
editable.onblur = null;
|
||||||
input.onkeydown = null;
|
editable.onkeydown = null;
|
||||||
window.removeEventListener("wheel", stopEvent, true);
|
window.removeEventListener("wheel", stopEvent, true);
|
||||||
document.body.removeChild(input);
|
document.body.removeChild(editable);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("wheel", stopEvent, true);
|
window.addEventListener("wheel", stopEvent, true);
|
||||||
document.body.appendChild(input);
|
document.body.appendChild(editable);
|
||||||
input.focus();
|
editable.focus();
|
||||||
input.select();
|
const selection = window.getSelection();
|
||||||
|
if (selection) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editable);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,5 +5,7 @@ export type ExcalidrawTextElement = ExcalidrawElement & {
|
||||||
type: "text";
|
type: "text";
|
||||||
font: string;
|
font: string;
|
||||||
text: string;
|
text: string;
|
||||||
actualBoundingBoxAscent: number;
|
// for backward compatibility
|
||||||
|
actualBoundingBoxAscent?: number;
|
||||||
|
baseline: number;
|
||||||
};
|
};
|
||||||
|
|
544
src/index.tsx
544
src/index.tsx
|
@ -1,11 +1,13 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
import rough from "roughjs/bin/wrappers/rough";
|
import rough from "roughjs/bin/wrappers/rough";
|
||||||
|
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
|
|
||||||
import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
|
import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
|
||||||
import { randomSeed } from "./random";
|
|
||||||
import {
|
import {
|
||||||
newElement,
|
newElement,
|
||||||
|
duplicateElement,
|
||||||
resizeTest,
|
resizeTest,
|
||||||
isTextElement,
|
isTextElement,
|
||||||
textWysiwyg,
|
textWysiwyg,
|
||||||
|
@ -27,61 +29,42 @@ import {
|
||||||
hasBackground,
|
hasBackground,
|
||||||
hasStroke,
|
hasStroke,
|
||||||
getElementAtPosition,
|
getElementAtPosition,
|
||||||
createScene
|
createScene,
|
||||||
|
getElementContainingPosition,
|
||||||
|
hasText
|
||||||
} 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 { getDateTime, isInputLike } from "./utils";
|
import { getDateTime, isInputLike, measureText } from "./utils";
|
||||||
|
import { KEYS, META_KEY, isArrowKey } from "./keys";
|
||||||
|
|
||||||
import { ButtonSelect } from "./components/ButtonSelect";
|
import { ButtonSelect } from "./components/ButtonSelect";
|
||||||
import { findShapeByKey, shapesShortcutKeys } from "./shapes";
|
import { findShapeByKey, shapesShortcutKeys } from "./shapes";
|
||||||
import { createHistory } from "./history";
|
import { createHistory } from "./history";
|
||||||
|
|
||||||
import "./styles.scss";
|
|
||||||
import ContextMenu from "./components/ContextMenu";
|
import ContextMenu from "./components/ContextMenu";
|
||||||
import { PanelTools } from "./components/panels/PanelTools";
|
import { PanelTools } from "./components/panels/PanelTools";
|
||||||
import { PanelSelection } from "./components/panels/PanelSelection";
|
import { PanelSelection } from "./components/panels/PanelSelection";
|
||||||
import { PanelColor } from "./components/panels/PanelColor";
|
import { PanelColor } from "./components/panels/PanelColor";
|
||||||
import { PanelExport } from "./components/panels/PanelExport";
|
import { PanelExport } from "./components/panels/PanelExport";
|
||||||
import { PanelCanvas } from "./components/panels/PanelCanvas";
|
import { PanelCanvas } from "./components/panels/PanelCanvas";
|
||||||
|
import { Panel } from "./components/Panel";
|
||||||
|
|
||||||
|
import "./styles.scss";
|
||||||
|
import { getElementWithResizeHandler } from "./element/resizeTest";
|
||||||
|
|
||||||
const { elements } = createScene();
|
const { elements } = createScene();
|
||||||
const { history } = createHistory();
|
const { history } = createHistory();
|
||||||
|
|
||||||
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
|
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
|
||||||
|
|
||||||
const CANVAS_WINDOW_OFFSET_LEFT = 250;
|
const CANVAS_WINDOW_OFFSET_LEFT = 250;
|
||||||
const CANVAS_WINDOW_OFFSET_TOP = 0;
|
const CANVAS_WINDOW_OFFSET_TOP = 0;
|
||||||
|
|
||||||
export const KEYS = {
|
|
||||||
ARROW_LEFT: "ArrowLeft",
|
|
||||||
ARROW_RIGHT: "ArrowRight",
|
|
||||||
ARROW_DOWN: "ArrowDown",
|
|
||||||
ARROW_UP: "ArrowUp",
|
|
||||||
ENTER: "Enter",
|
|
||||||
ESCAPE: "Escape",
|
|
||||||
DELETE: "Delete",
|
|
||||||
BACKSPACE: "Backspace"
|
|
||||||
};
|
|
||||||
|
|
||||||
const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
|
|
||||||
? "metaKey"
|
|
||||||
: "ctrlKey";
|
|
||||||
|
|
||||||
let copiedStyles: string = "{}";
|
let copiedStyles: string = "{}";
|
||||||
|
|
||||||
function isArrowKey(keyCode: string) {
|
|
||||||
return (
|
|
||||||
keyCode === KEYS.ARROW_LEFT ||
|
|
||||||
keyCode === KEYS.ARROW_RIGHT ||
|
|
||||||
keyCode === KEYS.ARROW_DOWN ||
|
|
||||||
keyCode === KEYS.ARROW_UP
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetCursor() {
|
function resetCursor() {
|
||||||
document.documentElement.style.cursor = "";
|
document.documentElement.style.cursor = "";
|
||||||
}
|
}
|
||||||
|
@ -95,36 +78,42 @@ function addTextElement(
|
||||||
if (text === null || text === "") {
|
if (text === null || text === "") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metrics = measureText(text, font);
|
||||||
element.text = text;
|
element.text = text;
|
||||||
element.font = font;
|
element.font = font;
|
||||||
const currentFont = context.font;
|
|
||||||
context.font = element.font;
|
|
||||||
const textMeasure = context.measureText(element.text);
|
|
||||||
const width = textMeasure.width;
|
|
||||||
const actualBoundingBoxAscent =
|
|
||||||
textMeasure.actualBoundingBoxAscent || parseInt(font);
|
|
||||||
const actualBoundingBoxDescent = textMeasure.actualBoundingBoxDescent || 0;
|
|
||||||
element.actualBoundingBoxAscent = actualBoundingBoxAscent;
|
|
||||||
context.font = currentFont;
|
|
||||||
const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
|
|
||||||
// Center the text
|
// Center the text
|
||||||
element.x -= width / 2;
|
element.x -= metrics.width / 2;
|
||||||
element.y -= actualBoundingBoxAscent;
|
element.y -= metrics.height / 2;
|
||||||
element.width = width;
|
element.width = metrics.width;
|
||||||
element.height = height;
|
element.height = metrics.height;
|
||||||
|
element.baseline = metrics.baseline;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||||
const ELEMENT_TRANSLATE_AMOUNT = 1;
|
const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||||
|
const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
||||||
|
|
||||||
let lastCanvasWidth = -1;
|
let lastCanvasWidth = -1;
|
||||||
let lastCanvasHeight = -1;
|
let lastCanvasHeight = -1;
|
||||||
|
|
||||||
let lastMouseUp: ((e: any) => void) | null = null;
|
let lastMouseUp: ((e: any) => void) | null = null;
|
||||||
|
|
||||||
class App extends React.Component<{}, AppState> {
|
export function viewportCoordsToSceneCoords(
|
||||||
|
{ clientX, clientY }: { clientX: number; clientY: number },
|
||||||
|
{ scrollX, scrollY }: { scrollX: number; scrollY: number }
|
||||||
|
) {
|
||||||
|
const x = clientX - CANVAS_WINDOW_OFFSET_LEFT - scrollX;
|
||||||
|
const y = clientY - CANVAS_WINDOW_OFFSET_TOP - scrollY;
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
export class App extends React.Component<{}, AppState> {
|
||||||
|
canvas: HTMLCanvasElement | null = null;
|
||||||
|
rc: RoughCanvas | null = null;
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
document.addEventListener("keydown", this.onKeyDown, false);
|
document.addEventListener("keydown", this.onKeyDown, false);
|
||||||
document.addEventListener("mousemove", this.getCurrentCursorPosition);
|
document.addEventListener("mousemove", this.getCurrentCursorPosition);
|
||||||
|
@ -287,6 +276,10 @@ class App extends React.Component<{}, AppState> {
|
||||||
element.fillStyle = pastedElement?.fillStyle;
|
element.fillStyle = pastedElement?.fillStyle;
|
||||||
element.opacity = pastedElement?.opacity;
|
element.opacity = pastedElement?.opacity;
|
||||||
element.roughness = pastedElement?.roughness;
|
element.roughness = pastedElement?.roughness;
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
element.font = pastedElement?.font;
|
||||||
|
this.redrawTextBoundingBox(element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
|
@ -359,6 +352,14 @@ class App extends React.Component<{}, AppState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private redrawTextBoundingBox = (element: ExcalidrawTextElement) => {
|
||||||
|
const metrics = measureText(element.text, element.font);
|
||||||
|
element.width = metrics.width;
|
||||||
|
element.height = metrics.height;
|
||||||
|
element.baseline = metrics.baseline;
|
||||||
|
this.forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
|
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
|
||||||
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
|
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
|
||||||
|
@ -399,112 +400,162 @@ class App extends React.Component<{}, AppState> {
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{someElementIsSelected(elements) && (
|
<Panel title="Selection" hide={!someElementIsSelected(elements)}>
|
||||||
<div className="panelColumn">
|
<PanelSelection
|
||||||
<PanelSelection
|
onBringForward={this.moveOneRight}
|
||||||
onBringForward={this.moveOneRight}
|
onBringToFront={this.moveAllRight}
|
||||||
onBringToFront={this.moveAllRight}
|
onSendBackward={this.moveOneLeft}
|
||||||
onSendBackward={this.moveOneLeft}
|
onSendToBack={this.moveAllLeft}
|
||||||
onSendToBack={this.moveAllLeft}
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
<PanelColor
|
<PanelColor
|
||||||
title="Stroke Color"
|
title="Stroke Color"
|
||||||
onColorChange={this.changeStrokeColor}
|
onColorChange={this.changeStrokeColor}
|
||||||
colorValue={getSelectedAttribute(
|
colorValue={getSelectedAttribute(
|
||||||
elements,
|
elements,
|
||||||
element => element.strokeColor
|
element => element.strokeColor
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{hasBackground(elements) && (
|
|
||||||
<>
|
|
||||||
<PanelColor
|
|
||||||
title="Background Color"
|
|
||||||
onColorChange={this.changeBackgroundColor}
|
|
||||||
colorValue={getSelectedAttribute(
|
|
||||||
elements,
|
|
||||||
element => element.backgroundColor
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h5>Fill</h5>
|
|
||||||
<ButtonSelect
|
|
||||||
options={[
|
|
||||||
{ value: "solid", text: "Solid" },
|
|
||||||
{ value: "hachure", text: "Hachure" },
|
|
||||||
{ value: "cross-hatch", text: "Cross-hatch" }
|
|
||||||
]}
|
|
||||||
value={getSelectedAttribute(
|
|
||||||
elements,
|
|
||||||
element => element.fillStyle
|
|
||||||
)}
|
|
||||||
onChange={value => {
|
|
||||||
this.changeProperty(element => {
|
|
||||||
element.fillStyle = value;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{hasStroke(elements) && (
|
{hasBackground(elements) && (
|
||||||
<>
|
<>
|
||||||
<h5>Stroke Width</h5>
|
<PanelColor
|
||||||
<ButtonSelect
|
title="Background Color"
|
||||||
options={[
|
onColorChange={this.changeBackgroundColor}
|
||||||
{ value: 1, text: "Thin" },
|
colorValue={getSelectedAttribute(
|
||||||
{ value: 2, text: "Bold" },
|
elements,
|
||||||
{ value: 4, text: "Extra Bold" }
|
element => element.backgroundColor
|
||||||
]}
|
)}
|
||||||
value={getSelectedAttribute(
|
/>
|
||||||
elements,
|
|
||||||
element => element.strokeWidth
|
|
||||||
)}
|
|
||||||
onChange={value => {
|
|
||||||
this.changeProperty(element => {
|
|
||||||
element.strokeWidth = value;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<h5>Sloppiness</h5>
|
<h5>Fill</h5>
|
||||||
<ButtonSelect
|
<ButtonSelect
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, text: "Draftsman" },
|
{ value: "solid", text: "Solid" },
|
||||||
{ value: 1, text: "Artist" },
|
{ value: "hachure", text: "Hachure" },
|
||||||
{ value: 3, text: "Cartoonist" }
|
{ value: "cross-hatch", text: "Cross-hatch" }
|
||||||
]}
|
]}
|
||||||
value={getSelectedAttribute(
|
value={getSelectedAttribute(
|
||||||
elements,
|
elements,
|
||||||
element => element.roughness
|
element => element.fillStyle
|
||||||
)}
|
)}
|
||||||
onChange={value =>
|
onChange={value => {
|
||||||
this.changeProperty(element => {
|
this.changeProperty(element => {
|
||||||
element.roughness = value;
|
element.fillStyle = value;
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h5>Opacity</h5>
|
{hasStroke(elements) && (
|
||||||
<input
|
<>
|
||||||
type="range"
|
<h5>Stroke Width</h5>
|
||||||
min="0"
|
<ButtonSelect
|
||||||
max="100"
|
options={[
|
||||||
onChange={this.changeOpacity}
|
{ value: 1, text: "Thin" },
|
||||||
value={
|
{ value: 2, text: "Bold" },
|
||||||
getSelectedAttribute(elements, element => element.opacity) ||
|
{ value: 4, text: "Extra Bold" }
|
||||||
0 /* Put the opacity at 0 if there are two conflicting ones */
|
]}
|
||||||
}
|
value={getSelectedAttribute(
|
||||||
/>
|
elements,
|
||||||
|
element => element.strokeWidth
|
||||||
|
)}
|
||||||
|
onChange={value => {
|
||||||
|
this.changeProperty(element => {
|
||||||
|
element.strokeWidth = value;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<button onClick={this.deleteSelectedElements}>
|
<h5>Sloppiness</h5>
|
||||||
Delete selected
|
<ButtonSelect
|
||||||
</button>
|
options={[
|
||||||
</div>
|
{ value: 0, text: "Draftsman" },
|
||||||
)}
|
{ value: 1, text: "Artist" },
|
||||||
|
{ value: 3, text: "Cartoonist" }
|
||||||
|
]}
|
||||||
|
value={getSelectedAttribute(
|
||||||
|
elements,
|
||||||
|
element => element.roughness
|
||||||
|
)}
|
||||||
|
onChange={value =>
|
||||||
|
this.changeProperty(element => {
|
||||||
|
element.roughness = value;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasText(elements) && (
|
||||||
|
<>
|
||||||
|
<h5>Font size</h5>
|
||||||
|
<ButtonSelect
|
||||||
|
options={[
|
||||||
|
{ value: 16, text: "Small" },
|
||||||
|
{ value: 20, text: "Medium" },
|
||||||
|
{ value: 28, text: "Large" },
|
||||||
|
{ value: 36, text: "Very Large" }
|
||||||
|
]}
|
||||||
|
value={getSelectedAttribute(
|
||||||
|
elements,
|
||||||
|
element =>
|
||||||
|
isTextElement(element) && +element.font.split("px ")[0]
|
||||||
|
)}
|
||||||
|
onChange={value =>
|
||||||
|
this.changeProperty(element => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
element.font = `${value}px ${
|
||||||
|
element.font.split("px ")[1]
|
||||||
|
}`;
|
||||||
|
this.redrawTextBoundingBox(element);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<h5>Font familly</h5>
|
||||||
|
<ButtonSelect
|
||||||
|
options={[
|
||||||
|
{ value: "Virgil", text: "Virgil" },
|
||||||
|
{ value: "Helvetica", text: "Helvetica" },
|
||||||
|
{ value: "Courier", text: "Courier" }
|
||||||
|
]}
|
||||||
|
value={getSelectedAttribute(
|
||||||
|
elements,
|
||||||
|
element =>
|
||||||
|
isTextElement(element) && element.font.split("px ")[1]
|
||||||
|
)}
|
||||||
|
onChange={value =>
|
||||||
|
this.changeProperty(element => {
|
||||||
|
if (isTextElement(element)) {
|
||||||
|
element.font = `${
|
||||||
|
element.font.split("px ")[0]
|
||||||
|
}px ${value}`;
|
||||||
|
this.redrawTextBoundingBox(element);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h5>Opacity</h5>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
onChange={this.changeOpacity}
|
||||||
|
value={
|
||||||
|
getSelectedAttribute(elements, element => element.opacity) ||
|
||||||
|
0 /* Put the opacity at 0 if there are two conflicting ones */
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button onClick={this.deleteSelectedElements}>
|
||||||
|
Delete selected
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
<PanelCanvas
|
<PanelCanvas
|
||||||
onClearCanvas={this.clearCanvas}
|
onClearCanvas={this.clearCanvas}
|
||||||
onViewBackgroundColorChange={val =>
|
onViewBackgroundColorChange={val =>
|
||||||
|
@ -515,7 +566,9 @@ class App extends React.Component<{}, AppState> {
|
||||||
<PanelExport
|
<PanelExport
|
||||||
projectName={this.state.name}
|
projectName={this.state.name}
|
||||||
onProjectNameChange={this.updateProjectName}
|
onProjectNameChange={this.updateProjectName}
|
||||||
onExportAsPNG={() => exportAsPNG(elements, canvas, this.state)}
|
onExportAsPNG={() =>
|
||||||
|
exportAsPNG(elements, this.canvas!, this.state)
|
||||||
|
}
|
||||||
exportBackground={this.state.exportBackground}
|
exportBackground={this.state.exportBackground}
|
||||||
onExportBackgroundChange={val =>
|
onExportBackgroundChange={val =>
|
||||||
this.setState({ exportBackground: val })
|
this.setState({ exportBackground: val })
|
||||||
|
@ -535,6 +588,10 @@ class App extends React.Component<{}, AppState> {
|
||||||
width={canvasWidth * window.devicePixelRatio}
|
width={canvasWidth * window.devicePixelRatio}
|
||||||
height={canvasHeight * window.devicePixelRatio}
|
height={canvasHeight * window.devicePixelRatio}
|
||||||
ref={canvas => {
|
ref={canvas => {
|
||||||
|
if (this.canvas === null) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.rc = rough.canvas(this.canvas!);
|
||||||
|
}
|
||||||
if (this.removeWheelEventListener) {
|
if (this.removeWheelEventListener) {
|
||||||
this.removeWheelEventListener();
|
this.removeWheelEventListener();
|
||||||
this.removeWheelEventListener = undefined;
|
this.removeWheelEventListener = undefined;
|
||||||
|
@ -563,9 +620,7 @@ class App extends React.Component<{}, AppState> {
|
||||||
onContextMenu={e => {
|
onContextMenu={e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const x =
|
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
|
||||||
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
|
||||||
|
|
||||||
const element = getElementAtPosition(elements, x, y);
|
const element = getElementAtPosition(elements, x, y);
|
||||||
if (!element) {
|
if (!element) {
|
||||||
|
@ -642,9 +697,8 @@ class App extends React.Component<{}, AppState> {
|
||||||
this.state.scrollY
|
this.state.scrollY
|
||||||
);
|
);
|
||||||
|
|
||||||
const x =
|
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
|
||||||
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
|
||||||
const element = newElement(
|
const element = newElement(
|
||||||
this.state.elementType,
|
this.state.elementType,
|
||||||
x,
|
x,
|
||||||
|
@ -656,28 +710,23 @@ class App extends React.Component<{}, AppState> {
|
||||||
1,
|
1,
|
||||||
100
|
100
|
||||||
);
|
);
|
||||||
let resizeHandle: string | false = false;
|
type ResizeTestType = ReturnType<typeof resizeTest>;
|
||||||
|
let resizeHandle: ResizeTestType = false;
|
||||||
let isDraggingElements = false;
|
let isDraggingElements = false;
|
||||||
let isResizingElements = false;
|
let isResizingElements = false;
|
||||||
if (this.state.elementType === "selection") {
|
if (this.state.elementType === "selection") {
|
||||||
const resizeElement = elements.find(element => {
|
const resizeElement = getElementWithResizeHandler(
|
||||||
return resizeTest(element, x, y, {
|
elements,
|
||||||
scrollX: this.state.scrollX,
|
{ x, y },
|
||||||
scrollY: this.state.scrollY,
|
this.state
|
||||||
viewBackgroundColor: this.state.viewBackgroundColor
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
resizingElement: resizeElement ? resizeElement : null
|
resizingElement: resizeElement ? resizeElement.element : null
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resizeElement) {
|
if (resizeElement) {
|
||||||
resizeHandle = resizeTest(resizeElement, x, y, {
|
resizeHandle = resizeElement.resizeHandle;
|
||||||
scrollX: this.state.scrollX,
|
|
||||||
scrollY: this.state.scrollY,
|
|
||||||
viewBackgroundColor: this.state.viewBackgroundColor
|
|
||||||
});
|
|
||||||
document.documentElement.style.cursor = `${resizeHandle}-resize`;
|
document.documentElement.style.cursor = `${resizeHandle}-resize`;
|
||||||
isResizingElements = true;
|
isResizingElements = true;
|
||||||
} else {
|
} else {
|
||||||
|
@ -686,15 +735,27 @@ class App extends React.Component<{}, AppState> {
|
||||||
// If we click on something
|
// If we click on something
|
||||||
if (hitElement) {
|
if (hitElement) {
|
||||||
if (hitElement.isSelected) {
|
if (hitElement.isSelected) {
|
||||||
// If that element is not already selected, do nothing,
|
// If that element is already selected, do nothing,
|
||||||
// we're likely going to drag it
|
// we're likely going to drag it
|
||||||
} else {
|
} else {
|
||||||
// We unselect every other elements unless shift is pressed
|
// We unselect every other elements unless shift is pressed
|
||||||
if (!e.shiftKey) {
|
if (!e.shiftKey) {
|
||||||
clearSelection(elements);
|
clearSelection(elements);
|
||||||
}
|
}
|
||||||
// No matter what, we select it
|
}
|
||||||
hitElement.isSelected = true;
|
// No matter what, we select it
|
||||||
|
hitElement.isSelected = true;
|
||||||
|
// We duplicate the selected element if alt is pressed on Mouse down
|
||||||
|
if (e.altKey) {
|
||||||
|
elements.push(
|
||||||
|
...elements.reduce((duplicates, element) => {
|
||||||
|
if (element.isSelected) {
|
||||||
|
duplicates.push(duplicateElement(element));
|
||||||
|
element.isSelected = false;
|
||||||
|
}
|
||||||
|
return duplicates;
|
||||||
|
}, [] as typeof elements)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If we don't click on anything, let's remove all the selected elements
|
// If we don't click on anything, let's remove all the selected elements
|
||||||
|
@ -710,10 +771,25 @@ class App extends React.Component<{}, AppState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTextElement(element)) {
|
if (isTextElement(element)) {
|
||||||
|
let textX = e.clientX;
|
||||||
|
let textY = e.clientY;
|
||||||
|
if (!e.altKey) {
|
||||||
|
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
);
|
||||||
|
if (snappedToCenterPosition) {
|
||||||
|
element.x = snappedToCenterPosition.elementCenterX;
|
||||||
|
element.y = snappedToCenterPosition.elementCenterY;
|
||||||
|
textX = snappedToCenterPosition.wysiwygX;
|
||||||
|
textY = snappedToCenterPosition.wysiwygY;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
textWysiwyg({
|
textWysiwyg({
|
||||||
initText: "",
|
initText: "",
|
||||||
x: e.clientX,
|
x: textX,
|
||||||
y: e.clientY,
|
y: textY,
|
||||||
strokeColor: this.state.currentItemStrokeColor,
|
strokeColor: this.state.currentItemStrokeColor,
|
||||||
font: this.state.currentItemFont,
|
font: this.state.currentItemFont,
|
||||||
onSubmit: text => {
|
onSubmit: text => {
|
||||||
|
@ -774,10 +850,8 @@ class App extends React.Component<{}, AppState> {
|
||||||
const el = this.state.resizingElement;
|
const el = this.state.resizingElement;
|
||||||
const selectedElements = elements.filter(el => el.isSelected);
|
const selectedElements = elements.filter(el => el.isSelected);
|
||||||
if (selectedElements.length === 1) {
|
if (selectedElements.length === 1) {
|
||||||
const x =
|
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
|
||||||
const y =
|
|
||||||
e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
|
||||||
selectedElements.forEach(element => {
|
selectedElements.forEach(element => {
|
||||||
switch (resizeHandle) {
|
switch (resizeHandle) {
|
||||||
case "nw":
|
case "nw":
|
||||||
|
@ -849,10 +923,8 @@ class App extends React.Component<{}, AppState> {
|
||||||
if (isDraggingElements) {
|
if (isDraggingElements) {
|
||||||
const selectedElements = elements.filter(el => el.isSelected);
|
const selectedElements = elements.filter(el => el.isSelected);
|
||||||
if (selectedElements.length) {
|
if (selectedElements.length) {
|
||||||
const x =
|
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
|
||||||
const y =
|
|
||||||
e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
|
||||||
selectedElements.forEach(element => {
|
selectedElements.forEach(element => {
|
||||||
element.x += x - lastX;
|
element.x += x - lastX;
|
||||||
element.y += y - lastY;
|
element.y += y - lastY;
|
||||||
|
@ -936,16 +1008,9 @@ class App extends React.Component<{}, AppState> {
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}}
|
}}
|
||||||
onDoubleClick={e => {
|
onDoubleClick={e => {
|
||||||
const x =
|
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
|
||||||
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
|
||||||
const elementAtPosition = getElementAtPosition(elements, x, y);
|
const elementAtPosition = getElementAtPosition(elements, x, y);
|
||||||
if (elementAtPosition && !isTextElement(elementAtPosition)) {
|
|
||||||
return;
|
|
||||||
} else if (elementAtPosition) {
|
|
||||||
elements.splice(elements.indexOf(elementAtPosition), 1);
|
|
||||||
this.forceUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
const element = newElement(
|
const element = newElement(
|
||||||
"text",
|
"text",
|
||||||
|
@ -962,12 +1027,15 @@ class App extends React.Component<{}, AppState> {
|
||||||
let initText = "";
|
let initText = "";
|
||||||
let textX = e.clientX;
|
let textX = e.clientX;
|
||||||
let textY = e.clientY;
|
let textY = e.clientY;
|
||||||
if (elementAtPosition) {
|
|
||||||
|
if (elementAtPosition && isTextElement(elementAtPosition)) {
|
||||||
|
elements.splice(elements.indexOf(elementAtPosition), 1);
|
||||||
|
this.forceUpdate();
|
||||||
|
|
||||||
Object.assign(element, elementAtPosition);
|
Object.assign(element, elementAtPosition);
|
||||||
// x and y will change after calling addTextElement function
|
// x and y will change after calling addTextElement function
|
||||||
element.x = elementAtPosition.x + elementAtPosition.width / 2;
|
element.x = elementAtPosition.x + elementAtPosition.width / 2;
|
||||||
element.y =
|
element.y = elementAtPosition.y + elementAtPosition.height / 2;
|
||||||
elementAtPosition.y + elementAtPosition.actualBoundingBoxAscent;
|
|
||||||
initText = elementAtPosition.text;
|
initText = elementAtPosition.text;
|
||||||
textX =
|
textX =
|
||||||
this.state.scrollX +
|
this.state.scrollX +
|
||||||
|
@ -978,7 +1046,19 @@ class App extends React.Component<{}, AppState> {
|
||||||
this.state.scrollY +
|
this.state.scrollY +
|
||||||
elementAtPosition.y +
|
elementAtPosition.y +
|
||||||
CANVAS_WINDOW_OFFSET_TOP +
|
CANVAS_WINDOW_OFFSET_TOP +
|
||||||
elementAtPosition.actualBoundingBoxAscent;
|
elementAtPosition.height / 2;
|
||||||
|
} else if (!e.altKey) {
|
||||||
|
const snappedToCenterPosition = this.getTextWysiwygSnappedToCenterPosition(
|
||||||
|
x,
|
||||||
|
y
|
||||||
|
);
|
||||||
|
|
||||||
|
if (snappedToCenterPosition) {
|
||||||
|
element.x = snappedToCenterPosition.elementCenterX;
|
||||||
|
element.y = snappedToCenterPosition.elementCenterY;
|
||||||
|
textX = snappedToCenterPosition.wysiwygX;
|
||||||
|
textY = snappedToCenterPosition.wysiwygY;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
textWysiwyg({
|
textWysiwyg({
|
||||||
|
@ -1002,6 +1082,34 @@ class App extends React.Component<{}, AppState> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onMouseMove={e => {
|
||||||
|
const hasDeselectedButton = Boolean(e.buttons);
|
||||||
|
if (hasDeselectedButton || this.state.elementType !== "selection") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||||
|
const resizeElement = getElementWithResizeHandler(
|
||||||
|
elements,
|
||||||
|
{ x, y },
|
||||||
|
this.state
|
||||||
|
);
|
||||||
|
if (resizeElement && resizeElement.resizeHandle) {
|
||||||
|
document.documentElement.style.cursor = `${resizeElement.resizeHandle}-resize`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hitElement = getElementAtPosition(elements, x, y);
|
||||||
|
if (hitElement) {
|
||||||
|
const resizeHandle = resizeTest(hitElement, x, y, {
|
||||||
|
scrollX: this.state.scrollX,
|
||||||
|
scrollY: this.state.scrollY
|
||||||
|
});
|
||||||
|
document.documentElement.style.cursor = resizeHandle
|
||||||
|
? `${resizeHandle}-resize`
|
||||||
|
: `move`;
|
||||||
|
} else {
|
||||||
|
document.documentElement.style.cursor = ``;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1028,14 +1136,14 @@ class App extends React.Component<{}, AppState> {
|
||||||
) {
|
) {
|
||||||
clearSelection(elements);
|
clearSelection(elements);
|
||||||
|
|
||||||
const minX = Math.min(...parsedElements.map(element => element.x));
|
|
||||||
const minY = Math.min(...parsedElements.map(element => element.y));
|
|
||||||
|
|
||||||
let subCanvasX1 = Infinity;
|
let subCanvasX1 = Infinity;
|
||||||
let subCanvasX2 = 0;
|
let subCanvasX2 = 0;
|
||||||
let subCanvasY1 = Infinity;
|
let subCanvasY1 = Infinity;
|
||||||
let subCanvasY2 = 0;
|
let subCanvasY2 = 0;
|
||||||
|
|
||||||
|
const minX = Math.min(...parsedElements.map(element => element.x));
|
||||||
|
const minY = Math.min(...parsedElements.map(element => element.y));
|
||||||
|
|
||||||
const distance = (x: number, y: number) => {
|
const distance = (x: number, y: number) => {
|
||||||
return Math.abs(x > y ? x - y : y - x);
|
return Math.abs(x > y ? x - y : y - x);
|
||||||
};
|
};
|
||||||
|
@ -1054,27 +1162,56 @@ class App extends React.Component<{}, AppState> {
|
||||||
const dx =
|
const dx =
|
||||||
this.state.cursorX -
|
this.state.cursorX -
|
||||||
this.state.scrollX -
|
this.state.scrollX -
|
||||||
canvas.offsetLeft -
|
CANVAS_WINDOW_OFFSET_LEFT -
|
||||||
elementsWidth;
|
elementsWidth;
|
||||||
const dy =
|
const dy =
|
||||||
this.state.cursorY -
|
this.state.cursorY -
|
||||||
this.state.scrollY -
|
this.state.scrollY -
|
||||||
canvas.offsetTop -
|
CANVAS_WINDOW_OFFSET_TOP -
|
||||||
elementsHeight;
|
elementsHeight;
|
||||||
|
|
||||||
parsedElements.forEach(parsedElement => {
|
parsedElements.forEach(parsedElement => {
|
||||||
parsedElement.x += dx - minX;
|
const duplicate = duplicateElement(parsedElement);
|
||||||
parsedElement.y += dy - minY;
|
duplicate.x += dx - minX;
|
||||||
parsedElement.seed = randomSeed();
|
duplicate.y += dy - minY;
|
||||||
elements.push(parsedElement);
|
elements.push(duplicate);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {
|
||||||
|
const elementClickedInside = getElementContainingPosition(elements, x, y);
|
||||||
|
if (elementClickedInside) {
|
||||||
|
const elementCenterX =
|
||||||
|
elementClickedInside.x + elementClickedInside.width / 2;
|
||||||
|
const elementCenterY =
|
||||||
|
elementClickedInside.y + elementClickedInside.height / 2;
|
||||||
|
const distanceToCenter = Math.hypot(
|
||||||
|
x - elementCenterX,
|
||||||
|
y - elementCenterY
|
||||||
|
);
|
||||||
|
const isSnappedToCenter =
|
||||||
|
distanceToCenter < TEXT_TO_CENTER_SNAP_THRESHOLD;
|
||||||
|
if (isSnappedToCenter) {
|
||||||
|
const wysiwygX =
|
||||||
|
this.state.scrollX +
|
||||||
|
elementClickedInside.x +
|
||||||
|
CANVAS_WINDOW_OFFSET_LEFT +
|
||||||
|
elementClickedInside.width / 2;
|
||||||
|
const wysiwygY =
|
||||||
|
this.state.scrollY +
|
||||||
|
elementClickedInside.y +
|
||||||
|
CANVAS_WINDOW_OFFSET_TOP +
|
||||||
|
elementClickedInside.height / 2;
|
||||||
|
return { wysiwygX, wysiwygY, elementCenterX, elementCenterY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate() {
|
componentDidUpdate() {
|
||||||
renderScene(elements, rc, canvas, {
|
renderScene(elements, this.rc!, this.canvas!, {
|
||||||
scrollX: this.state.scrollX,
|
scrollX: this.state.scrollX,
|
||||||
scrollY: this.state.scrollY,
|
scrollY: this.state.scrollY,
|
||||||
viewBackgroundColor: this.state.viewBackgroundColor
|
viewBackgroundColor: this.state.viewBackgroundColor
|
||||||
|
@ -1090,8 +1227,3 @@ class App extends React.Component<{}, AppState> {
|
||||||
|
|
||||||
const rootElement = document.getElementById("root");
|
const rootElement = document.getElementById("root");
|
||||||
ReactDOM.render(<App />, rootElement);
|
ReactDOM.render(<App />, rootElement);
|
||||||
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
|
|
||||||
const rc = rough.canvas(canvas);
|
|
||||||
const context = canvas.getContext("2d")!;
|
|
||||||
|
|
||||||
ReactDOM.render(<App />, rootElement);
|
|
||||||
|
|
23
src/keys.ts
Normal file
23
src/keys.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
export const KEYS = {
|
||||||
|
ARROW_LEFT: "ArrowLeft",
|
||||||
|
ARROW_RIGHT: "ArrowRight",
|
||||||
|
ARROW_DOWN: "ArrowDown",
|
||||||
|
ARROW_UP: "ArrowUp",
|
||||||
|
ENTER: "Enter",
|
||||||
|
ESCAPE: "Escape",
|
||||||
|
DELETE: "Delete",
|
||||||
|
BACKSPACE: "Backspace"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const META_KEY = /Mac|iPod|iPhone|iPad/.test(window.navigator.platform)
|
||||||
|
? "metaKey"
|
||||||
|
: "ctrlKey";
|
||||||
|
|
||||||
|
export function isArrowKey(keyCode: string) {
|
||||||
|
return (
|
||||||
|
keyCode === KEYS.ARROW_LEFT ||
|
||||||
|
keyCode === KEYS.ARROW_RIGHT ||
|
||||||
|
keyCode === KEYS.ARROW_DOWN ||
|
||||||
|
keyCode === KEYS.ARROW_UP
|
||||||
|
);
|
||||||
|
}
|
|
@ -131,7 +131,9 @@ export function renderElement(
|
||||||
context.fillText(
|
context.fillText(
|
||||||
element.text,
|
element.text,
|
||||||
element.x + scrollX,
|
element.x + scrollX,
|
||||||
element.y + element.actualBoundingBoxAscent + scrollY
|
element.y +
|
||||||
|
scrollY +
|
||||||
|
(element.baseline || element.actualBoundingBoxAscent || 0)
|
||||||
);
|
);
|
||||||
context.fillStyle = fillStyle;
|
context.fillStyle = fillStyle;
|
||||||
context.font = font;
|
context.font = font;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
|
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
|
||||||
|
|
||||||
import { roundRect } from "../scene/roundRect";
|
import { roundRect } from "./roundRect";
|
||||||
import { SceneState } from "../scene/types";
|
import { SceneState } from "../scene/types";
|
||||||
import {
|
import {
|
||||||
getScrollBars,
|
getScrollBars,
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { ExcalidrawElement } from "../element/types";
|
import { ExcalidrawElement } from "../element/types";
|
||||||
import { hitTest } from "../element/collision";
|
import { hitTest } from "../element/collision";
|
||||||
|
import { getElementAbsoluteCoords } from "../element";
|
||||||
|
|
||||||
export const hasBackground = (elements: ExcalidrawElement[]) =>
|
export const hasBackground = (elements: ExcalidrawElement[]) =>
|
||||||
elements.some(
|
elements.some(
|
||||||
|
@ -20,6 +21,9 @@ export const hasStroke = (elements: ExcalidrawElement[]) =>
|
||||||
element.type === "arrow")
|
element.type === "arrow")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const hasText = (elements: ExcalidrawElement[]) =>
|
||||||
|
elements.some(element => element.isSelected && element.type === "text");
|
||||||
|
|
||||||
export function getElementAtPosition(
|
export function getElementAtPosition(
|
||||||
elements: ExcalidrawElement[],
|
elements: ExcalidrawElement[],
|
||||||
x: number,
|
x: number,
|
||||||
|
@ -36,3 +40,20 @@ export function getElementAtPosition(
|
||||||
|
|
||||||
return hitElement;
|
return hitElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getElementContainingPosition(
|
||||||
|
elements: ExcalidrawElement[],
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
) {
|
||||||
|
let hitElement = null;
|
||||||
|
// We need to to hit testing from front (end of the array) to back (beginning of the array)
|
||||||
|
for (let i = elements.length - 1; i >= 0; --i) {
|
||||||
|
const [x1, y1, x2, y2] = getElementAbsoluteCoords(elements[i]);
|
||||||
|
if (x1 < x && x < x2 && y1 < y && y < y2) {
|
||||||
|
hitElement = elements[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hitElement;
|
||||||
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { getElementAbsoluteCoords } from "../element";
|
||||||
|
|
||||||
import { renderScene } from "../renderer";
|
import { renderScene } from "../renderer";
|
||||||
import { AppState } from "../types";
|
import { AppState } from "../types";
|
||||||
|
import nanoid from "nanoid";
|
||||||
|
|
||||||
const LOCAL_STORAGE_KEY = "excalidraw";
|
const LOCAL_STORAGE_KEY = "excalidraw";
|
||||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
||||||
|
@ -143,6 +144,7 @@ function restore(
|
||||||
: savedElements)
|
: savedElements)
|
||||||
);
|
);
|
||||||
elements.forEach((element: ExcalidrawElement) => {
|
elements.forEach((element: ExcalidrawElement) => {
|
||||||
|
element.id = element.id || nanoid();
|
||||||
element.fillStyle = element.fillStyle || "hachure";
|
element.fillStyle = element.fillStyle || "hachure";
|
||||||
element.strokeWidth = element.strokeWidth || 1;
|
element.strokeWidth = element.strokeWidth || 1;
|
||||||
element.roughness = element.roughness || 1;
|
element.roughness = element.roughness || 1;
|
||||||
|
|
|
@ -14,5 +14,11 @@ export {
|
||||||
restoreFromLocalStorage,
|
restoreFromLocalStorage,
|
||||||
saveToLocalStorage
|
saveToLocalStorage
|
||||||
} from "./data";
|
} from "./data";
|
||||||
export { hasBackground, hasStroke, getElementAtPosition } from "./comparisons";
|
export {
|
||||||
|
hasBackground,
|
||||||
|
hasStroke,
|
||||||
|
getElementAtPosition,
|
||||||
|
getElementContainingPosition,
|
||||||
|
hasText
|
||||||
|
} from "./comparisons";
|
||||||
export { createScene } from "./createScene";
|
export { createScene } from "./createScene";
|
||||||
|
|
|
@ -7,6 +7,11 @@ export type SceneState = {
|
||||||
viewBackgroundColor: string | null;
|
viewBackgroundColor: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SceneScroll = {
|
||||||
|
scrollX: number;
|
||||||
|
scrollY: number;
|
||||||
|
};
|
||||||
|
|
||||||
export interface Scene {
|
export interface Scene {
|
||||||
elements: ExcalidrawTextElement[];
|
elements: ExcalidrawTextElement[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,6 +30,27 @@ body {
|
||||||
margin: 10px 0 10px 0;
|
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 {
|
.panelTools {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
28
src/utils.ts
28
src/utils.ts
|
@ -18,8 +18,36 @@ export function isInputLike(
|
||||||
target: Element | EventTarget | null
|
target: Element | EventTarget | null
|
||||||
): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
|
): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
|
||||||
return (
|
return (
|
||||||
|
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||||
target instanceof HTMLInputElement ||
|
target instanceof HTMLInputElement ||
|
||||||
target instanceof HTMLTextAreaElement ||
|
target instanceof HTMLTextAreaElement ||
|
||||||
target instanceof HTMLSelectElement
|
target instanceof HTMLSelectElement
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
|
||||||
|
export function measureText(text: string, font: string) {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
const body = document.body;
|
||||||
|
line.style.position = "absolute";
|
||||||
|
line.style.whiteSpace = "nowrap";
|
||||||
|
line.style.font = font;
|
||||||
|
body.appendChild(line);
|
||||||
|
// Now we can measure width and height of the letter
|
||||||
|
line.innerHTML = text;
|
||||||
|
const width = line.offsetWidth;
|
||||||
|
const height = line.offsetHeight;
|
||||||
|
// Now creating 1px sized item that will be aligned to baseline
|
||||||
|
// to calculate baseline shift
|
||||||
|
const span = document.createElement("span");
|
||||||
|
span.style.display = "inline-block";
|
||||||
|
span.style.overflow = "hidden";
|
||||||
|
span.style.width = "1px";
|
||||||
|
span.style.height = "1px";
|
||||||
|
line.appendChild(span);
|
||||||
|
// Baseline is important for positioning text on canvas
|
||||||
|
const baseline = span.offsetTop + span.offsetHeight;
|
||||||
|
document.body.removeChild(line);
|
||||||
|
|
||||||
|
return { width, height, baseline };
|
||||||
|
}
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
@font-face{font-family:Virgil;src:url(https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf);font-display:swap}body{margin:0;font-family:Arial,Helvetica,sans-serif}.container{display:flex;position:fixed;top:0;bottom:0;left:0;right:0}.sidePanel{width:230px;background-color:#eee;padding:10px;overflow-y:auto}.sidePanel h4{margin:10px 0}.sidePanel .panelTools{display:flex;justify-content:space-between}.sidePanel .panelTools label{margin:0}.sidePanel .panelColumn{display:flex;flex-direction:column}.tool{position:relative}.tool input[type=radio]{position:absolute;opacity:0;width:0;height:0}.tool input[type=radio]+.toolIcon{background-color:#ddd;width:41px;height:41px;display:flex;justify-content:center;align-items:center;border-radius:3px}.tool input[type=radio]+.toolIcon svg{height:1em}.tool input[type=radio]:hover+.toolIcon{background-color:#e7e5e5}.tool input[type=radio]:checked+.toolIcon{background-color:#bdbebc}.tool input[type=radio]:focus+.toolIcon{box-shadow:0 0 0 2px #4682b4}label{margin-right:6px}label span{display:inline-block}input[type=number]{width:30px}input[type=color]{margin:2px}input{margin-right:5px}input:focus{box-shadow:0 0 0 2px #4682b4}button,input:focus{outline:transparent}button{background-color:#ddd;border:1px solid #ccc;border-radius:4px;margin:2px 0;padding:5px}button:focus{box-shadow:0 0 0 2px #4682b4}button:hover{background-color:#e7e5e5;border-color:#d6d4d4}button:active{background-color:#bdbebc;border-color:#bdbebc}button:disabled{cursor:not-allowed}
|
|
||||||
/*# sourceMappingURL=main.4b4142f8.chunk.css.map */
|
|
|
@ -1 +0,0 @@
|
||||||
{"version":3,"sources":["styles.scss"],"names":[],"mappings":"AACA,WACE,kBAAqB,CACrB,4GAA+G,CAC/G,iBAAkB,CAGpB,KACE,QAAS,CACT,sCAAyC,CAC1C,WAGC,YAAa,CACb,cAAe,CACf,KAAM,CACN,QAAS,CACT,MAAO,CACP,OAAQ,CACT,WAGC,WAAY,CACZ,qBAAsB,CAEtB,YAAa,CACb,eAAgB,CALlB,cAQI,aAAqB,CARzB,uBAYI,YAAa,CACb,6BAA8B,CAblC,6BAgBM,QAAS,CAhBf,wBAqBI,YAAa,CACb,qBAAsB,CACvB,MAID,iBAAkB,CADpB,wBAII,iBAAkB,CAClB,SAAU,CACV,OAAQ,CACR,QAAS,CAPb,kCAYM,qBAAsB,CAEtB,UAAW,CACX,WAAY,CAEZ,YAAa,CACb,sBAAuB,CACvB,kBAAmB,CAEnB,iBAAkB,CArBxB,sCAwBQ,UAAW,CAxBnB,wCA4BM,wBAAyB,CA5B/B,0CA+BM,wBAAyB,CA/B/B,wCAkCM,4BAA+B,CAChC,MAKH,gBAAiB,CADnB,WAGI,oBAAqB,CACtB,mBAID,UAAW,CACZ,kBAGC,UAAW,CACZ,MAGC,gBAAiB,CADnB,YAKI,4BAA+B,CAChC,mBAFC,mBAWkB,CATnB,OAID,qBAAsB,CACtB,qBAAsB,CACtB,iBAAkB,CAClB,YAAa,CACb,WACoB,CANtB,aASI,4BAA+B,CATnC,aAaI,wBAAyB,CACzB,oBAAqB,CAdzB,cAkBI,wBAAyB,CACzB,oBAAqB,CAnBzB,gBAuBI,kBAAmB","file":"main.4b4142f8.chunk.css","sourcesContent":["/* http://www.eaglefonts.com/fg-virgil-ttf-131249.htm */\n@font-face {\n font-family: \"Virgil\";\n src: url(\"https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf\");\n font-display: swap;\n}\n\nbody {\n margin: 0;\n font-family: Arial, Helvetica, sans-serif;\n}\n\n.container {\n display: flex;\n position: fixed;\n top: 0;\n bottom: 0;\n left: 0;\n right: 0;\n}\n\n.sidePanel {\n width: 230px;\n background-color: #eee;\n\n padding: 10px;\n overflow-y: auto;\n\n h4 {\n margin: 10px 0 10px 0;\n }\n\n .panelTools {\n display: flex;\n justify-content: space-between;\n\n label {\n margin: 0;\n }\n }\n\n .panelColumn {\n display: flex;\n flex-direction: column;\n }\n}\n\n.tool {\n position: relative;\n\n input[type=\"radio\"] {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n input[type=\"radio\"] {\n & + .toolIcon {\n background-color: #ddd;\n\n width: 41px;\n height: 41px;\n\n display: flex;\n justify-content: center;\n align-items: center;\n\n border-radius: 3px;\n\n svg {\n height: 1em;\n }\n }\n &:hover + .toolIcon {\n background-color: #e7e5e5;\n }\n &:checked + .toolIcon {\n background-color: #bdbebc;\n }\n &:focus + .toolIcon {\n box-shadow: 0 0 0 2px steelblue;\n }\n }\n}\n\nlabel {\n margin-right: 6px;\n span {\n display: inline-block;\n }\n}\n\ninput[type=\"number\"] {\n width: 30px;\n}\n\ninput[type=\"color\"] {\n margin: 2px;\n}\n\ninput {\n margin-right: 5px;\n\n &:focus {\n outline: transparent;\n box-shadow: 0 0 0 2px steelblue;\n }\n}\n\nbutton {\n background-color: #ddd;\n border: 1px solid #ccc;\n border-radius: 4px;\n margin: 2px 0;\n padding: 5px;\n outline: transparent;\n\n &:focus {\n box-shadow: 0 0 0 2px steelblue;\n }\n\n &:hover {\n background-color: #e7e5e5;\n border-color: #d6d4d4;\n }\n\n &:active {\n background-color: #bdbebc;\n border-color: #bdbebc;\n }\n\n &:disabled {\n cursor: not-allowed;\n }\n}\n"]}
|
|
File diff suppressed because one or more lines are too long
|
@ -1,32 +0,0 @@
|
||||||
/*
|
|
||||||
object-assign
|
|
||||||
(c) Sindre Sorhus
|
|
||||||
@license MIT
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v16.12.0
|
|
||||||
* react.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v16.12.0
|
|
||||||
* react-dom.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** @license React v0.18.0
|
|
||||||
* scheduler.production.min.js
|
|
||||||
*
|
|
||||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
|
||||||
*
|
|
||||||
* This source code is licensed under the MIT license found in the
|
|
||||||
* LICENSE file in the root directory of this source tree.
|
|
||||||
*/
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -1,2 +0,0 @@
|
||||||
!function(e){function r(r){for(var n,l,a=r[0],f=r[1],i=r[2],p=0,s=[];p<a.length;p++)l=a[p],Object.prototype.hasOwnProperty.call(o,l)&&o[l]&&s.push(o[l][0]),o[l]=0;for(n in f)Object.prototype.hasOwnProperty.call(f,n)&&(e[n]=f[n]);for(c&&c(r);s.length;)s.shift()();return u.push.apply(u,i||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,a=1;a<t.length;a++){var f=t[a];0!==o[f]&&(n=!1)}n&&(u.splice(r--,1),e=l(l.s=t[0]))}return e}var n={},o={1:0},u=[];function l(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,l),t.l=!0,t.exports}l.m=e,l.c=n,l.d=function(e,r,t){l.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},l.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},l.t=function(e,r){if(1&r&&(e=l(e)),8&r)return e;if(4&r&&"object"===typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(l.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)l.d(t,n,function(r){return e[r]}.bind(null,n));return t},l.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return l.d(r,"a",r),r},l.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},l.p="/";var a=this.webpackJsonpreact=this.webpackJsonpreact||[],f=a.push.bind(a);a.push=r,a=a.slice();for(var i=0;i<a.length;i++)r(a[i]);var c=f;t()}([]);
|
|
||||||
//# sourceMappingURL=runtime-main.b019aae8.js.map
|
|
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue