Save state and elements automatically

This commit is contained in:
Paulo Menezes 2020-01-03 13:28:16 -03:00
commit c59d3c90f4
4 changed files with 853 additions and 713 deletions

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json
# Dependency directories
node_modules/

View file

@ -14,6 +14,7 @@ type ExcaliburTextElement = ExcaliburElement & {
};
const LOCAL_STORAGE_KEY = "excalibur";
const LOCAL_STORAGE_KEY_STATE = "excalibur-state";
var elements = Array.of<ExcaliburElement>();
@ -107,7 +108,15 @@ function hitTest(element: ExcaliburElement, x: number, y: number): boolean {
}
}
function newElement(type: string, x: number, y: number, width = 0, height = 0) {
function newElement(
type: string,
x: number,
y: number,
strokeColor: string,
backgroundColor: string,
width = 0,
height = 0
) {
const element = {
type: type,
x: x,
@ -115,29 +124,69 @@ function newElement(type: string, x: number, y: number, width = 0, height = 0) {
width: width,
height: height,
isSelected: false,
strokeColor: strokeColor,
backgroundColor: backgroundColor,
draw(rc: RoughCanvas, context: CanvasRenderingContext2D) {}
};
return element;
}
function renderScene(
rc: RoughCanvas,
context: CanvasRenderingContext2D,
// null indicates transparent bg
viewBackgroundColor: string | null
) {
if (!context) return;
const fillStyle = context.fillStyle;
if (typeof viewBackgroundColor === "string") {
context.fillStyle = viewBackgroundColor;
context.fillRect(-0.5, -0.5, canvas.width, canvas.height);
} else {
context.clearRect(-0.5, -0.5, canvas.width, canvas.height);
}
context.fillStyle = fillStyle;
elements.forEach(element => {
element.draw(rc, context);
if (element.isSelected) {
const margin = 4;
const elementX1 = getElementAbsoluteX1(element);
const elementX2 = getElementAbsoluteX2(element);
const elementY1 = getElementAbsoluteY1(element);
const elementY2 = getElementAbsoluteY2(element);
const lineDash = context.getLineDash();
context.setLineDash([8, 4]);
context.strokeRect(
elementX1 - margin,
elementY1 - margin,
elementX2 - elementX1 + margin * 2,
elementY2 - elementY1 + margin * 2
);
context.setLineDash(lineDash);
}
});
}
function exportAsPNG({
exportBackground,
exportVisibleOnly,
exportPadding = 10,
viewBgColor
viewBackgroundColor
}: {
exportBackground: boolean;
exportVisibleOnly: boolean;
exportPadding?: number;
viewBgColor: string;
viewBackgroundColor: string;
}) {
if (!elements.length) return window.alert("Cannot export empty canvas.");
// deselect & rerender
clearSelection();
drawScene();
ReactDOM.render(<App />, rootElement, () => {
// calculate visible-area coords
let subCanvasX1 = Infinity;
@ -165,9 +214,10 @@ function exportAsPNG({
? subCanvasY2 - subCanvasY1 + exportPadding * 2
: canvas.height;
if (exportBackground) {
tempCanvasCtx.fillStyle = viewBgColor;
tempCanvasCtx.fillRect(0, 0, canvas.width, canvas.height);
// if we're exporting without bg, we need to rerender the scene without it
// (it's reset again, below)
if (!exportBackground) {
renderScene(rc, context, null);
}
// copy our original canvas onto the temp canvas
@ -191,6 +241,11 @@ function exportAsPNG({
exportVisibleOnly ? tempCanvas.height : canvas.height // dHeight
);
// reset transparent bg back to original
if (!exportBackground) {
renderScene(rc, context, viewBackgroundColor);
}
// create a temporary <a> elem which we'll use to download the image
const link = document.createElement("a");
link.setAttribute("download", "excalibur.png");
@ -200,6 +255,7 @@ function exportAsPNG({
// clean up the DOM
link.remove();
if (tempCanvas !== canvas) tempCanvas.remove();
});
}
function rotate(x1: number, y1: number, x2: number, y2: number, angle: number) {
@ -242,11 +298,7 @@ function getArrowPoints(element: ExcaliburElement) {
return [x1, y1, x2, y2, x3, y3, x4, y4];
}
function generateDraw(
element: ExcaliburElement,
itemStrokeColor: string,
itemBackgroundColorColor: string
) {
function generateDraw(element: ExcaliburElement) {
if (element.type === "selection") {
element.draw = (rc, context) => {
const fillStyle = context.fillStyle;
@ -256,8 +308,8 @@ function generateDraw(
};
} else if (element.type === "rectangle") {
const shape = generator.rectangle(0, 0, element.width, element.height, {
stroke: itemStrokeColor,
fill: itemBackgroundColorColor
stroke: element.strokeColor,
fill: element.backgroundColor
});
element.draw = (rc, context) => {
context.translate(element.x, element.y);
@ -270,7 +322,7 @@ function generateDraw(
element.height / 2,
element.width,
element.height,
{ stroke: itemStrokeColor, fill: itemBackgroundColorColor }
{ stroke: element.strokeColor, fill: element.backgroundColor }
);
element.draw = (rc, context) => {
context.translate(element.x, element.y);
@ -281,11 +333,11 @@ function generateDraw(
const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element);
const shapes = [
// \
generator.line(x3, y3, x2, y2, { stroke: itemStrokeColor }),
generator.line(x3, y3, x2, y2, { stroke: element.strokeColor }),
// -----
generator.line(x1, y1, x2, y2, { stroke: itemStrokeColor }),
generator.line(x1, y1, x2, y2, { stroke: element.strokeColor }),
// /
generator.line(x4, y4, x2, y2, { stroke: itemStrokeColor })
generator.line(x4, y4, x2, y2, { stroke: element.strokeColor })
];
element.draw = (rc, context) => {
@ -360,41 +412,26 @@ function deleteSelectedElements() {
}
}
function save() {
if (elements && elements.length > 0) {
const items = [...elements];
for (const item of items) {
item.isSelected = false;
}
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(items));
}
window.removeEventListener("beforeunload", onUnload);
function save(state: AppState) {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(elements));
localStorage.setItem(LOCAL_STORAGE_KEY_STATE, JSON.stringify(state));
}
function restore() {
const el = localStorage.getItem(LOCAL_STORAGE_KEY);
try {
const savedElements = localStorage.getItem(LOCAL_STORAGE_KEY);
const savedState = localStorage.getItem(LOCAL_STORAGE_KEY_STATE);
if (el) {
const items = JSON.parse(el);
for (let item of items) {
item = generateDraw(item, "#000000", "#ffffff");
}
elements = [...items];
}
if (savedElements) {
elements = JSON.parse(savedElements);
elements.forEach((element: ExcaliburElement) => generateDraw(element));
}
function onUnload(event: BeforeUnloadEvent) {
event.preventDefault();
const confirmationMessage = "excalibur";
event.returnValue = confirmationMessage;
return confirmationMessage;
return savedState ? JSON.parse(savedState) : null;
} catch (e) {
elements = [];
return null;
}
function addOnBeforeUnload() {
window.addEventListener("beforeunload", onUnload);
}
type AppState = {
@ -403,19 +440,45 @@ type AppState = {
exportBackground: boolean;
exportVisibleOnly: boolean;
exportPadding: number;
itemStrokeColor: string;
itemBackgroundColor: string;
viewBgColor: string;
currentItemStrokeColor: string;
currentItemBackgroundColor: string;
viewBackgroundColor: string;
};
const KEYS = {
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
ARROW_DOWN: "ArrowDown",
ARROW_UP: "ArrowUp",
ESCAPE: "Escape",
DELETE: "Delete",
BACKSPACE: "Backspace"
};
function isArrowKey(keyCode: string) {
return (
keyCode === KEYS.ARROW_LEFT ||
keyCode === KEYS.ARROW_RIGHT ||
keyCode === KEYS.ARROW_DOWN ||
keyCode === KEYS.ARROW_UP
);
}
const ELEMENT_SHIFT_TRANSLATE_AMOUNT = 5;
const ELEMENT_TRANSLATE_AMOUNT = 1;
class App extends React.Component<{}, AppState> {
public componentDidMount() {
document.addEventListener("keydown", this.onKeyDown, false);
const savedState = restore();
if (savedState) {
this.setState(savedState);
}
}
public componentWillUnmount() {
document.removeEventListener("keydown", this.onKeyDown, false);
window.removeEventListener("beforeunload", onUnload);
}
public state: AppState = {
@ -424,9 +487,9 @@ class App extends React.Component<{}, AppState> {
exportBackground: false,
exportVisibleOnly: true,
exportPadding: 10,
itemStrokeColor: "#000000",
itemBackgroundColor: "#ffffff",
viewBgColor: "#ffffff"
currentItemStrokeColor: "#000000",
currentItemBackgroundColor: "#ffffff",
viewBackgroundColor: "#ffffff"
};
private onKeyDown = (event: KeyboardEvent) => {
@ -434,39 +497,33 @@ class App extends React.Component<{}, AppState> {
return;
}
if (event.key === "Escape") {
if (event.key === KEYS.ESCAPE) {
clearSelection();
drawScene();
this.forceUpdate();
event.preventDefault();
} else if (event.key === "Backspace") {
addOnBeforeUnload();
} else if (event.key === KEYS.BACKSPACE || event.key === KEYS.DELETE) {
deleteSelectedElements();
drawScene();
this.forceUpdate();
event.preventDefault();
} else if (
event.key === "ArrowLeft" ||
event.key === "ArrowRight" ||
event.key === "ArrowUp" ||
event.key === "ArrowDown"
) {
addOnBeforeUnload();
const step = event.shiftKey ? 5 : 1;
} else if (isArrowKey(event.key)) {
const step = event.shiftKey
? ELEMENT_SHIFT_TRANSLATE_AMOUNT
: ELEMENT_TRANSLATE_AMOUNT;
elements.forEach(element => {
if (element.isSelected) {
if (event.key === "ArrowLeft") element.x -= step;
else if (event.key === "ArrowRight") element.x += step;
else if (event.key === "ArrowUp") element.y -= step;
else if (event.key === "ArrowDown") element.y += step;
if (event.key === KEYS.ARROW_LEFT) element.x -= step;
else if (event.key === KEYS.ARROW_RIGHT) element.x += step;
else if (event.key === KEYS.ARROW_UP) element.y -= step;
else if (event.key === KEYS.ARROW_DOWN) element.y += step;
}
});
drawScene();
this.forceUpdate();
event.preventDefault();
} else if (event.key === "a" && event.metaKey) {
elements.forEach(element => {
element.isSelected = true;
});
drawScene();
this.forceUpdate();
event.preventDefault();
}
};
@ -486,7 +543,7 @@ class App extends React.Component<{}, AppState> {
onChange={() => {
this.setState({ elementType: type });
clearSelection();
drawScene();
this.forceUpdate();
}}
/>
{children}
@ -496,66 +553,6 @@ class App extends React.Component<{}, AppState> {
public render() {
return (
<>
<div className="wrappers">
<div className="saveWrapper">
<button disabled={elements.length === 0} onClick={save}>
Save
</button>
</div>
<div className="exportWrapper">
<button
onClick={() => {
exportAsPNG({
exportBackground: this.state.exportBackground,
exportVisibleOnly: this.state.exportVisibleOnly,
exportPadding: this.state.exportPadding,
viewBgColor: this.state.viewBgColor
});
}}
>
Export to png
</button>
<label>
<input
type="checkbox"
checked={this.state.exportBackground}
onChange={e => {
this.setState({ exportBackground: e.target.checked });
}}
/>{" "}
background
</label>
<label>
<input
type="checkbox"
checked={this.state.exportVisibleOnly}
onChange={e => {
this.setState({ exportVisibleOnly: e.target.checked });
}}
/>
visible area only
</label>
(padding:
<input
type="number"
value={this.state.exportPadding}
onChange={e => {
this.setState({ exportPadding: Number(e.target.value) });
}}
disabled={!this.state.exportVisibleOnly}
/>
px)
</div>
</div>
<fieldset>
<legend>Shapes</legend>
{this.renderOption({ type: "rectangle", children: "Rectangle" })}
{this.renderOption({ type: "ellipse", children: "Ellipse" })}
{this.renderOption({ type: "arrow", children: "Arrow" })}
{this.renderOption({ type: "text", children: "Text" })}
{this.renderOption({ type: "selection", children: "Selection" })}
</fieldset>
<div
onCut={e => {
e.clipboardData.setData(
@ -563,7 +560,7 @@ class App extends React.Component<{}, AppState> {
JSON.stringify(elements.filter(element => element.isSelected))
);
deleteSelectedElements();
drawScene();
this.forceUpdate();
e.preventDefault();
}}
onCopy={e => {
@ -588,19 +585,23 @@ class App extends React.Component<{}, AppState> {
parsedElements.forEach(parsedElement => {
parsedElement.x += 10;
parsedElement.y += 10;
generateDraw(
parsedElement,
this.state.itemStrokeColor,
this.state.itemBackgroundColor
);
generateDraw(parsedElement);
elements.push(parsedElement);
addOnBeforeUnload();
});
drawScene();
this.forceUpdate();
}
e.preventDefault();
}}
>
<fieldset>
<legend>Shapes</legend>
{this.renderOption({ type: "rectangle", children: "Rectangle" })}
{this.renderOption({ type: "ellipse", children: "Ellipse" })}
{this.renderOption({ type: "arrow", children: "Arrow" })}
{this.renderOption({ type: "text", children: "Text" })}
{this.renderOption({ type: "selection", children: "Selection" })}
</fieldset>
<canvas
id="canvas"
width={window.innerWidth}
@ -608,7 +609,13 @@ class App extends React.Component<{}, AppState> {
onMouseDown={e => {
const x = e.clientX - (e.target as HTMLElement).offsetLeft;
const y = e.clientY - (e.target as HTMLElement).offsetTop;
const element = newElement(this.state.elementType, x, y);
const element = newElement(
this.state.elementType,
x,
y,
this.state.currentItemStrokeColor,
this.state.currentItemBackgroundColor
);
let isDraggingElements = false;
const cursorStyle = document.documentElement.style.cursor;
if (this.state.elementType === "selection") {
@ -634,15 +641,11 @@ class App extends React.Component<{}, AppState> {
clearSelection();
}
isDraggingElements = elements.some(
element => element.isSelected
);
isDraggingElements = elements.some(element => element.isSelected);
if (isDraggingElements) {
document.documentElement.style.cursor = "move";
}
} else {
addOnBeforeUnload();
}
if (isTextElement(element)) {
@ -661,8 +664,7 @@ class App extends React.Component<{}, AppState> {
} = context.measureText(element.text);
element.actualBoundingBoxAscent = actualBoundingBoxAscent;
context.font = font;
const height =
actualBoundingBoxAscent + actualBoundingBoxDescent;
const height = actualBoundingBoxAscent + actualBoundingBoxDescent;
// Center the text
element.x -= width / 2;
element.y -= actualBoundingBoxAscent;
@ -670,11 +672,7 @@ class App extends React.Component<{}, AppState> {
element.height = height;
}
generateDraw(
element,
this.state.itemStrokeColor,
this.state.itemBackgroundColor
);
generateDraw(element);
elements.push(element);
if (this.state.elementType === "text") {
this.setState({
@ -706,8 +704,7 @@ class App extends React.Component<{}, AppState> {
});
lastX = x;
lastY = y;
drawScene();
addOnBeforeUnload();
this.forceUpdate();
return;
}
}
@ -722,16 +719,12 @@ class App extends React.Component<{}, AppState> {
// Make a perfect square or circle when shift is enabled
draggingElement.height = e.shiftKey ? width : height;
generateDraw(
draggingElement,
this.state.itemStrokeColor,
this.state.itemBackgroundColor
);
generateDraw(draggingElement);
if (this.state.elementType === "selection") {
setSelection(draggingElement);
}
drawScene();
this.forceUpdate();
};
const onMouseUp = (e: MouseEvent) => {
@ -745,7 +738,7 @@ class App extends React.Component<{}, AppState> {
// if no element is clicked, clear the selection and redraw
if (draggingElement === null) {
clearSelection();
drawScene();
this.forceUpdate();
return;
}
@ -762,24 +755,23 @@ class App extends React.Component<{}, AppState> {
draggingElement: null,
elementType: "selection"
});
drawScene();
this.forceUpdate();
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
drawScene();
this.forceUpdate();
}}
/>
</div>
<fieldset>
<legend>Colors</legend>
<label>
<input
type="color"
value={this.state.viewBgColor}
value={this.state.viewBackgroundColor}
onChange={e => {
this.setState({ viewBgColor: e.target.value });
this.setState({ viewBackgroundColor: e.target.value });
}}
/>
Background
@ -787,9 +779,9 @@ class App extends React.Component<{}, AppState> {
<label>
<input
type="color"
value={this.state.itemStrokeColor}
value={this.state.currentItemStrokeColor}
onChange={e => {
this.setState({ itemStrokeColor: e.target.value });
this.setState({ currentItemStrokeColor: e.target.value });
}}
/>
Shape Stroke
@ -797,9 +789,9 @@ class App extends React.Component<{}, AppState> {
<label>
<input
type="color"
value={this.state.itemBackgroundColor}
value={this.state.currentItemBackgroundColor}
onChange={e => {
this.setState({ itemBackgroundColor: e.target.value });
this.setState({ currentItemBackgroundColor: e.target.value });
}}
/>
Shape Background
@ -813,7 +805,7 @@ class App extends React.Component<{}, AppState> {
exportBackground: this.state.exportBackground,
exportVisibleOnly: this.state.exportVisibleOnly,
exportPadding: this.state.exportPadding,
viewBgColor: this.state.viewBgColor
viewBackgroundColor: this.state.viewBackgroundColor
});
}}
>
@ -850,35 +842,13 @@ class App extends React.Component<{}, AppState> {
/>
px)
</fieldset>
</>
</div>
);
}
componentDidUpdate() {
const fillStyle = context.fillStyle;
context.fillStyle = this.state.viewBgColor;
context.fillRect(-0.5, -0.5, canvas.width, canvas.height);
context.fillStyle = fillStyle;
elements.forEach(element => {
element.draw(rc, context);
if (element.isSelected) {
const margin = 4;
const elementX1 = getElementAbsoluteX1(element);
const elementX2 = getElementAbsoluteX2(element);
const elementY1 = getElementAbsoluteY1(element);
const elementY2 = getElementAbsoluteY2(element);
const lineDash = context.getLineDash();
context.setLineDash([8, 4]);
context.strokeRect(
elementX1 - margin,
elementY1 - margin,
elementX2 - elementX1 + margin * 2,
elementY2 - elementY1 + margin * 2
);
context.setLineDash(lineDash);
}
});
renderScene(rc, context, this.state.viewBackgroundColor);
save(this.state);
}
}
@ -892,10 +862,4 @@ const context = canvas.getContext("2d")!;
// https://stackoverflow.com/questions/13879322/drawing-a-1px-thick-line-in-canvas-creates-a-2px-thick-line/13879402#comment90766599_13879402
context.translate(0.5, 0.5);
restore();
function drawScene() {
ReactDOM.render(<App />, rootElement);
}
drawScene();

View file

@ -4,32 +4,24 @@
src: url("https://uploads.codesandbox.io/uploads/user/ed077012-e728-4a42-8395-cbd299149d62/AflB-FG_Virgil.ttf");
}
.wrappers {
display: flex;
align-items: center;
margin-bottom: 10px;
body {
margin: 0;
}
.exportWrapper {
display: flex;
align-items: center;
}
.exportWrapper label {
display: flex;
align-items: center;
margin: 0 5px;
}
.exportWrapper button {
margin-right: 10px;
/* Controls - Begin */
fieldset {
margin: 5px;
}
label {
margin-right: 10px;
}
input[type="number"] {
width: 30px;
}
input {
margin-right: 5px;
}
/* Controls - End */

735
yarn.lock

File diff suppressed because it is too large Load diff