diff --git a/.gitignore b/.gitignore index 9c975e4ba..a7904639e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ yarn-error.log* # Dependency directories node_modules/ +.vscode/ diff --git a/src/index.tsx b/src/index.tsx index 530610c8b..db539c3a9 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,20 +10,99 @@ export type ExcaliburTextElement = ExcaliburElement & { type: "text"; font: string; text: string; - measure: TextMetrics; + actualBoundingBoxAscent: number; }; // var elements = Array.of(); -function isInsideAnElement(x: number, y: number) { - return (element: ExcaliburElement) => { +// https://stackoverflow.com/a/6853926/232122 +function distanceBetweenPointAndSegment( + x: number, + y: number, + x1: number, + y1: number, + x2: number, + y2: number +) { + const A = x - x1; + const B = y - y1; + const C = x2 - x1; + const D = y2 - y1; + + const dot = A * C + B * D; + const lenSquare = C * C + D * D; + let param = -1; + if (lenSquare !== 0) { + // in case of 0 length line + param = dot / lenSquare; + } + + let xx, yy; + if (param < 0) { + xx = x1; + yy = y1; + } else if (param > 1) { + xx = x2; + yy = y2; + } else { + xx = x1 + param * C; + yy = y1 + param * D; + } + + const dx = x - xx; + const dy = y - yy; + return Math.sqrt(dx * dx + dy * dy); +} + +function hitTest(element: ExcaliburElement, x: number, y: number): boolean { + // For shapes that are composed of lines, we only enable point-selection when the distance + // of the click is less than x pixels of any of the lines that the shape is composed of + const lineThreshold = 10; + + if ( + element.type === "rectangle" || + // There doesn't seem to be a closed form solution for the distance between + // a point and an ellipse, let's assume it's a rectangle for now... + element.type === "ellipse" + ) { + const x1 = getElementAbsoluteX1(element); + const x2 = getElementAbsoluteX2(element); + const y1 = getElementAbsoluteY1(element); + const y2 = getElementAbsoluteY2(element); + + // (x1, y1) --A-- (x2, y1) + // |D |B + // (x1, y2) --C-- (x2, y2) + return ( + distanceBetweenPointAndSegment(x, y, x1, y1, x2, y1) < lineThreshold || // A + distanceBetweenPointAndSegment(x, y, x2, y1, x2, y2) < lineThreshold || // B + distanceBetweenPointAndSegment(x, y, x2, y2, x1, y2) < lineThreshold || // C + distanceBetweenPointAndSegment(x, y, x1, y2, x1, y1) < lineThreshold // D + ); + } else if (element.type === "arrow") { + let [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); + // The computation is done at the origin, we need to add a translation + x -= element.x; + y -= element.y; + + return ( + // \ + distanceBetweenPointAndSegment(x, y, x3, y3, x2, y2) < lineThreshold || + // ----- + distanceBetweenPointAndSegment(x, y, x1, y1, x2, y2) < lineThreshold || + // / + distanceBetweenPointAndSegment(x, y, x4, y4, x2, y2) < lineThreshold + ); + } else if (element.type === "text") { const x1 = getElementAbsoluteX1(element); const x2 = getElementAbsoluteX2(element); const y1 = getElementAbsoluteY1(element); const y2 = getElementAbsoluteY2(element); return x >= x1 && x <= x2 && y >= y1 && y <= y2; - }; + } else { + throw new Error("Unimplemented type " + element.type); + } } function newElement(type: string, x: number, y: number, width = 0, height = 0) { @@ -59,6 +138,26 @@ function isTextElement( return element.type === "text"; } +function getArrowPoints(element: ExcaliburElement) { + const x1 = 0; + const y1 = 0; + const x2 = element.width; + const y2 = element.height; + + const size = 30; // pixels + const distance = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); + // Scale down the arrow until we hit a certain size so that it doesn't look weird + const minSize = Math.min(size, distance / 2); + const xs = x2 - ((x2 - x1) / distance) * minSize; + const ys = y2 - ((y2 - y1) / distance) * minSize; + + const angle = 20; // degrees + const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); + const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); + + return [x1, y1, x2, y2, x3, y3, x4, y4]; +} + function generateDraw(element: ExcaliburElement) { if (element.type === "selection") { element.draw = (rc, context) => { @@ -87,22 +186,7 @@ function generateDraw(element: ExcaliburElement) { context.translate(-element.x, -element.y); }; } else if (element.type === "arrow") { - const x1 = 0; - const y1 = 0; - const x2 = element.width; - const y2 = element.height; - - const size = 30; // pixels - const distance = Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2)); - // Scale down the arrow until we hit a certain size so that it doesn't look weird - const minSize = Math.min(size, distance / 2); - const xs = x2 - ((x2 - x1) / distance) * minSize; - const ys = y2 - ((y2 - y1) / distance) * minSize; - - const angle = 20; // degrees - const [x3, y3] = rotate(xs, ys, x2, y2, (-angle * Math.PI) / 180); - const [x4, y4] = rotate(xs, ys, x2, y2, (angle * Math.PI) / 180); - + const [x1, y1, x2, y2, x3, y3, x4, y4] = getArrowPoints(element); const shapes = [ // \ generator.line(x3, y3, x2, y2), @@ -125,7 +209,7 @@ function generateDraw(element: ExcaliburElement) { context.fillText( element.text, element.x, - element.y + element.measure.actualBoundingBoxAscent + element.y + element.actualBoundingBoxAscent ); context.font = font; }; @@ -208,17 +292,20 @@ class App extends React.Component<{}, AppState> { this.setState({ elements: newElements }); }; + deleteSelectedElements = () => { + this.setState({ elements: this.state.elements.filter(e => !e.isSelected) }); + }; + private onKeyDown = (event: KeyboardEvent) => { - const { elements } = this.state; - if ( - event.key === "Backspace" && - (event.target as HTMLElement)?.nodeName !== "INPUT" - ) { - for (var i = elements.length - 1; i >= 0; --i) { - if (elements[i].isSelected) { - elements.splice(i, 1); - } - } + if ((event.target as HTMLElement).nodeName === "INPUT") { + return; + } + + if (event.key === "Escape") { + this.clearSelection(); + drawScene(this.state.elements); + } else if (event.key === "Backspace") { + this.deleteSelectedElements(); drawScene(this.state.elements); event.preventDefault(); } else if ( @@ -228,7 +315,7 @@ class App extends React.Component<{}, AppState> { event.key === "ArrowDown" ) { const step = event.shiftKey ? 5 : 1; - elements.forEach(element => { + this.state.elements.forEach(element => { if (element.isSelected) { if (event.key === "ArrowLeft") element.x -= step; else if (event.key === "ArrowRight") element.x += step; @@ -392,7 +479,55 @@ class App extends React.Component<{}, AppState> { /> px) -
+
{ + e.clipboardData.setData( + "text/plain", + JSON.stringify( + this.state.elements.filter(element => element.isSelected) + ) + ); + this.deleteSelectedElements(); + drawScene(this.state.elements); + e.preventDefault(); + }} + onCopy={e => { + e.clipboardData.setData( + "text/plain", + JSON.stringify( + this.state.elements.filter(element => element.isSelected) + ) + ); + e.preventDefault(); + }} + onPaste={e => { + const paste = e.clipboardData.getData("text"); + console.log(paste); + let parsedElements; + try { + parsedElements = JSON.parse(paste); + } catch (e) {} + if ( + Array.isArray(parsedElements) && + parsedElements.length > 0 && + parsedElements[0].type // need to implement a better check here... + ) { + this.clearSelection(); + + parsedElements = parsedElements.map(parsedElement => { + parsedElement.x += 10; + parsedElement.y += 10; + generateDraw(parsedElement); + return parsedElement; + }); + this.setState({ + elements: [...this.state.elements, ...parsedElements] + }); + drawScene(this.state.elements); + } + e.preventDefault(); + }} + > {this.renderOption({ type: "rectangle", children: "Rectangle" })} {this.renderOption({ type: "ellipse", children: "Ellipse" })} {this.renderOption({ type: "arrow", children: "Arrow" })} @@ -409,16 +544,23 @@ class App extends React.Component<{}, AppState> { let isDraggingElements = false; const cursorStyle = document.documentElement.style.cursor; if (this.state.elementType === "selection") { - const selectedElement = this.state.elements.find(element => { - const isSelected = isInsideAnElement(x, y)(element); - if (isSelected) { - element.isSelected = true; - } - return isSelected; + const hitElement = this.state.elements.find(element => { + return hitTest(element, x, y); }); - if (selectedElement) { - this.setState({ draggingElement: selectedElement }); + // If we click on something + if (hitElement) { + if (hitElement.isSelected) { + // If that element is not already selected, do nothing, + // we're likely going to drag it + } else { + // We unselect every other elements unless shift is pressed + if (!e.shiftKey) { + this.clearSelection(); + } + // No matter what, we select it + hitElement.isSelected = true; + } } else { this.clearSelection(); } @@ -441,15 +583,19 @@ class App extends React.Component<{}, AppState> { element.font = "20px Virgil"; const font = context.font; context.font = element.font; - element.measure = context.measureText(element.text); + const { + actualBoundingBoxAscent, + actualBoundingBoxDescent, + width + } = context.measureText(element.text); + element.actualBoundingBoxAscent = actualBoundingBoxAscent; context.font = font; const height = - element.measure.actualBoundingBoxAscent + - element.measure.actualBoundingBoxDescent; + actualBoundingBoxAscent + actualBoundingBoxDescent; // Center the text - element.x -= element.measure.width / 2; - element.y -= element.measure.actualBoundingBoxAscent; - element.width = element.measure.width; + element.x -= width / 2; + element.y -= actualBoundingBoxAscent; + element.width = width; element.height = height; }