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/
|
||||
|
||||
.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/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
|
||||
|
||||
|
|
14
package-lock.json
generated
14
package-lock.json
generated
|
@ -1473,6 +1473,7 @@
|
|||
"version": "24.0.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-24.0.25.tgz",
|
||||
"integrity": "sha512-hnP1WpjN4KbGEK4dLayul6lgtys6FPz0UfxMeMQCv0M+sTnzN3ConfiO72jHgLxl119guHgI8gLqDOrRLsyp2g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"jest-diff": "^24.3.0"
|
||||
}
|
||||
|
@ -1487,6 +1488,14 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
|
||||
"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": {
|
||||
"version": "13.1.2",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz",
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
"keywords": [],
|
||||
"main": "src/index.js",
|
||||
"dependencies": {
|
||||
"nanoid": "^2.1.9",
|
||||
"react": "16.12.0",
|
||||
"react-color": "^2.17.3",
|
||||
"react-dom": "16.12.0",
|
||||
|
@ -14,6 +15,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^24.0.25",
|
||||
"@types/nanoid": "^2.1.0",
|
||||
"@types/react": "16.9.17",
|
||||
"@types/react-color": "^3.0.1",
|
||||
"@types/react-dom": "16.9.4",
|
||||
|
@ -49,7 +51,7 @@
|
|||
"git add"
|
||||
],
|
||||
"*.{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 { ColorPicker } from "../ColorPicker";
|
||||
import { Panel } from "../Panel";
|
||||
|
||||
interface PanelCanvasProps {
|
||||
viewBackgroundColor: string;
|
||||
|
@ -14,9 +15,7 @@ export const PanelCanvas: React.FC<PanelCanvasProps> = ({
|
|||
onClearCanvas
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h4>Canvas</h4>
|
||||
<div className="panelColumn">
|
||||
<Panel title="Canvas">
|
||||
<h5>Canvas Background Color</h5>
|
||||
<ColorPicker
|
||||
color={viewBackgroundColor}
|
||||
|
@ -29,7 +28,6 @@ export const PanelCanvas: React.FC<PanelCanvasProps> = ({
|
|||
>
|
||||
Clear canvas
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import React from "react";
|
||||
import { EditableText } from "../EditableText";
|
||||
import { Panel } from "../Panel";
|
||||
|
||||
interface PanelExportProps {
|
||||
projectName: string;
|
||||
|
@ -21,8 +22,7 @@ export const PanelExport: React.FC<PanelExportProps> = ({
|
|||
onExportAsPNG
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h4>Export</h4>
|
||||
<Panel title="Export">
|
||||
<div className="panelColumn">
|
||||
<h5>Name</h5>
|
||||
{projectName && (
|
||||
|
@ -47,6 +47,6 @@ export const PanelExport: React.FC<PanelExportProps> = ({
|
|||
<button onClick={onSaveScene}>Save as...</button>
|
||||
<button onClick={onLoadScene}>Load file...</button>
|
||||
</div>
|
||||
</>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -14,8 +14,7 @@ export const PanelSelection: React.FC<PanelSelectionProps> = ({
|
|||
onSendToBack
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h4>Selection</h4>
|
||||
<div>
|
||||
<div className="buttonList">
|
||||
<button type="button" onClick={onBringForward}>
|
||||
Bring forward
|
||||
|
@ -30,6 +29,6 @@ export const PanelSelection: React.FC<PanelSelectionProps> = ({
|
|||
Send to back
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@ import React from "react";
|
|||
|
||||
import { SHAPES } from "../../shapes";
|
||||
import { capitalizeString } from "../../utils";
|
||||
import { Panel } from "../Panel";
|
||||
|
||||
interface PanelToolsProps {
|
||||
activeTool: string;
|
||||
|
@ -13,8 +14,7 @@ export const PanelTools: React.FC<PanelToolsProps> = ({
|
|||
onToolChange
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<h4>Shapes</h4>
|
||||
<Panel title="Shapes">
|
||||
<div className="panelTools">
|
||||
{SHAPES.map(({ value, icon }) => (
|
||||
<label
|
||||
|
@ -33,6 +33,6 @@ export const PanelTools: React.FC<PanelToolsProps> = ({
|
|||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import { SceneState } from "../scene/types";
|
||||
import { ExcalidrawElement } from "./types";
|
||||
import { SceneScroll } from "../scene/types";
|
||||
|
||||
type Sides = "n" | "s" | "w" | "e" | "nw" | "ne" | "sw" | "se";
|
||||
|
||||
export function handlerRectangles(
|
||||
element: ExcalidrawElement,
|
||||
sceneState: SceneState
|
||||
{ scrollX, scrollY }: SceneScroll
|
||||
) {
|
||||
const elementX1 = element.x;
|
||||
const elementX2 = element.x + element.width;
|
||||
|
@ -12,22 +14,22 @@ export function handlerRectangles(
|
|||
|
||||
const margin = 4;
|
||||
const minimumSize = 40;
|
||||
const handlers: { [handler: string]: number[] } = {};
|
||||
const handlers = {} as { [T in Sides]: number[] };
|
||||
|
||||
const marginX = element.width < 0 ? 8 : -8;
|
||||
const marginY = element.height < 0 ? 8 : -8;
|
||||
|
||||
if (Math.abs(elementX2 - elementX1) > minimumSize) {
|
||||
handlers["n"] = [
|
||||
elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4,
|
||||
elementY1 - margin + sceneState.scrollY + marginY,
|
||||
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
|
||||
elementY1 - margin + scrollY + marginY,
|
||||
8,
|
||||
8
|
||||
];
|
||||
|
||||
handlers["s"] = [
|
||||
elementX1 + (elementX2 - elementX1) / 2 + sceneState.scrollX - 4,
|
||||
elementY2 - margin + sceneState.scrollY - marginY,
|
||||
elementX1 + (elementX2 - elementX1) / 2 + scrollX - 4,
|
||||
elementY2 - margin + scrollY - marginY,
|
||||
8,
|
||||
8
|
||||
];
|
||||
|
@ -35,41 +37,41 @@ export function handlerRectangles(
|
|||
|
||||
if (Math.abs(elementY2 - elementY1) > minimumSize) {
|
||||
handlers["w"] = [
|
||||
elementX1 - margin + sceneState.scrollX + marginX,
|
||||
elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4,
|
||||
elementX1 - margin + scrollX + marginX,
|
||||
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
|
||||
8,
|
||||
8
|
||||
];
|
||||
|
||||
handlers["e"] = [
|
||||
elementX2 - margin + sceneState.scrollX - marginX,
|
||||
elementY1 + (elementY2 - elementY1) / 2 + sceneState.scrollY - 4,
|
||||
elementX2 - margin + scrollX - marginX,
|
||||
elementY1 + (elementY2 - elementY1) / 2 + scrollY - 4,
|
||||
8,
|
||||
8
|
||||
];
|
||||
}
|
||||
|
||||
handlers["nw"] = [
|
||||
elementX1 - margin + sceneState.scrollX + marginX,
|
||||
elementY1 - margin + sceneState.scrollY + marginY,
|
||||
elementX1 - margin + scrollX + marginX,
|
||||
elementY1 - margin + scrollY + marginY,
|
||||
8,
|
||||
8
|
||||
]; // nw
|
||||
handlers["ne"] = [
|
||||
elementX2 - margin + sceneState.scrollX - marginX,
|
||||
elementY1 - margin + sceneState.scrollY + marginY,
|
||||
elementX2 - margin + scrollX - marginX,
|
||||
elementY1 - margin + scrollY + marginY,
|
||||
8,
|
||||
8
|
||||
]; // ne
|
||||
handlers["sw"] = [
|
||||
elementX1 - margin + sceneState.scrollX + marginX,
|
||||
elementY2 - margin + sceneState.scrollY - marginY,
|
||||
elementX1 - margin + scrollX + marginX,
|
||||
elementY2 - margin + scrollY - marginY,
|
||||
8,
|
||||
8
|
||||
]; // sw
|
||||
handlers["se"] = [
|
||||
elementX2 - margin + sceneState.scrollX - marginX,
|
||||
elementY2 - margin + sceneState.scrollY - marginY,
|
||||
elementX2 - margin + scrollX - marginX,
|
||||
elementY2 - margin + scrollY - marginY,
|
||||
8,
|
||||
8
|
||||
]; // se
|
||||
|
@ -78,7 +80,7 @@ export function handlerRectangles(
|
|||
return {
|
||||
nw: handlers.nw,
|
||||
se: handlers.se
|
||||
};
|
||||
} as typeof handlers;
|
||||
}
|
||||
|
||||
return handlers;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export { newElement } from "./newElement";
|
||||
export { newElement, duplicateElement } from "./newElement";
|
||||
export {
|
||||
getElementAbsoluteCoords,
|
||||
getDiamondPoints,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { randomSeed } from "../random";
|
||||
import nanoid from "nanoid";
|
||||
|
||||
export function newElement(
|
||||
type: string,
|
||||
|
@ -14,6 +15,7 @@ export function newElement(
|
|||
height = 0
|
||||
) {
|
||||
const element = {
|
||||
id: nanoid(),
|
||||
type,
|
||||
x,
|
||||
y,
|
||||
|
@ -30,3 +32,10 @@ export function newElement(
|
|||
};
|
||||
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 { SceneState } from "../scene/types";
|
||||
|
||||
import { handlerRectangles } from "./handlerRectangles";
|
||||
import { SceneScroll } from "../scene/types";
|
||||
|
||||
type HandlerRectanglesRet = keyof ReturnType<typeof handlerRectangles>;
|
||||
|
||||
export function resizeTest(
|
||||
element: ExcalidrawElement,
|
||||
x: number,
|
||||
y: number,
|
||||
sceneState: SceneState
|
||||
): string | false {
|
||||
if (element.type === "text") return false;
|
||||
{ scrollX, scrollY }: SceneScroll
|
||||
): HandlerRectanglesRet | 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 handler = handlers[key];
|
||||
const handler = handlers[key as HandlerRectanglesRet]!;
|
||||
|
||||
return (
|
||||
x + sceneState.scrollX >= handler[0] &&
|
||||
x + sceneState.scrollX <= handler[0] + handler[2] &&
|
||||
y + sceneState.scrollY >= handler[1] &&
|
||||
y + sceneState.scrollY <= handler[1] + handler[3]
|
||||
x + scrollX >= handler[0] &&
|
||||
x + scrollX <= handler[0] + handler[2] &&
|
||||
y + scrollY >= handler[1] &&
|
||||
y + scrollY <= handler[1] + handler[3]
|
||||
);
|
||||
});
|
||||
|
||||
if (filter.length > 0) {
|
||||
return filter[0];
|
||||
return filter[0] as HandlerRectanglesRet;
|
||||
}
|
||||
|
||||
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 = {
|
||||
initText: string;
|
||||
|
@ -17,25 +17,37 @@ export function textWysiwyg({
|
|||
font,
|
||||
onSubmit
|
||||
}: TextWysiwygParams) {
|
||||
const input = document.createElement("input");
|
||||
input.value = initText;
|
||||
Object.assign(input.style, {
|
||||
// Using contenteditable here as it has dynamic width.
|
||||
// But this solution has an issue — it allows to paste
|
||||
// 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,
|
||||
position: "absolute",
|
||||
top: y - 8 + "px",
|
||||
top: y + "px",
|
||||
left: x + "px",
|
||||
transform: "translate(-50%, -50%)",
|
||||
boxShadow: "none",
|
||||
textAlign: "center",
|
||||
width: (window.innerWidth - x) * 2 + "px",
|
||||
display: "inline-block",
|
||||
font: font,
|
||||
border: "none",
|
||||
background: "transparent"
|
||||
padding: "4px",
|
||||
outline: "transparent",
|
||||
whiteSpace: "nowrap"
|
||||
});
|
||||
|
||||
input.onkeydown = ev => {
|
||||
editable.onkeydown = ev => {
|
||||
if (ev.key === KEYS.ESCAPE) {
|
||||
ev.preventDefault();
|
||||
if (initText) {
|
||||
editable.innerText = initText;
|
||||
handleSubmit();
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
@ -44,28 +56,34 @@ export function textWysiwyg({
|
|||
handleSubmit();
|
||||
}
|
||||
};
|
||||
input.onblur = handleSubmit;
|
||||
editable.onblur = handleSubmit;
|
||||
|
||||
function stopEvent(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (input.value) {
|
||||
onSubmit(input.value);
|
||||
if (editable.innerText) {
|
||||
onSubmit(editable.innerText);
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
input.onblur = null;
|
||||
input.onkeydown = null;
|
||||
editable.onblur = null;
|
||||
editable.onkeydown = null;
|
||||
window.removeEventListener("wheel", stopEvent, true);
|
||||
document.body.removeChild(input);
|
||||
document.body.removeChild(editable);
|
||||
}
|
||||
|
||||
window.addEventListener("wheel", stopEvent, true);
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
document.body.appendChild(editable);
|
||||
editable.focus();
|
||||
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";
|
||||
font: string;
|
||||
text: string;
|
||||
actualBoundingBoxAscent: number;
|
||||
// for backward compatibility
|
||||
actualBoundingBoxAscent?: number;
|
||||
baseline: number;
|
||||
};
|
||||
|
|
348
src/index.tsx
348
src/index.tsx
|
@ -1,11 +1,13 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import rough from "roughjs/bin/wrappers/rough";
|
||||
import { RoughCanvas } from "roughjs/bin/canvas";
|
||||
|
||||
import { moveOneLeft, moveAllLeft, moveOneRight, moveAllRight } from "./zindex";
|
||||
import { randomSeed } from "./random";
|
||||
import {
|
||||
newElement,
|
||||
duplicateElement,
|
||||
resizeTest,
|
||||
isTextElement,
|
||||
textWysiwyg,
|
||||
|
@ -27,61 +29,42 @@ import {
|
|||
hasBackground,
|
||||
hasStroke,
|
||||
getElementAtPosition,
|
||||
createScene
|
||||
createScene,
|
||||
getElementContainingPosition,
|
||||
hasText
|
||||
} from "./scene";
|
||||
|
||||
import { renderScene } from "./renderer";
|
||||
import { AppState } from "./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 { findShapeByKey, shapesShortcutKeys } from "./shapes";
|
||||
import { createHistory } from "./history";
|
||||
|
||||
import "./styles.scss";
|
||||
import ContextMenu from "./components/ContextMenu";
|
||||
import { PanelTools } from "./components/panels/PanelTools";
|
||||
import { PanelSelection } from "./components/panels/PanelSelection";
|
||||
import { PanelColor } from "./components/panels/PanelColor";
|
||||
import { PanelExport } from "./components/panels/PanelExport";
|
||||
import { PanelCanvas } from "./components/panels/PanelCanvas";
|
||||
import { Panel } from "./components/Panel";
|
||||
|
||||
import "./styles.scss";
|
||||
import { getElementWithResizeHandler } from "./element/resizeTest";
|
||||
|
||||
const { elements } = createScene();
|
||||
const { history } = createHistory();
|
||||
|
||||
const DEFAULT_PROJECT_NAME = `excalidraw-${getDateTime()}`;
|
||||
|
||||
const CANVAS_WINDOW_OFFSET_LEFT = 250;
|
||||
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 = "{}";
|
||||
|
||||
function isArrowKey(keyCode: string) {
|
||||
return (
|
||||
keyCode === KEYS.ARROW_LEFT ||
|
||||
keyCode === KEYS.ARROW_RIGHT ||
|
||||
keyCode === KEYS.ARROW_DOWN ||
|
||||
keyCode === KEYS.ARROW_UP
|
||||
);
|
||||
}
|
||||
|
||||
function resetCursor() {
|
||||
document.documentElement.style.cursor = "";
|
||||
}
|
||||
|
@ -95,36 +78,42 @@ function addTextElement(
|
|||
if (text === null || text === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const metrics = measureText(text, font);
|
||||
element.text = text;
|
||||
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
|
||||
element.x -= width / 2;
|
||||
element.y -= actualBoundingBoxAscent;
|
||||
element.width = width;
|
||||
element.height = height;
|
||||
element.x -= metrics.width / 2;
|
||||
element.y -= metrics.height / 2;
|
||||
element.width = metrics.width;
|
||||
element.height = metrics.height;
|
||||
element.baseline = metrics.baseline;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
|
||||
const ELEMENT_TRANSLATE_AMOUNT = 1;
|
||||
const TEXT_TO_CENTER_SNAP_THRESHOLD = 30;
|
||||
|
||||
let lastCanvasWidth = -1;
|
||||
let lastCanvasHeight = -1;
|
||||
|
||||
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() {
|
||||
document.addEventListener("keydown", this.onKeyDown, false);
|
||||
document.addEventListener("mousemove", this.getCurrentCursorPosition);
|
||||
|
@ -287,6 +276,10 @@ class App extends React.Component<{}, AppState> {
|
|||
element.fillStyle = pastedElement?.fillStyle;
|
||||
element.opacity = pastedElement?.opacity;
|
||||
element.roughness = pastedElement?.roughness;
|
||||
if (isTextElement(element)) {
|
||||
element.font = pastedElement?.font;
|
||||
this.redrawTextBoundingBox(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
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() {
|
||||
const canvasWidth = window.innerWidth - CANVAS_WINDOW_OFFSET_LEFT;
|
||||
const canvasHeight = window.innerHeight - CANVAS_WINDOW_OFFSET_TOP;
|
||||
|
@ -399,8 +400,7 @@ class App extends React.Component<{}, AppState> {
|
|||
this.forceUpdate();
|
||||
}}
|
||||
/>
|
||||
{someElementIsSelected(elements) && (
|
||||
<div className="panelColumn">
|
||||
<Panel title="Selection" hide={!someElementIsSelected(elements)}>
|
||||
<PanelSelection
|
||||
onBringForward={this.moveOneRight}
|
||||
onBringToFront={this.moveAllRight}
|
||||
|
@ -488,6 +488,58 @@ class App extends React.Component<{}, AppState> {
|
|||
</>
|
||||
)}
|
||||
|
||||
{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"
|
||||
|
@ -503,8 +555,7 @@ class App extends React.Component<{}, AppState> {
|
|||
<button onClick={this.deleteSelectedElements}>
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
<PanelCanvas
|
||||
onClearCanvas={this.clearCanvas}
|
||||
onViewBackgroundColorChange={val =>
|
||||
|
@ -515,7 +566,9 @@ class App extends React.Component<{}, AppState> {
|
|||
<PanelExport
|
||||
projectName={this.state.name}
|
||||
onProjectNameChange={this.updateProjectName}
|
||||
onExportAsPNG={() => exportAsPNG(elements, canvas, this.state)}
|
||||
onExportAsPNG={() =>
|
||||
exportAsPNG(elements, this.canvas!, this.state)
|
||||
}
|
||||
exportBackground={this.state.exportBackground}
|
||||
onExportBackgroundChange={val =>
|
||||
this.setState({ exportBackground: val })
|
||||
|
@ -535,6 +588,10 @@ class App extends React.Component<{}, AppState> {
|
|||
width={canvasWidth * window.devicePixelRatio}
|
||||
height={canvasHeight * window.devicePixelRatio}
|
||||
ref={canvas => {
|
||||
if (this.canvas === null) {
|
||||
this.canvas = canvas;
|
||||
this.rc = rough.canvas(this.canvas!);
|
||||
}
|
||||
if (this.removeWheelEventListener) {
|
||||
this.removeWheelEventListener();
|
||||
this.removeWheelEventListener = undefined;
|
||||
|
@ -563,9 +620,7 @@ class App extends React.Component<{}, AppState> {
|
|||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
|
||||
const x =
|
||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
||||
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||
|
||||
const element = getElementAtPosition(elements, x, y);
|
||||
if (!element) {
|
||||
|
@ -642,9 +697,8 @@ class App extends React.Component<{}, AppState> {
|
|||
this.state.scrollY
|
||||
);
|
||||
|
||||
const x =
|
||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
||||
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||
|
||||
const element = newElement(
|
||||
this.state.elementType,
|
||||
x,
|
||||
|
@ -656,28 +710,23 @@ class App extends React.Component<{}, AppState> {
|
|||
1,
|
||||
100
|
||||
);
|
||||
let resizeHandle: string | false = false;
|
||||
type ResizeTestType = ReturnType<typeof resizeTest>;
|
||||
let resizeHandle: ResizeTestType = false;
|
||||
let isDraggingElements = false;
|
||||
let isResizingElements = false;
|
||||
if (this.state.elementType === "selection") {
|
||||
const resizeElement = elements.find(element => {
|
||||
return resizeTest(element, x, y, {
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor
|
||||
});
|
||||
});
|
||||
const resizeElement = getElementWithResizeHandler(
|
||||
elements,
|
||||
{ x, y },
|
||||
this.state
|
||||
);
|
||||
|
||||
this.setState({
|
||||
resizingElement: resizeElement ? resizeElement : null
|
||||
resizingElement: resizeElement ? resizeElement.element : null
|
||||
});
|
||||
|
||||
if (resizeElement) {
|
||||
resizeHandle = resizeTest(resizeElement, x, y, {
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor
|
||||
});
|
||||
resizeHandle = resizeElement.resizeHandle;
|
||||
document.documentElement.style.cursor = `${resizeHandle}-resize`;
|
||||
isResizingElements = true;
|
||||
} else {
|
||||
|
@ -686,15 +735,27 @@ class App extends React.Component<{}, AppState> {
|
|||
// If we click on something
|
||||
if (hitElement) {
|
||||
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
|
||||
} else {
|
||||
// We unselect every other elements unless shift is pressed
|
||||
if (!e.shiftKey) {
|
||||
clearSelection(elements);
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
// 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)) {
|
||||
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({
|
||||
initText: "",
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
x: textX,
|
||||
y: textY,
|
||||
strokeColor: this.state.currentItemStrokeColor,
|
||||
font: this.state.currentItemFont,
|
||||
onSubmit: text => {
|
||||
|
@ -774,10 +850,8 @@ class App extends React.Component<{}, AppState> {
|
|||
const el = this.state.resizingElement;
|
||||
const selectedElements = elements.filter(el => el.isSelected);
|
||||
if (selectedElements.length === 1) {
|
||||
const x =
|
||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
||||
const y =
|
||||
e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||
|
||||
selectedElements.forEach(element => {
|
||||
switch (resizeHandle) {
|
||||
case "nw":
|
||||
|
@ -849,10 +923,8 @@ class App extends React.Component<{}, AppState> {
|
|||
if (isDraggingElements) {
|
||||
const selectedElements = elements.filter(el => el.isSelected);
|
||||
if (selectedElements.length) {
|
||||
const x =
|
||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
||||
const y =
|
||||
e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||
|
||||
selectedElements.forEach(element => {
|
||||
element.x += x - lastX;
|
||||
element.y += y - lastY;
|
||||
|
@ -936,16 +1008,9 @@ class App extends React.Component<{}, AppState> {
|
|||
this.forceUpdate();
|
||||
}}
|
||||
onDoubleClick={e => {
|
||||
const x =
|
||||
e.clientX - CANVAS_WINDOW_OFFSET_LEFT - this.state.scrollX;
|
||||
const y = e.clientY - CANVAS_WINDOW_OFFSET_TOP - this.state.scrollY;
|
||||
const { x, y } = viewportCoordsToSceneCoords(e, this.state);
|
||||
|
||||
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(
|
||||
"text",
|
||||
|
@ -962,12 +1027,15 @@ class App extends React.Component<{}, AppState> {
|
|||
let initText = "";
|
||||
let textX = e.clientX;
|
||||
let textY = e.clientY;
|
||||
if (elementAtPosition) {
|
||||
|
||||
if (elementAtPosition && isTextElement(elementAtPosition)) {
|
||||
elements.splice(elements.indexOf(elementAtPosition), 1);
|
||||
this.forceUpdate();
|
||||
|
||||
Object.assign(element, elementAtPosition);
|
||||
// x and y will change after calling addTextElement function
|
||||
element.x = elementAtPosition.x + elementAtPosition.width / 2;
|
||||
element.y =
|
||||
elementAtPosition.y + elementAtPosition.actualBoundingBoxAscent;
|
||||
element.y = elementAtPosition.y + elementAtPosition.height / 2;
|
||||
initText = elementAtPosition.text;
|
||||
textX =
|
||||
this.state.scrollX +
|
||||
|
@ -978,7 +1046,19 @@ class App extends React.Component<{}, AppState> {
|
|||
this.state.scrollY +
|
||||
elementAtPosition.y +
|
||||
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({
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -1028,14 +1136,14 @@ class App extends React.Component<{}, AppState> {
|
|||
) {
|
||||
clearSelection(elements);
|
||||
|
||||
const minX = Math.min(...parsedElements.map(element => element.x));
|
||||
const minY = Math.min(...parsedElements.map(element => element.y));
|
||||
|
||||
let subCanvasX1 = Infinity;
|
||||
let subCanvasX2 = 0;
|
||||
let subCanvasY1 = Infinity;
|
||||
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) => {
|
||||
return Math.abs(x > y ? x - y : y - x);
|
||||
};
|
||||
|
@ -1054,27 +1162,56 @@ class App extends React.Component<{}, AppState> {
|
|||
const dx =
|
||||
this.state.cursorX -
|
||||
this.state.scrollX -
|
||||
canvas.offsetLeft -
|
||||
CANVAS_WINDOW_OFFSET_LEFT -
|
||||
elementsWidth;
|
||||
const dy =
|
||||
this.state.cursorY -
|
||||
this.state.scrollY -
|
||||
canvas.offsetTop -
|
||||
CANVAS_WINDOW_OFFSET_TOP -
|
||||
elementsHeight;
|
||||
|
||||
parsedElements.forEach(parsedElement => {
|
||||
parsedElement.x += dx - minX;
|
||||
parsedElement.y += dy - minY;
|
||||
parsedElement.seed = randomSeed();
|
||||
elements.push(parsedElement);
|
||||
const duplicate = duplicateElement(parsedElement);
|
||||
duplicate.x += dx - minX;
|
||||
duplicate.y += dy - minY;
|
||||
elements.push(duplicate);
|
||||
});
|
||||
|
||||
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() {
|
||||
renderScene(elements, rc, canvas, {
|
||||
renderScene(elements, this.rc!, this.canvas!, {
|
||||
scrollX: this.state.scrollX,
|
||||
scrollY: this.state.scrollY,
|
||||
viewBackgroundColor: this.state.viewBackgroundColor
|
||||
|
@ -1090,8 +1227,3 @@ class App extends React.Component<{}, AppState> {
|
|||
|
||||
const rootElement = document.getElementById("root");
|
||||
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(
|
||||
element.text,
|
||||
element.x + scrollX,
|
||||
element.y + element.actualBoundingBoxAscent + scrollY
|
||||
element.y +
|
||||
scrollY +
|
||||
(element.baseline || element.actualBoundingBoxAscent || 0)
|
||||
);
|
||||
context.fillStyle = fillStyle;
|
||||
context.font = font;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { RoughCanvas } from "roughjs/bin/canvas";
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { getElementAbsoluteCoords, handlerRectangles } from "../element";
|
||||
|
||||
import { roundRect } from "../scene/roundRect";
|
||||
import { roundRect } from "./roundRect";
|
||||
import { SceneState } from "../scene/types";
|
||||
import {
|
||||
getScrollBars,
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { ExcalidrawElement } from "../element/types";
|
||||
import { hitTest } from "../element/collision";
|
||||
import { getElementAbsoluteCoords } from "../element";
|
||||
|
||||
export const hasBackground = (elements: ExcalidrawElement[]) =>
|
||||
elements.some(
|
||||
|
@ -20,6 +21,9 @@ export const hasStroke = (elements: ExcalidrawElement[]) =>
|
|||
element.type === "arrow")
|
||||
);
|
||||
|
||||
export const hasText = (elements: ExcalidrawElement[]) =>
|
||||
elements.some(element => element.isSelected && element.type === "text");
|
||||
|
||||
export function getElementAtPosition(
|
||||
elements: ExcalidrawElement[],
|
||||
x: number,
|
||||
|
@ -36,3 +40,20 @@ export function getElementAtPosition(
|
|||
|
||||
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 { AppState } from "../types";
|
||||
import nanoid from "nanoid";
|
||||
|
||||
const LOCAL_STORAGE_KEY = "excalidraw";
|
||||
const LOCAL_STORAGE_KEY_STATE = "excalidraw-state";
|
||||
|
@ -143,6 +144,7 @@ function restore(
|
|||
: savedElements)
|
||||
);
|
||||
elements.forEach((element: ExcalidrawElement) => {
|
||||
element.id = element.id || nanoid();
|
||||
element.fillStyle = element.fillStyle || "hachure";
|
||||
element.strokeWidth = element.strokeWidth || 1;
|
||||
element.roughness = element.roughness || 1;
|
||||
|
|
|
@ -14,5 +14,11 @@ export {
|
|||
restoreFromLocalStorage,
|
||||
saveToLocalStorage
|
||||
} from "./data";
|
||||
export { hasBackground, hasStroke, getElementAtPosition } from "./comparisons";
|
||||
export {
|
||||
hasBackground,
|
||||
hasStroke,
|
||||
getElementAtPosition,
|
||||
getElementContainingPosition,
|
||||
hasText
|
||||
} from "./comparisons";
|
||||
export { createScene } from "./createScene";
|
||||
|
|
|
@ -7,6 +7,11 @@ export type SceneState = {
|
|||
viewBackgroundColor: string | null;
|
||||
};
|
||||
|
||||
export type SceneScroll = {
|
||||
scrollX: number;
|
||||
scrollY: number;
|
||||
};
|
||||
|
||||
export interface Scene {
|
||||
elements: ExcalidrawTextElement[];
|
||||
}
|
||||
|
|
|
@ -30,6 +30,27 @@ body {
|
|||
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;
|
||||
|
|
28
src/utils.ts
28
src/utils.ts
|
@ -18,8 +18,36 @@ export function isInputLike(
|
|||
target: Element | EventTarget | null
|
||||
): target is HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement {
|
||||
return (
|
||||
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
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