diff --git a/apps/arrows-ts/src/actions/export.js b/apps/arrows-ts/src/actions/export.ts similarity index 76% rename from apps/arrows-ts/src/actions/export.js rename to apps/arrows-ts/src/actions/export.ts index 559b8a16..8c278a2d 100644 --- a/apps/arrows-ts/src/actions/export.js +++ b/apps/arrows-ts/src/actions/export.ts @@ -2,7 +2,7 @@ import {getPresentGraph} from "../selectors"; import {selectedNodes, selectedRelationships} from "../model/selection"; export const handleCopy = () => { - return function (dispatch, getState) { + return function (dispatch:any, getState: () => any) { const state = getState() const graph = getPresentGraph(state) const nodes = selectedNodes(graph, state.selection) @@ -14,9 +14,9 @@ export const handleCopy = () => { } const jsonString = JSON.stringify(selectedGraph, null, 2) navigator.clipboard.writeText(jsonString).then(function() { - console.log("Copied to clipboard successfully!"); + console.log("Copied to clipboard successfully! --ABK"); }, function() { - console.error("Unable to write to clipboard. :-("); + console.error("Unable to write to clipboard. :-( --abk"); }); } } \ No newline at end of file diff --git a/apps/arrows-ts/src/actions/geometricSnapping.ts b/apps/arrows-ts/src/actions/geometricSnapping.ts new file mode 100644 index 00000000..f8660729 --- /dev/null +++ b/apps/arrows-ts/src/actions/geometricSnapping.ts @@ -0,0 +1,197 @@ +import {Point} from "../model-old/Point"; +import {idsMatch} from "../model-old/Id"; +import {LineGuide} from "../model-old/guides/LineGuide"; +import {CircleGuide} from "../model-old/guides/CircleGuide"; +import {areParallel} from "../model-old/guides/intersections"; +import {byAscendingError} from "../model-old/guides/guides"; + +export const snapTolerance = 20 +const grossTolerance = snapTolerance * 2 +export const angleTolerance = Math.PI / 4 + +export const snapToNeighbourDistancesAndAngles = (graph, snappingNodeId, naturalPosition, otherSelectedNodes) => { + + const neighbours = []; + graph.relationships.forEach((relationship) => { + if (idsMatch(relationship.fromId, snappingNodeId) && !otherSelectedNodes.includes(relationship.toId)) { + neighbours.push(graph.nodes.find((node) => idsMatch(node.id, relationship.toId))) + } else if (idsMatch(relationship.toId, snappingNodeId) && !otherSelectedNodes.includes(relationship.fromId)) { + neighbours.push(graph.nodes.find((node) => idsMatch(node.id, relationship.fromId))) + } + }) + + const includeNode = (nodeId) => !idsMatch(nodeId, snappingNodeId) && !otherSelectedNodes.includes(nodeId) + + return snapToDistancesAndAngles(graph, neighbours, includeNode, naturalPosition) +} + +export const snapToDistancesAndAngles = (graph, neighbours, includeNode, naturalPosition) => { + + const isNeighbour = (nodeId) => !!neighbours.find(neighbour => neighbour.id === nodeId) + let snappedPosition = naturalPosition + + let possibleGuides = [] + + const neighbourRelationships = {}; + const collectRelationship = (neighbourNodeId, nonNeighbourNodeId) => { + const pair = { + neighbour: graph.nodes.find((node) => idsMatch(node.id, neighbourNodeId)), + nonNeighbour: graph.nodes.find((node) => idsMatch(node.id, nonNeighbourNodeId)) + } + const pairs = neighbourRelationships[pair.neighbour.id] || [] + pairs.push(pair) + neighbourRelationships[pair.neighbour.id] = pairs + } + + graph.relationships.forEach((relationship) => { + if (isNeighbour(relationship.fromId) && includeNode(relationship.toId)) { + collectRelationship(relationship.fromId, relationship.toId) + } + if (includeNode(relationship.fromId) && isNeighbour(relationship.toId)) { + collectRelationship(relationship.toId, relationship.fromId) + } + }) + + const snappingAngles = [6, 4, 3] + .map(denominator => Math.PI / denominator) + .flatMap(angle => [-1, -0.5, 0, 0.5].map(offset => offset * Math.PI + angle)) + + for (const neighbourA of neighbours) { + const relationshipDistances = [] + + for (const relationship of neighbourRelationships[neighbourA.id] || []) { + const relationshipVector = relationship.nonNeighbour.position.vectorFrom(relationship.neighbour.position); + const distance = relationshipVector.distance() + const similarDistance = relationshipDistances.includes((entry) => Math.abs(entry - distance) < 0.01); + if (!similarDistance) { + relationshipDistances.push(distance) + } + + const guide = new LineGuide(neighbourA.position, relationshipVector.angle(), naturalPosition) + if (guide.error < grossTolerance) { + possibleGuides.push(guide) + } + } + + for (const distance of relationshipDistances) { + const distanceGuide = new CircleGuide(neighbourA.position, distance, naturalPosition) + if (distanceGuide.error < grossTolerance) { + possibleGuides.push(distanceGuide) + } + } + + snappingAngles.forEach(snappingAngle => { + const diagonalGuide = new LineGuide(neighbourA.position, snappingAngle, naturalPosition) + const offset = naturalPosition.vectorFrom(neighbourA.position) + if (diagonalGuide.error < grossTolerance && Math.abs(offset.angle() - snappingAngle) < angleTolerance) { + possibleGuides.push(diagonalGuide) + } + }) + + for (const neighbourB of neighbours) { + if (neighbourA.id < neighbourB.id) { + const interNeighbourVector = neighbourB.position.vectorFrom(neighbourA.position) + const segment1 = naturalPosition.vectorFrom(neighbourA.position) + const segment2 = neighbourB.position.vectorFrom(naturalPosition) + const parallelGuide = new LineGuide(neighbourA.position, interNeighbourVector.angle(), naturalPosition) + if (parallelGuide.error < grossTolerance && segment1.dot(segment2) > 0) { + possibleGuides.push(parallelGuide) + } + + const midPoint = neighbourA.position.translate(interNeighbourVector.scale(0.5)) + const perpendicularGuide = new LineGuide( + midPoint, + interNeighbourVector.rotate(Math.PI / 2).angle(), + naturalPosition + ) + + if (perpendicularGuide.error < grossTolerance) { + possibleGuides.push(perpendicularGuide) + } + } + } + } + + const columns = new Set() + const rows = new Set() + graph.nodes.forEach((node) => { + if (includeNode(node.id)) { + if (Math.abs(naturalPosition.x - node.position.x) < grossTolerance) { + columns.add(node.position.x) + } + if (Math.abs(naturalPosition.y - node.position.y) < grossTolerance) { + rows.add(node.position.y) + } + } + }) + for (const column of columns) { + possibleGuides.push(new LineGuide( + new Point(column, naturalPosition.y), + Math.PI / 2, + naturalPosition + )) + } + for (const row of rows) { + possibleGuides.push(new LineGuide( + new Point(naturalPosition.x, row), + 0, + naturalPosition + )) + } + + const includedNodes = graph.nodes.filter(node => includeNode(node.id)) + const intervalGuides = [] + for (const guide of possibleGuides) { + const intervalGuide = guide.intervalGuide(includedNodes, naturalPosition) + if (intervalGuide && intervalGuide.error < grossTolerance) { + intervalGuides.push(intervalGuide) + } + } + possibleGuides.push(...intervalGuides) + + const candidateGuides = [...possibleGuides] + candidateGuides.sort(byAscendingError) + + const guidelines = [] + + while (guidelines.length === 0 && candidateGuides.length > 0) { + const candidateGuide = candidateGuides.shift() + if (candidateGuide.error < snapTolerance) { + guidelines.push(candidateGuide) + snappedPosition = candidateGuide.snap(naturalPosition) + } + } + + while (guidelines.length === 1 && candidateGuides.length > 0) { + const candidateGuide = candidateGuides.shift() + const combination = guidelines[0].combine(candidateGuide, naturalPosition) + if (combination.possible) { + const error = combination.intersection.vectorFrom(naturalPosition).distance() + if (error < snapTolerance) { + guidelines.push(candidateGuide) + snappedPosition = combination.intersection + } + } + } + + const lineGuides = guidelines.filter(guide => guide.type === 'LINE') + for (const candidateGuide of possibleGuides) { + if (!guidelines.includes(candidateGuide) && + candidateGuide.calculateError(snappedPosition) < 0.01) { + if (candidateGuide.type === 'LINE') { + if (lineGuides.every(guide => !areParallel(guide, candidateGuide))) { + lineGuides.push(candidateGuide) + guidelines.push(candidateGuide) + } + } else { + guidelines.push(candidateGuide) + } + } + } + + return { + snapped: guidelines.length > 0, + guidelines, + snappedPosition + } +} \ No newline at end of file diff --git a/apps/arrows-ts/src/actions/import.js b/apps/arrows-ts/src/actions/import.js index 44de345f..ca0e9d6f 100644 --- a/apps/arrows-ts/src/actions/import.js +++ b/apps/arrows-ts/src/actions/import.js @@ -42,11 +42,13 @@ export const interpretClipboardData = (clipboardData, nodeSpacing, handlers) => try { switch (format.outputType) { case 'graph': + // eslint-disable-next-line no-case-declarations const importedGraph = format.parse(text, nodeSpacing) handlers.onGraph && handlers.onGraph(importedGraph) break case 'svg': + // eslint-disable-next-line no-case-declarations const svgImageUrl = format.parse(text) handlers.onSvgImageUrl && handlers.onSvgImageUrl(svgImageUrl) break @@ -101,7 +103,7 @@ export const handlePaste = (pasteEvent) => { const formats = [ { // JSON - recognise: (plainText) => new RegExp('^{.*\}$', 's').test(plainText.trim()), + recognise: (plainText) => new RegExp('^{.*}$', 's').test(plainText.trim()), outputType: 'graph', parse: (plainText) => { const object = JSON.parse(plainText) diff --git a/apps/arrows-ts/src/app/App.jsx b/apps/arrows-ts/src/app/App.tsx similarity index 75% rename from apps/arrows-ts/src/app/App.jsx rename to apps/arrows-ts/src/app/App.tsx index 9a2941c3..231831ef 100644 --- a/apps/arrows-ts/src/app/App.jsx +++ b/apps/arrows-ts/src/app/App.tsx @@ -3,10 +3,9 @@ import GraphContainer from "../containers/GraphContainer" import {connect} from 'react-redux' import withKeybindings, { ignoreTarget } from '../interactions/Keybindings' import {windowResized} from "../actions/applicationLayout" -import { compose } from 'react-recompose' import HeaderContainer from '../containers/HeaderContainer' import InspectorChooser from "../containers/InspectorChooser" -import {computeCanvasSize, inspectorWidth} from "../model/applicationLayout"; +import {computeCanvasSize, inspectorWidth} from "@neo4j-arrows/model"; import ExportContainer from "../containers/ExportContainer"; import GoogleSignInModal from "../components/editors/GoogleSignInModal"; import HelpModal from "../components/HelpModal"; @@ -23,8 +22,25 @@ import {handleImportMessage} from "../reducers/storage"; import './App.css' -class App extends Component { - constructor (props) { +export interface AppProps { + inspectorVisible:boolean; + showSaveAsDialog:boolean; + showExportDialog:boolean; + showImportDialog:boolean; + pickingFromGoogleDrive:boolean; + pickingFromLocalStorage:boolean; + onCancelPicker:any; + loadFromGoogleDrive:any; + canvasHeight:number; + fireAction:any; + handleCopy: (ev:ClipboardEvent) => void; + handlePaste: (ev:ClipboardEvent) => void; + handleImportMessage: (ev:MessageEvent) => void; + onWindowResized: (this: Window, ev: UIEvent) => any +} + +class App extends Component { + constructor (props:AppProps) { super(props) linkToGoogleFontsCss() window.addEventListener('keydown', this.fireKeyboardShortcutAction.bind(this)) @@ -94,7 +110,7 @@ class App extends Component { ); } - fireKeyboardShortcutAction(ev) { + fireKeyboardShortcutAction(ev:KeyboardEvent) { if (ignoreTarget(ev)) return const handled = this.props.fireAction(ev) @@ -104,17 +120,18 @@ class App extends Component { } } - handleCopy(ev) { + handleCopy(ev:ClipboardEvent) { if (ignoreTarget(ev)) return + console.log('copying') this.props.handleCopy(ev) } - handlePaste(ev) { + handlePaste(ev:ClipboardEvent) { if (ignoreTarget(ev)) return this.props.handlePaste(ev) } - handleMessage(ev) { + handleMessage(ev:MessageEvent) { this.props.handleImportMessage(ev) } @@ -123,7 +140,7 @@ class App extends Component { } } -const mapStateToProps = (state) => ({ +const mapStateToProps = (state:any) => ({ inspectorVisible: state.applicationLayout.inspectorVisible, canvasHeight: computeCanvasSize(state.applicationLayout).height, pickingFromGoogleDrive: state.storage.status === 'PICKING_FROM_GOOGLE_DRIVE', @@ -134,18 +151,20 @@ const mapStateToProps = (state) => ({ }) -const mapDispatchToProps = dispatch => { +const mapDispatchToProps = (dispatch:any) => { return { onWindowResized: () => dispatch(windowResized(window.innerWidth, window.innerHeight)), onCancelPicker: () => dispatch(pickDiagramCancel()), - loadFromGoogleDrive: fileId => dispatch(getFileFromGoogleDrive(fileId)), + loadFromGoogleDrive: (fileId:any) => dispatch(getFileFromGoogleDrive(fileId)), handleCopy: () => dispatch(handleCopy()), - handlePaste: clipboardEvent => dispatch(handlePaste(clipboardEvent)), - handleImportMessage: message => dispatch(handleImportMessage(message)) + handlePaste: (clipboardEvent:any) => dispatch(handlePaste(clipboardEvent)), + handleImportMessage: (message:any) => dispatch(handleImportMessage(message)) } } -export default compose( - connect(mapStateToProps, mapDispatchToProps), - withKeybindings -)(App) +// NOTE: compose(a,b,c)(X) ==[BECOMES]=> a(b(c(X))) +// export default compose( +// connect(mapStateToProps, mapDispatchToProps), +// withKeybindings +// )(App) +export default connect(mapStateToProps, mapDispatchToProps)(withKeybindings(App)) diff --git a/apps/arrows-ts/src/app/app.module.css b/apps/arrows-ts/src/app/app.module.css deleted file mode 100644 index 7b88fbab..00000000 --- a/apps/arrows-ts/src/app/app.module.css +++ /dev/null @@ -1 +0,0 @@ -/* Your styles goes here. */ diff --git a/apps/arrows-ts/src/app/app.notsx b/apps/arrows-ts/src/app/app.notsx deleted file mode 100644 index 19f594b5..00000000 --- a/apps/arrows-ts/src/app/app.notsx +++ /dev/null @@ -1,14 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import styles from './app.module.css'; - -import NxWelcome from './nx-welcome'; - -export function App() { - return ( -
- -
- ); -} - -export default App; diff --git a/apps/arrows-ts/src/app/nx-welcome.tsx b/apps/arrows-ts/src/app/nx-welcome.tsx deleted file mode 100644 index f0cd657b..00000000 --- a/apps/arrows-ts/src/app/nx-welcome.tsx +++ /dev/null @@ -1,845 +0,0 @@ -/* - * * * * * * * * * * * * * * * * * * * * * * * * * * * * - This is a starter component and can be deleted. - * * * * * * * * * * * * * * * * * * * * * * * * * * * * - Delete this file and get started with your project! - * * * * * * * * * * * * * * * * * * * * * * * * * * * * - */ -export function NxWelcome({ title }: { title: string }) { - return ( - <> -