From 579c32b5b234f68eb1ac487f4887b6210d68cb15 Mon Sep 17 00:00:00 2001 From: dwelle Date: Thu, 2 Jan 2020 20:43:00 +0100 Subject: [PATCH 1/7] remove optional chaning until CodeSandbox adds support for it in CRA apps --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 72055ad5a..a2ec4ae2f 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -284,7 +284,7 @@ class App extends React.Component<{}, AppState> { private onKeyDown = (event: KeyboardEvent) => { if ( event.key === "Backspace" && - (event.target as HTMLElement)?.nodeName !== "INPUT" + (event.target as HTMLElement).nodeName !== "INPUT" ) { for (var i = elements.length - 1; i >= 0; --i) { if (elements[i].isSelected) { From e9bc1eb98a7e54fe3c40bd6b78a0c1e7906d87cf Mon Sep 17 00:00:00 2001 From: dwelle Date: Thu, 2 Jan 2020 21:11:33 +0100 Subject: [PATCH 2/7] add .vscode to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From b6c30c05501c62507b7d6a1497c51731d955bccf Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 2 Jan 2020 12:49:06 -0800 Subject: [PATCH 3/7] Copy paste (#44) * Copy Paste * Copy paste --- src/index.tsx | 75 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index a2ec4ae2f..e6492a684 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,7 +10,7 @@ type ExcaliburTextElement = ExcaliburElement & { type: "text"; font: string; text: string; - measure: TextMetrics; + actualBoundingBoxAscent: number; }; var elements = Array.of(); @@ -205,7 +205,7 @@ function generateDraw(element: ExcaliburElement) { context.fillText( element.text, element.x, - element.y + element.measure.actualBoundingBoxAscent + element.y + element.actualBoundingBoxAscent ); context.font = font; }; @@ -256,6 +256,14 @@ function clearSelection() { }); } +function deleteSelectedElements() { + for (var i = elements.length - 1; i >= 0; --i) { + if (elements[i].isSelected) { + elements.splice(i, 1); + } + } +} + type AppState = { draggingElement: ExcaliburElement | null; elementType: string; @@ -286,11 +294,7 @@ class App extends React.Component<{}, AppState> { 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); - } - } + deleteSelectedElements(); drawScene(); event.preventDefault(); } else if ( @@ -382,7 +386,46 @@ class App extends React.Component<{}, AppState> { /> px) -
+
{ + e.clipboardData.setData( + "text/plain", + JSON.stringify(elements.filter(element => element.isSelected)) + ); + deleteSelectedElements(); + drawScene(); + e.preventDefault(); + }} + onCopy={e => { + e.clipboardData.setData( + "text/plain", + JSON.stringify(elements.filter(element => element.isSelected)) + ); + e.preventDefault(); + }} + onPaste={e => { + const paste = e.clipboardData.getData("text"); + 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... + ) { + clearSelection(); + parsedElements.forEach(parsedElement => { + parsedElement.x += 10; + parsedElement.y += 10; + generateDraw(parsedElement); + elements.push(parsedElement); + }); + drawScene(); + } + e.preventDefault(); + }} + > {this.renderOption({ type: "rectangle", children: "Rectangle" })} {this.renderOption({ type: "ellipse", children: "Ellipse" })} {this.renderOption({ type: "arrow", children: "Arrow" })} @@ -431,15 +474,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; } From 4c1bf07863f9b0106936569aad5301aedaa7a7f5 Mon Sep 17 00:00:00 2001 From: David Luzar Date: Thu, 2 Jan 2020 22:03:14 +0100 Subject: [PATCH 4/7] ensure click-to-select is exclusive (fixes #43) (#45) --- src/index.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index e6492a684..82db6097c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -450,10 +450,13 @@ class App extends React.Component<{}, AppState> { return isSelected; }); + // deselect everything except target element to-be-selected + elements.forEach(element => { + if (element === selectedElement) return; + element.isSelected = false; + }); if (selectedElement) { this.setState({ draggingElement: selectedElement }); - } else { - clearSelection(); } isDraggingElements = elements.some( From 0d75b78374ed97784bf1c0d283c00fdde1fdffc3 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 2 Jan 2020 13:47:50 -0800 Subject: [PATCH 5/7] Path-dependent hit test (#48) * Hit test * Hit test * Hit test --- src/index.tsx | 124 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 104 insertions(+), 20 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 82db6097c..e5156e1f0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,15 +15,94 @@ type ExcaliburTextElement = ExcaliburElement & { 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) { @@ -139,6 +218,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) => { @@ -167,22 +266,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), @@ -443,7 +527,7 @@ class App extends React.Component<{}, AppState> { const cursorStyle = document.documentElement.style.cursor; if (this.state.elementType === "selection") { const selectedElement = elements.find(element => { - const isSelected = isInsideAnElement(x, y)(element); + const isSelected = hitTest(element, x, y); if (isSelected) { element.isSelected = true; } From 8a43ed691df3081d422acfa77bc65784e6ad4a96 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 2 Jan 2020 14:10:32 -0800 Subject: [PATCH 6/7] Handle escape keybinding (#49) --- src/index.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index e5156e1f0..119889692 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -374,10 +374,14 @@ class App extends React.Component<{}, AppState> { }; private onKeyDown = (event: KeyboardEvent) => { - if ( - event.key === "Backspace" && - (event.target as HTMLElement).nodeName !== "INPUT" - ) { + if ((event.target as HTMLElement).nodeName === "INPUT") { + return; + } + + if (event.key === "Escape") { + clearSelection(); + drawScene(); + } else if (event.key === "Backspace") { deleteSelectedElements(); drawScene(); event.preventDefault(); From 278fc11d2265e6503993059d82a4e404971867b9 Mon Sep 17 00:00:00 2001 From: Christopher Chedeau Date: Thu, 2 Jan 2020 14:33:45 -0800 Subject: [PATCH 7/7] Better selection click detection (#50) --- src/index.tsx | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 119889692..0504ff7ed 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -530,21 +530,26 @@ class App extends React.Component<{}, AppState> { let isDraggingElements = false; const cursorStyle = document.documentElement.style.cursor; if (this.state.elementType === "selection") { - const selectedElement = elements.find(element => { - const isSelected = hitTest(element, x, y); - if (isSelected) { - element.isSelected = true; - } - return isSelected; + const hitElement = elements.find(element => { + return hitTest(element, x, y); }); - // deselect everything except target element to-be-selected - elements.forEach(element => { - if (element === selectedElement) return; - element.isSelected = false; - }); - 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) { + clearSelection(); + } + // No matter what, we select it + hitElement.isSelected = true; + } + } else { + // If we don't click on anything, let's remove all the selected elements + clearSelection(); } isDraggingElements = elements.some(