diff --git a/web/src/components/ContextMenu/ContextMenu.scss b/web/src/components/ContextMenu/ContextMenu.scss index 6dd9ae25..1f343730 100644 --- a/web/src/components/ContextMenu/ContextMenu.scss +++ b/web/src/components/ContextMenu/ContextMenu.scss @@ -3,7 +3,7 @@ color: var(--text-color-dark-bg); font: 0.825rem var(--font-family-primary-medium); box-shadow: 0rem 0rem 0.75rem var(--shadow-color); - padding: 0.5rem; + padding: 0.75rem 0.625rem; box-sizing: border-box; border-radius: 0.75rem; cursor: pointer; @@ -18,9 +18,10 @@ display: flex; flex-direction: row; align-items: center; - padding: 0.325rem 0.25rem; + padding: 0.325rem 0.5rem; border-radius: 0.625rem; transition: 0.075s all ease; + line-height: 1.5; &:hover { background-color: var(--bg-color-hover-dark); @@ -28,7 +29,7 @@ } .context-menu-action-icon { - margin-right: 0.375rem; + margin-right: 0.5rem; .icon { height: 0.875rem; diff --git a/web/src/components/ContextMenu/ContextMenu.tsx b/web/src/components/ContextMenu/ContextMenu.tsx index 6f5ff3c8..593e4ea1 100644 --- a/web/src/components/ContextMenu/ContextMenu.tsx +++ b/web/src/components/ContextMenu/ContextMenu.tsx @@ -1,11 +1,11 @@ -import React from 'react' +import React, { useEffect, useRef } from 'react' import { ActionHashB64 } from '../../types/shared' import Icon from '../Icon/Icon' import './ContextMenu.scss' export type ContextMenuProps = { // proptypes - menuWidth?: string + menuWidth: string outcomeActionHash: ActionHashB64 actions: { key: string @@ -13,16 +13,40 @@ export type ContextMenuProps = { text: string onClick?: (outcomeActionHash: ActionHashB64) => void }[] + setMenuHeight: (height: number) => void } const ContextMenu: React.FC = ({ - // prop declarations - menuWidth = '11rem', + menuWidth, actions, outcomeActionHash, + setMenuHeight, }) => { + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + const height = ref.current.offsetHeight + // send the height back to the parent + setMenuHeight(height) + } + }, [ref.current]) + + // adjust height when number of actions changes + useEffect(() => { + if (ref.current) { + const height = ref.current.offsetHeight + // send the height back to the parent + setMenuHeight(height) + } + }, [actions.length]) + return ( -
+
{actions.map((action) => (
= ({ }`} />
+ {comments.length === 0 && ( +
+
+ There are no comments on this outcome yet. +
+
+ )} {comments // just in case we don't have someones profile, don't show them for now // so only keep ones whose profiles we have diff --git a/web/src/components/ExpandedViewMode/EVMiddleColumn/TabContent/EvComments/EvComments.scss b/web/src/components/ExpandedViewMode/EVMiddleColumn/TabContent/EvComments/EvComments.scss index fabefc4e..2d78acc2 100644 --- a/web/src/components/ExpandedViewMode/EVMiddleColumn/TabContent/EvComments/EvComments.scss +++ b/web/src/components/ExpandedViewMode/EVMiddleColumn/TabContent/EvComments/EvComments.scss @@ -9,6 +9,10 @@ margin-bottom: 0.5rem; transition: 0.2s background-color ease; + .comments-posted-list-item-empty { + color: var(--text-color-tertiary); + } + &:first-child { margin-top: 1rem; } diff --git a/web/src/components/ExpandedViewMode/ExpandedViewMode.scss b/web/src/components/ExpandedViewMode/ExpandedViewMode.scss index 0b8c74f3..125b0746 100644 --- a/web/src/components/ExpandedViewMode/ExpandedViewMode.scss +++ b/web/src/components/ExpandedViewMode/ExpandedViewMode.scss @@ -12,7 +12,7 @@ left: 50%; -ms-transform: translate(-50%, -50%); transform: translate(-50%, -50%); - z-index: 3; + z-index: 4; .button-close-wrapper { position: absolute; @@ -30,14 +30,14 @@ overflow-y: hidden; position: fixed; top: 0; - z-index: 2; + z-index: 4; } .breadcrumbs-overlay { position: fixed; top: 0.5rem; left: 1rem; - z-index: 3; + z-index: 5; } // breadcrumbs css transitions (animation) diff --git a/web/src/components/Footer/Footer.scss b/web/src/components/Footer/Footer.scss index a33689ed..6d5088d9 100644 --- a/web/src/components/Footer/Footer.scss +++ b/web/src/components/Footer/Footer.scss @@ -1,11 +1,11 @@ .footer { max-height: 48px; - z-index: 3; + } /* Buttom Left Panel */ -.bottom-panel { +.bottom-left-panel { position: fixed; bottom: 10px; left: 10px; @@ -13,31 +13,7 @@ align-items: center; flex-direction: row; height: 48px; -} - -.bottom-panel-entry-points { - background: rgba(255, 255, 255, 0.9) 0% 0% no-repeat padding-box; - border-radius: 10px; - height: 46px; - width: 46px; - margin-left: 10px; - display: flex; - align-items: center; - justify-content: space-evenly; -} - -.bottom-panel-entry-points .icon { - height: 26px; - width: 26px; - padding: 2px; - display: inline-block; - position: relative; -} - -/* when Entry Point Icon is open: */ - -.bottom-panel-entry-points .icon.active .inner-icon { - background-color: var(--text-color-link); + z-index: 4; } /* Buttom Right Panel */ @@ -54,6 +30,7 @@ align-items: center; flex-direction: row; height: 48px; + z-index: 3; .map-viewing-options-button-wrapper { position: relative; diff --git a/web/src/components/Footer/Footer.tsx b/web/src/components/Footer/Footer.tsx index 46554670..6e340dd5 100644 --- a/web/src/components/Footer/Footer.tsx +++ b/web/src/components/Footer/Footer.tsx @@ -104,7 +104,7 @@ const Footer: React.FC = ({ return (
{/* Report Issue Button */} -
+
void } -const Checkbox: React.FC = ({ +const MapViewContextMenu: React.FC = ({ projectCellId, outcomeActionHash, outcomeStatement, isCollapsed, hasChildren, - contextMenuCoordinate, + contextMenuClickCoordinate, expandOutcome, collapseOutcome, unsetContextMenu, }) => { - -// pull in the toast context -const { setToastState } = useContext(ToastContext) + // pull in the toast context + const { setToastState } = useContext(ToastContext) const wrappedCollapseOutcome = () => { collapseOutcome(projectCellId, outcomeActionHash) @@ -60,6 +60,14 @@ const { setToastState } = useContext(ToastContext) } const actions = [] + + actions.push({ + key: 'copy-outcome', + icon: 'file-copy.svg', + text: 'Copy Outcome', + onClick: copyOutcomeStatement, + }) + actions.push({ key: 'copy-statement', icon: 'text-align-left.svg', @@ -67,35 +75,79 @@ const { setToastState } = useContext(ToastContext) onClick: copyOutcomeStatement, }) + // only show this if the outcome has children + if (hasChildren) { + actions.push({ + key: 'copy-subtree', + icon: 'file-copy.svg', + text: 'Copy Subtree', + onClick: copyOutcomeStatement, + }) + } + + // only show this if the outcome has children + if (hasChildren) { + actions.push({ + key: 'export-subtree', + icon: 'export.svg', + text: 'Export Subtree', + onClick: copyOutcomeStatement, + }) + } + + // only enable if the outcome is has children BUT + // children should not have multiple parents if (hasChildren && isCollapsed) { actions.push({ key: 'expand', - icon: 'leaf.svg', - text: 'Expand Outcome', + icon: 'expand2.svg', + text: 'Expand Subtree', onClick: wrappedExpandOutcome, }) } - // DISABLED: collapsing outcomes - // else if (hasChildren) { - // actions.push({ - // key: 'collapse', - // icon: 'leaf.svg', - // text: 'Collapse Outcome', - // onClick: wrappedCollapseOutcome, - // }) - // } + // only enable if the outcome is has children BUT + // TODO: if only children do not have multiple parents + else if (hasChildren) { + actions.push({ + key: 'collapse', + icon: 'collapse.svg', + text: 'Collapse Subtree', + onClick: wrappedCollapseOutcome, + }) + } + + // set menu width in pixels + const menuWidth = 176 + + // use this hook to make sure the menu is contained within the screen + const { + initialized, + setItemHeight: setMenuHeight, + renderCoordinate, + } = useContainWithinScreen({ + initialWidth: menuWidth, + initialHeight: 0, + cursorCoordinate: contextMenuClickCoordinate, + }) return (
- +
) } -export default Checkbox +export default MapViewContextMenu diff --git a/web/src/components/MapViewCreateOutcome/MapViewCreateOutcome.component.tsx b/web/src/components/MapViewCreateOutcome/MapViewCreateOutcome.component.tsx index fcd1208d..af2cb32b 100644 --- a/web/src/components/MapViewCreateOutcome/MapViewCreateOutcome.component.tsx +++ b/web/src/components/MapViewCreateOutcome/MapViewCreateOutcome.component.tsx @@ -14,7 +14,8 @@ import { LinkedOutcomeDetails, Outcome } from '../../types' import ButtonCheckbox from '../ButtonCheckbox/ButtonCheckbox' import { coordsCanvasToPage } from '../../drawing/coordinateSystems' import Icon from '../Icon/Icon' -import checkForKeyboardKeyModifier from '../../event-listeners/osPlatformHelper' +import checkForKeyboardKeyModifier from '../../event-listeners/helpers/osPlatformHelper' +import useContainWithinScreen from '../../hooks/useContainWithinScreen' export type MapViewCreateOutcomeOwnProps = { projectId: CellIdString @@ -67,6 +68,11 @@ const MapViewCreateOutcome: React.FC = ({ createOutcomeWithConnection, closeOutcomeForm, }) => { + // outer ref for the sake of onClickOutside + const outerRef = useRef(null) + const textAreaRef = useRef(null) + useOnClickOutside(outerRef, handleSubmit) + const [isSmallScopeChecked, setIsSmallScopeChecked] = useState(false) const [textIsFocused, setTextIsFocused] = useState(false) @@ -85,7 +91,11 @@ const MapViewCreateOutcome: React.FC = ({ const handleKeyDown = (e: KeyboardEvent) => { if (textIsFocused && e.key === 'Enter') { handleSubmit() - } else if (!textIsFocused && e.key === 'Enter' && checkForKeyboardKeyModifier(e)) { + } else if ( + !textIsFocused && + e.key === 'Enter' && + checkForKeyboardKeyModifier(e) + ) { handleSubmit() } } @@ -113,7 +123,7 @@ const MapViewCreateOutcome: React.FC = ({ // this can get called via keyboard events // or via 'onClickOutside' of the MapViewCreateOutcome component - const handleSubmit = async () => { + async function handleSubmit() { // do not allow submit with no content if (!content || content === '') { closeOutcomeForm() @@ -171,9 +181,6 @@ const MapViewCreateOutcome: React.FC = ({ ) } - const ref = useRef() - useOnClickOutside(ref, handleSubmit) - const pageCoords = coordsCanvasToPage( { x: leftConnectionXPosition, @@ -182,18 +189,52 @@ const MapViewCreateOutcome: React.FC = ({ translate, scale ) + // set card width in pixels + const cardWidth = 384 + // use this hook to make sure the card is contained within the screen + const { + initialized, + setItemHeight: setCardHeight, + renderCoordinate, + } = useContainWithinScreen({ + initialWidth: cardWidth, + initialHeight: 0, + cursorCoordinate: pageCoords, + }) + + // capture card's height as rendered on the screen + useEffect(() => { + if (outerRef.current) { + const height = outerRef.current.offsetHeight + setCardHeight(height) + } + // this also fires when the `content` changes + // so that adjustments can be made to the height + }, [outerRef.current, content]) + + // focus text area + // after the whole thing becomes visible + // which is after the height is calculated + // this is necessary because a non visible + // element cannot be focused + useEffect(() => { + if (initialized) { + textAreaRef.current?.focus() + } + }, [initialized]) return (
-
+
= ({ onChange={handleChange} onFocus={handleFocus} onBlur={handleBlur} + ref={textAreaRef} />
{/* small scope option checkbox */} -
+
{ ctx.lineCap = 'round' - - const DEFAULT_WIDTH = 3 // (at 100 %) + const DEFAULT_WIDTH = connectionForSelectedOutcome ? 4.5 : 3 // (at 100 %) // 0.02 < zoomLevel < 2.5 // dont go lower than the DEFAULT_WIDTH, but go higher as the // zoomLevel drops @@ -97,6 +100,18 @@ export default function render({ // isHovered adjust // isSelected adjust ctx.lineWidth = lineWidth + + // shadowColor + if (connectionForSelectedOutcome && isAchieved) { + ctx.shadowColor = CONNECTION_ACHIEVED_SELECTED_OUTCOME_COLOR + + } else if (connectionForSelectedOutcome && !isAchieved) { + ctx.shadowColor = CONNECTION_NOT_ACHIEVED_SELECTED_OUTCOME_COLOR + } + ctx.shadowBlur = connectionForSelectedOutcome ? 15 : 0 + ctx.shadowOffsetX = 0 + ctx.shadowOffsetY = 0 + ctx.strokeStyle = isSelected ? SELECTED_COLOR : isAchieved diff --git a/web/src/drawing/drawConnectionConnector.ts b/web/src/drawing/drawConnectionConnector.ts new file mode 100644 index 00000000..a07280ce --- /dev/null +++ b/web/src/drawing/drawConnectionConnector.ts @@ -0,0 +1,59 @@ +import { ActionHashB64 } from '@holochain/client' +import { RenderProps } from '../routes/ProjectView/MapView/selectRenderProps' +import drawConnection, { + calculateConnectionCoordsByOutcomeCoords, +} from './drawConnection' + +/* + DRAW PENDING CONNECTION FOR "CONNECTION CONNECTOR" +*/ +// render the connection that is pending to be created between existing Outcomes +// if there's an Outcome this is pending +// as being "to", then we will be drawing the connection to its correct +// upper or lower port +// the opposite of whichever the "from" port is connected to +export default function drawConnectionConnector({ + ctx, + coordinates, + allOutcomeDimensions, + mouseLiveCoordinate, + zoomLevel, + outcomeConnectorMaybeLinkedOutcome, + outcomeConnectorToAddress, +}: { + ctx: CanvasRenderingContext2D + coordinates: RenderProps['coordinates'] + allOutcomeDimensions: RenderProps['dimensions'] + mouseLiveCoordinate: RenderProps['mouseLiveCoordinate'] + zoomLevel: RenderProps['zoomLevel'] + outcomeConnectorMaybeLinkedOutcome: RenderProps['outcomeConnectorMaybeLinkedOutcome'] + outcomeConnectorToAddress?: ActionHashB64 +}) { + const outcomeConnectorFromAddress = + outcomeConnectorMaybeLinkedOutcome.outcomeActionHash + const outcomeConnectorRelation = outcomeConnectorMaybeLinkedOutcome.relation + const fromCoords = coordinates[outcomeConnectorFromAddress] + const [childCoords, parentCoords] = calculateConnectionCoordsByOutcomeCoords( + fromCoords, + allOutcomeDimensions[outcomeConnectorFromAddress], + // use the current mouse coordinate position, liveCoordinate, by default + outcomeConnectorToAddress + ? coordinates[outcomeConnectorToAddress] + : mouseLiveCoordinate, + outcomeConnectorToAddress + ? allOutcomeDimensions[outcomeConnectorToAddress] + : { width: 0, height: 0 }, + outcomeConnectorRelation + ) + // in drawConnection, it draws at exactly the two coordinates given, + // so we could pass them in either order/position + drawConnection({ + connection1port: childCoords, + connection2port: parentCoords, + ctx, + isAchieved: false, + isHovered: false, + isSelected: false, + zoomLevel, + }) +} diff --git a/web/src/drawing/drawCreateOutcomeConnection.ts b/web/src/drawing/drawCreateOutcomeConnection.ts new file mode 100644 index 00000000..2c5b9018 --- /dev/null +++ b/web/src/drawing/drawCreateOutcomeConnection.ts @@ -0,0 +1,82 @@ +import { RenderProps } from '../routes/ProjectView/MapView/selectRenderProps' +import { coordsCanvasToPage, coordsPageToCanvas } from './coordinateSystems' +import { getOutcomeWidth, getOutcomeHeight } from './dimensions' +import drawConnection, { + calculateConnectionCoordsByOutcomeCoords, +} from './drawConnection' +import { getPlaceholderOutcome } from './drawOutcome/placeholderOutcome' + +/* +DRAW PENDING CONNECTION FOR OUTCOME FORM +*/ +// render the Connection that is pending to be created to an open Outcome +// creation form +export default function drawCreateOutcomeConnection({ + ctx, + coordinates, + allOutcomeDimensions, + zoomLevel, + translate, + outcomeFormMaybeLinkedOutcome, + outcomeFormLeftConnectionX, + outcomeFormTopConnectionY, +}: { + ctx: CanvasRenderingContext2D + coordinates: RenderProps['coordinates'] + allOutcomeDimensions: RenderProps['dimensions'] + zoomLevel: RenderProps['zoomLevel'] + translate: RenderProps['translate'] + outcomeFormMaybeLinkedOutcome: RenderProps['outcomeFormMaybeLinkedOutcome'] + outcomeFormLeftConnectionX: RenderProps['outcomeFormLeftConnectionX'] + outcomeFormTopConnectionY: RenderProps['outcomeFormTopConnectionY'] +}) { + const outcomeFormFromActionHash = + outcomeFormMaybeLinkedOutcome.outcomeActionHash + const outcomeFormRelation = outcomeFormMaybeLinkedOutcome.relation + const sourceCoordinates = coordinates[outcomeFormFromActionHash] + const sourceDimensions = allOutcomeDimensions[outcomeFormFromActionHash] + const destinationCoordinates = { + x: outcomeFormLeftConnectionX, + y: outcomeFormTopConnectionY, + } + const pixelWidth = 384 + const pixelHeight = 205 + const destinationPageCoords = coordsCanvasToPage(destinationCoordinates, translate, zoomLevel) + // overflowX situation + if (destinationPageCoords.x + pixelWidth > window.innerWidth) { + const adjustBy = destinationPageCoords.x + pixelWidth - window.innerWidth + destinationCoordinates.x -= coordsPageToCanvas({ x: adjustBy, y: 0 }, { x: 0, y: 0 }, zoomLevel).x + } + // overflowY situation + if (destinationPageCoords.y + pixelHeight > window.innerHeight) { + const adjustBy = destinationPageCoords.y + pixelHeight - window.innerHeight + destinationCoordinates.y -= coordsPageToCanvas({ x: 0, y: adjustBy }, { x: 0, y: 0 }, zoomLevel).y + } + // convert the height of the card which is measured in pixels into + // the height of the card measured in canvas units + const createOutcomeCardCanvasWidthAndHeight = coordsPageToCanvas({ x: pixelWidth, y: pixelHeight }, { x: 0, y: 0 }, zoomLevel) + const destinationDimensions = { + width: createOutcomeCardCanvasWidthAndHeight.x, + height: createOutcomeCardCanvasWidthAndHeight.y, + } + const [ + connection1port, + connection2port, + ] = calculateConnectionCoordsByOutcomeCoords( + sourceCoordinates, + sourceDimensions, + destinationCoordinates, + destinationDimensions, + outcomeFormRelation + ) + + drawConnection({ + connection1port, + connection2port, + ctx, + isAchieved: false, + isSelected: false, + isHovered: false, + zoomLevel, + }) +} diff --git a/web/src/drawing/drawExistingConnections.ts b/web/src/drawing/drawExistingConnections.ts new file mode 100644 index 00000000..7ba69117 --- /dev/null +++ b/web/src/drawing/drawExistingConnections.ts @@ -0,0 +1,91 @@ +import { Connection } from 'zod-models' +import { ProjectComputedOutcomes } from '../context/ComputedOutcomeContext' +import selectRenderProps, { + RenderProps, +} from '../routes/ProjectView/MapView/selectRenderProps' +import { RelationInput, ComputedSimpleAchievementStatus } from '../types' +import { ActionHashB64, WithActionHash } from '../types/shared' +import drawConnection, { + calculateConnectionCoordsByOutcomeCoords, +} from './drawConnection' + +// render each connection to the canvas, basing it off the rendering coordinates of the parent and child nodes +export default function drawExistingConnections({ + connectionsAsArray, + coordinates, + allOutcomeDimensions, + hoveredConnectionActionHash, + selectedConnections, + zoomLevel, + ctx, + outcomes, + outcomeFormExistingParent, + outcomeConnectorExistingParent, + selectedOutcomeActionHash, +}: { + connectionsAsArray: WithActionHash[] + coordinates: RenderProps['coordinates'] + allOutcomeDimensions: RenderProps['dimensions'] + hoveredConnectionActionHash: RenderProps['hoveredConnectionActionHash'] + selectedConnections: RenderProps['selectedConnections'] + zoomLevel: RenderProps['zoomLevel'] + outcomeFormExistingParent: RenderProps['outcomeFormExistingParent'] + outcomeConnectorExistingParent: RenderProps['outcomeConnectorExistingParent'] + ctx: CanvasRenderingContext2D + outcomes: ProjectComputedOutcomes['computedOutcomesKeyed'] + selectedOutcomeActionHash: ActionHashB64 | null +}) { + connectionsAsArray.forEach(function (connection) { + // if in the pending re-parenting mode for the child card of an existing connection, + // temporarily omit/hide the existing connection from view + // ASSUMPTION: one parent + if ( + connection.actionHash === outcomeFormExistingParent || + connection.actionHash === outcomeConnectorExistingParent + ) { + // do not draw, because we are pre-representing the + // fact that this connection will be deleted/replaced + return + } + + const childCoords = coordinates[connection.childActionHash] + const parentCoords = coordinates[connection.parentActionHash] + const childOutcome = outcomes[connection.childActionHash] + const parentOutcome = outcomes[connection.parentActionHash] + // we can only render this connection + // if we know the coordinates of the Outcomes it connects + if (childCoords && parentCoords && parentOutcome && childOutcome) { + const [ + connection1port, + connection2port, + ] = calculateConnectionCoordsByOutcomeCoords( + childCoords, + allOutcomeDimensions[connection.childActionHash], + parentCoords, + allOutcomeDimensions[connection.parentActionHash], + RelationInput.ExistingOutcomeAsChild + ) + const isHovered = hoveredConnectionActionHash === connection.actionHash + + const isSelected = selectedConnections.includes(connection.actionHash) + + // highlight the existing connections for a selected outcome + const connectionForSelectedOutcome = selectedOutcomeActionHash + ? selectedOutcomeActionHash === connection.childActionHash || + selectedOutcomeActionHash === connection.parentActionHash + : false + drawConnection({ + connection1port, + connection2port, + ctx, + isAchieved: + childOutcome.computedAchievementStatus.simple === + ComputedSimpleAchievementStatus.Achieved, + isHovered, + isSelected, + connectionForSelectedOutcome, + zoomLevel, + }) + } + }) +} diff --git a/web/src/drawing/drawOutcomeGroup.ts b/web/src/drawing/drawOutcomeGroup.ts new file mode 100644 index 00000000..703184a2 --- /dev/null +++ b/web/src/drawing/drawOutcomeGroup.ts @@ -0,0 +1,92 @@ +import { ProjectComputedOutcomes } from '../context/ComputedOutcomeContext' +import { RenderProps } from '../routes/ProjectView/MapView/selectRenderProps' +import drawOutcome from './drawOutcome' +import { ComputedOutcome } from '../types' +import { ActionHashB64 } from '@holochain/client' + +export default function drawOutcomeGroup({ + outcomesAsArray, + coordinates, + allOutcomeDimensions, + projectTags, + topPriorityOutcomes, + areSelected, + zoomLevel, + ctx, +}: { + outcomesAsArray: ComputedOutcome[] + coordinates: RenderProps['coordinates'] + allOutcomeDimensions: RenderProps['dimensions'] + projectTags: RenderProps['projectTags'] + topPriorityOutcomes: ActionHashB64[] + areSelected: boolean + zoomLevel: RenderProps['zoomLevel'] + ctx: CanvasRenderingContext2D +}) { + outcomesAsArray.forEach(function (outcome) { + const coords = coordinates[outcome.actionHash] + const outcomeDimensions = allOutcomeDimensions[outcome.actionHash] + const isTopPriorityOutcome = !!topPriorityOutcomes.find( + (actionHash) => actionHash === outcome.actionHash + ) + // we can only render this outcome + // if we know its coordinates + if (coords) { + drawOutcome({ + outcome, + zoomLevel, + outcomeLeftX: coords.x, + outcomeTopY: coords.y, + outcomeHeight: outcomeDimensions.height, + outcomeWidth: outcomeDimensions.width, + projectTags, + useLineLimit: true, + isTopPriority: isTopPriorityOutcome, + isSelected: areSelected, + ctx, + // outcomeFocusedMembers: [], + // members: membersOfOutcome, + // isEditing: isEditing, // self + // editText: '', + // isHovered: isHovered, + // isBeingEdited: isBeingEdited, // by other + // isBeingEditedBy: isBeingEditedBy, // other + // allMembersActiveOnOutcome: allMembersActiveOnOutcome, + }) + } + }) +} + +/* +// const isHovered = state.ui.hover.hoveredOutcome === outcome.actionHash + // const isEditing = false + // let editInfoObjects = Object.values(state.ui.realtimeInfo).filter( + // (agentInfo) => + // agentInfo.outcomeBeingEdited !== null && + // agentInfo.outcomeBeingEdited.outcomeActionHash === outcome.actionHash + // ) + // const isBeingEdited = editInfoObjects.length > 0 + // const isBeingEditedBy = + // editInfoObjects.length === 1 + // ? state.agents[editInfoObjects[0].agentPubKey].handle + // : editInfoObjects.length > 1 + // ? `${editInfoObjects.length} people` + // : null + // a combination of those editing + those with expanded view open + // const allMembersActiveOnOutcome = Object.values(state.ui.realtimeInfo) + // .filter( + // (agentInfo) => + // agentInfo.outcomeExpandedView === outcome.actionHash || + // (agentInfo.outcomeBeingEdited !== null && + // agentInfo.outcomeBeingEdited.outcomeActionHash === + // outcome.actionHash) + // ) + // .map( + // (realtimeInfoObject) => state.agents[realtimeInfoObject.agentPubKey] + // ) + + // const membersOfOutcome = Object.keys(outcomeMembers) + // .map(actionHash => outcomeMembers[actionHash]) + // .filter(outcomeMember => outcomeMember.outcomeActionHash === outcome.actionHash) + // .map(outcomeMember => state.agents[outcomeMember.memberAgentPubKey]) + */ diff --git a/web/src/drawing/graphCoordinates.ts b/web/src/drawing/graphCoordinates.ts index 0415c1c2..71896f11 100644 --- a/web/src/drawing/graphCoordinates.ts +++ b/web/src/drawing/graphCoordinates.ts @@ -89,9 +89,9 @@ export default function layoutForGraph( .nodeSize((node: any) => { // width and height, plus some extra padding const width = - node === undefined ? 0 : allOutcomeDimensions[node.data.id].width + 200 + node === undefined || allOutcomeDimensions[node.data.id] === undefined ? 0 : allOutcomeDimensions[node.data.id].width + 200 const height = - node === undefined ? 0 : allOutcomeDimensions[node.data.id].height + 200 + node === undefined || allOutcomeDimensions[node.data.id] === undefined ? 0 : allOutcomeDimensions[node.data.id].height + 200 return [width, height] }) @@ -102,6 +102,8 @@ export default function layoutForGraph( // define the coordinates of each node const coordinates = {} for (const node of dag) { + const dimensions = allOutcomeDimensions[node.data.id] + if (dimensions === undefined) continue const width = allOutcomeDimensions[node.data.id].width const height = allOutcomeDimensions[node.data.id].height coordinates[node.data.id] = { diff --git a/web/src/drawing/index.ts b/web/src/drawing/index.ts index 0de57d92..012e2f21 100644 --- a/web/src/drawing/index.ts +++ b/web/src/drawing/index.ts @@ -1,3 +1,14 @@ +import drawOverlay from './drawOverlay' +import drawSelectBox from './drawSelectBox' +import drawEntryPoints from './drawEntryPoints' +import selectRenderProps from '../routes/ProjectView/MapView/selectRenderProps' +import { ProjectComputedOutcomes } from '../context/ComputedOutcomeContext' +import drawExistingConnections from './drawExistingConnections' +import drawOutcomeGroup from './drawOutcomeGroup' +import drawCreateOutcomeConnection from './drawCreateOutcomeConnection' +import drawConnectionConnector from './drawConnectionConnector' +import setupCanvas from './setupCanvas' + /* This file is the entry point for how to render the redux state visually onto the screen, using the HTML5 canvas APIs. @@ -5,55 +16,12 @@ and use well defined functions for rendering those specific parts to the canvas. */ -import drawOutcomeCard from './drawOutcome' -import drawConnection, { - calculateConnectionCoordsByOutcomeCoords, -} from './drawConnection' -import drawOverlay from './drawOverlay' -import drawSelectBox from './drawSelectBox' -import drawEntryPoints from './drawEntryPoints' -import { getOutcomeHeight, getOutcomeWidth } from './dimensions' -import { - ComputedOutcome, - ComputedSimpleAchievementStatus, - ProjectMeta, - RelationInput, - Tag, -} from '../types' -import { ActionHashB64, WithActionHash } from '../types/shared' -import { ProjectConnectionsState } from '../redux/persistent/projects/connections/reducer' -import { ProjectEntryPointsState } from '../redux/persistent/projects/entry-points/reducer' -import { ProjectOutcomeMembersState } from '../redux/persistent/projects/outcome-members/reducer' -import { getPlaceholderOutcome } from './drawOutcome/placeholderOutcome' -import { - CoordinatesState, - DimensionsState, -} from '../redux/ephemeral/layout/state-type' -import selectRenderProps from '../routes/ProjectView/MapView/selectRenderProps' -import { ProjectComputedOutcomes } from '../context/ComputedOutcomeContext' - -function setupCanvas(canvas) { - // Get the device pixel ratio, falling back to 1. - const dpr = window.devicePixelRatio || 1 - // Get the size of the canvas in CSS pixels. - const rect = canvas.getBoundingClientRect() - // Give the canvas pixel dimensions of their CSS - // size * the device pixel ratio. - canvas.width = rect.width * dpr - canvas.height = rect.height * dpr - const ctx = canvas.getContext('2d') - return ctx -} // Render is responsible for painting all the existing outcomes & connections, // as well as the yet to be created (pending) ones (For new Outcome / new Connection / edit Connection) // render the state contained in store onto the canvas -// `store` is a redux store -// `canvas` is a reference to an HTML5 canvas DOM element -function render( +export default function render( { - computedOutcomesKeyed, - // from selectRenderProps projectTags, screenWidth, screenHeight, @@ -82,39 +50,33 @@ function render( shiftKeyDown, startedSelection, startedSelectionCoordinate, - }: ReturnType & { - computedOutcomesKeyed: ProjectComputedOutcomes['computedOutcomesKeyed'] - }, + }: ReturnType, + computedOutcomesKeyed: ProjectComputedOutcomes['computedOutcomesKeyed'], canvas: HTMLCanvasElement ) { - // Get the 2 dimensional drawing context of the canvas (there is also 3 dimensional, e.g.) - const ctx = setupCanvas(canvas) - - // zoomLevel x, skew x, skew y, zoomLevel y, translate x, and translate y - ctx.setTransform(1, 0, 0, 1, 0, 0) // normalize - // clear the entirety of the canvas - ctx.clearRect(0, 0, screenWidth, screenHeight) + // only draw if the project has fully loaded + if ( + !( + computedOutcomesKeyed && + connections && + outcomeMembers && + entryPoints && + projectMeta + ) + ) { + return + } - // zoomLevel all drawing operations by the dpr, as well as the zoom, so you - // don't have to worry about the difference. - const dpr = window.devicePixelRatio || 1 - ctx.setTransform( - zoomLevel * dpr, - 0, - 0, - zoomLevel * dpr, - translate.x * dpr, - translate.y * dpr + const ctx = setupCanvas( + canvas, + screenWidth, + screenHeight, + zoomLevel, + translate ) + // massaging the data we're passed into useful formats and segments const outcomes = computedOutcomesKeyed - - // draw things relating to the project, if the project has fully loaded - if ( - !(outcomes && connections && outcomeMembers && entryPoints && projectMeta) - ) - return - const topPriorityOutcomes = projectMeta.topPriorityOutcomes // converts the outcomes object to an array const outcomesAsArray = Object.keys(outcomes).map( @@ -124,137 +86,56 @@ function render( const connectionsAsArray = Object.keys(connections).map( (actionHash) => connections[actionHash] ) - - /* - DRAW CONNECTIONS (EXISTING) - */ - // render each connection to the canvas, basing it off the rendering coordinates of the parent and child nodes - connectionsAsArray.forEach(function (connection) { - // if in the pending re-parenting mode for the child card of an existing connection, - // temporarily omit/hide the existing connection from view - // ASSUMPTION: one parent - if ( - connection.actionHash === outcomeFormExistingParent || - connection.actionHash === outcomeConnectorExistingParent - ) { - // do not draw, because we are pre-representing the - // fact that this connection will be deleted/replaced - return - } - - const childCoords = coordinates[connection.childActionHash] - const parentCoords = coordinates[connection.parentActionHash] - const childOutcome = outcomes[connection.childActionHash] - const parentOutcome = outcomes[connection.parentActionHash] - // we can only render this connection - // if we know the coordinates of the Outcomes it connects - if (childCoords && parentCoords && parentOutcome && childOutcome) { - const [ - connection1port, - connection2port, - ] = calculateConnectionCoordsByOutcomeCoords( - childCoords, - allOutcomeDimensions[connection.childActionHash], - parentCoords, - allOutcomeDimensions[connection.parentActionHash], - RelationInput.ExistingOutcomeAsChild - ) - const isHovered = hoveredConnectionActionHash === connection.actionHash - const isSelected = selectedConnections.includes(connection.actionHash) - drawConnection({ - connection1port, - connection2port, - ctx, - isAchieved: - childOutcome.computedAchievementStatus.simple === - ComputedSimpleAchievementStatus.Achieved, - isHovered, - isSelected, - zoomLevel, - }) - } - }) - - /* - SEPARATE SELECTED & UNSELECTED OUTCOMES - */ - // in order to create layers behind and in front of the editing highlight overlay + // separate selected and unselected Outcomes + // in order to create layers behind and in + // front of the editing highlight overlay const unselectedOutcomes = outcomesAsArray.filter((outcome) => { return selectedOutcomes.indexOf(outcome.actionHash) === -1 }) const selectedOutcomesActual = outcomesAsArray.filter((outcome) => { return selectedOutcomes.indexOf(outcome.actionHash) > -1 }) + // create a list of the entry points that are active + const activeEntryPointsObjects = activeEntryPoints + .map((entryPointAddress) => entryPoints[entryPointAddress]) + // drop ones that may be undefined + .filter((activeEntryPoint) => activeEntryPoint) - /* - DRAW UNSELECTED OUTCOMES - */ - // render each unselected outcome to the canvas - unselectedOutcomes.forEach((outcome) => { - // use the set of coordinates at the same index - // in the coordinates array - const isSelected = false - // const isHovered = state.ui.hover.hoveredOutcome === outcome.actionHash - // const isEditing = false - // let editInfoObjects = Object.values(state.ui.realtimeInfo).filter( - // (agentInfo) => - // agentInfo.outcomeBeingEdited !== null && - // agentInfo.outcomeBeingEdited.outcomeActionHash === outcome.actionHash - // ) - // const isBeingEdited = editInfoObjects.length > 0 - // const isBeingEditedBy = - // editInfoObjects.length === 1 - // ? state.agents[editInfoObjects[0].agentPubKey].handle - // : editInfoObjects.length > 1 - // ? `${editInfoObjects.length} people` - // : null - // a combination of those editing + those with expanded view open - // const allMembersActiveOnOutcome = Object.values(state.ui.realtimeInfo) - // .filter( - // (agentInfo) => - // agentInfo.outcomeExpandedView === outcome.actionHash || - // (agentInfo.outcomeBeingEdited !== null && - // agentInfo.outcomeBeingEdited.outcomeActionHash === - // outcome.actionHash) - // ) - // .map( - // (realtimeInfoObject) => state.agents[realtimeInfoObject.agentPubKey] - // ) + /* START DRAWING */ - // const membersOfOutcome = Object.keys(outcomeMembers) - // .map(actionHash => outcomeMembers[actionHash]) - // .filter(outcomeMember => outcomeMember.outcomeActionHash === outcome.actionHash) - // .map(outcomeMember => state.agents[outcomeMember.memberAgentPubKey]) - const isTopPriorityOutcome = !!topPriorityOutcomes.find( - (actionHash) => actionHash === outcome.actionHash - ) - if (coordinates[outcome.actionHash]) { - drawOutcomeCard({ - useLineLimit: true, - zoomLevel: zoomLevel, - outcome: outcome, - outcomeLeftX: coordinates[outcome.actionHash].x, - outcomeTopY: coordinates[outcome.actionHash].y, - isSelected: isSelected, - ctx: ctx, - isTopPriority: isTopPriorityOutcome, - outcomeHeight: allOutcomeDimensions[outcome.actionHash].height, - outcomeWidth: allOutcomeDimensions[outcome.actionHash].width, - projectTags, - // members: membersOfOutcome, - // isEditing: isEditing, // self - // editText: '', - // isHovered: isHovered, - // isBeingEdited: isBeingEdited, // by other - // isBeingEditedBy: isBeingEditedBy, // other - // allMembersActiveOnOutcome: allMembersActiveOnOutcome, - }) - } + // The order of drawing is important, because it determines the layering + // of the elements on the canvas. + + // Draw all the Connections that exist already + drawExistingConnections({ + connectionsAsArray, + coordinates, + allOutcomeDimensions, + hoveredConnectionActionHash, + selectedConnections, + zoomLevel, + outcomeFormExistingParent, + outcomeConnectorExistingParent, + ctx, + outcomes, + selectedOutcomeActionHash: + // if there is only one selected Outcome, pass it in + selectedOutcomes.length === 1 ? selectedOutcomes[0] : null, }) - /* - DRAW SELECT BOX - */ + // draw all the Outcomes that are not selected + drawOutcomeGroup({ + outcomesAsArray: unselectedOutcomes, + coordinates, + allOutcomeDimensions, + projectTags, + topPriorityOutcomes, + areSelected: false, + zoomLevel, + ctx, + }) + + // Draw select box if (shiftKeyDown && startedSelection && startedSelectionCoordinate.x !== 0) { drawSelectBox( startedSelectionCoordinate, @@ -263,13 +144,7 @@ function render( ) } - /* - DRAW ENTRY POINTS - */ - const activeEntryPointsObjects = activeEntryPoints - .map((entryPointAddress) => entryPoints[entryPointAddress]) - // drop ones that may be undefined - .filter((activeEntryPoint) => activeEntryPoint) + // Draw Entry Point boxes drawEntryPoints( ctx, activeEntryPointsObjects, @@ -280,209 +155,51 @@ function render( zoomLevel ) - /* - DRAW EDITING HIGHLIGHT SEMI-TRANSPARENT OVERLAY - */ - /* if shift key not held down and there are more than 1 Outcomes selected */ + // Draw editing highlight semi-transparent overlay. if (selectedOutcomes.length > 1 && !shiftKeyDown) { + // if there are more than 1 Outcomes selected and the Shift key + // is not held down drawOverlay(ctx, 0, 0, screenWidth, screenHeight) } - /* - DRAW SELECTED OUTCOMES - */ - selectedOutcomesActual.forEach((outcome) => { - // use the set of coordinates at the same index - // in the coordinates array - const isSelected = true - // const isHovered = state.ui.hover.hoveredOutcome === outcome.actionHash - // const isEditing = false - // let editInfoObjects = Object.values(state.ui.realtimeInfo).filter( - // (agentInfo) => - // agentInfo.outcomeBeingEdited !== null && - // agentInfo.outcomeBeingEdited.outcomeActionHash === outcome.actionHash - // ) - // const isBeingEdited = editInfoObjects.length > 0 - // const isBeingEditedBy = - // editInfoObjects.length === 1 - // ? state.agents[editInfoObjects[0].agentPubKey].handle - // : editInfoObjects.length > 1 - // ? `${editInfoObjects.length} people` - // : null - // a combination of those editing + those with expanded view open - // const allMembersActiveOnOutcome = Object.values(state.ui.realtimeInfo) - // .filter( - // (agentInfo) => - // agentInfo.outcomeExpandedView === outcome.actionHash || - // (agentInfo.outcomeBeingEdited !== null && - // agentInfo.outcomeBeingEdited.outcomeActionHash === - // outcome.actionHash) - // ) - // .map( - // (realtimeInfoObject) => state.agents[realtimeInfoObject.agentPubKey] - // ) - // const membersOfOutcome = Object.keys(outcomeMembers) - // .map((actionHash) => outcomeMembers[actionHash]) - // .filter( - // (outcomeMember) => - // outcomeMember.outcomeActionHash === outcome.actionHash - // ) - // .map((outcomeMember) => state.agents[outcomeMember.memberAgentPubKey]) - const isTopPriorityOutcome = !!topPriorityOutcomes.find( - (actionHash) => actionHash === outcome.actionHash - ) - if (coordinates[outcome.actionHash]) { - drawOutcomeCard({ - useLineLimit: true, - zoomLevel: zoomLevel, - outcome: outcome, - outcomeLeftX: coordinates[outcome.actionHash].x, - outcomeTopY: coordinates[outcome.actionHash].y, - isSelected: isSelected, - ctx: ctx, - isTopPriority: isTopPriorityOutcome, - outcomeWidth: allOutcomeDimensions[outcome.actionHash].width, - outcomeHeight: allOutcomeDimensions[outcome.actionHash].height, - projectTags, - // members: membersOfOutcome, - // isEditing: isEditing, - // editText: '', - // isHovered: isHovered, - // isBeingEdited: isBeingEdited, - // isBeingEditedBy: isBeingEditedBy, - // allMembersActiveOnOutcome: allMembersActiveOnOutcome, - }) - } - }) - - /* - establish width and height for the card for a - new Outcome in the process of being created - */ - const placeholderOutcomeWithText = getPlaceholderOutcome(outcomeFormContent) - const newOutcomeWidth = getOutcomeWidth({ - outcome: placeholderOutcomeWithText, - zoomLevel, - }) - const newOutcomeHeight = getOutcomeHeight({ - ctx, - outcome: placeholderOutcomeWithText, + // Draw selected Outcomes + drawOutcomeGroup({ + outcomesAsArray: selectedOutcomesActual, + coordinates, + allOutcomeDimensions, projectTags, - width: newOutcomeWidth, + topPriorityOutcomes, + areSelected: true, zoomLevel, - // we set this because in the case of creating a new outcome - // it should use the full text at the proper text scaling - noStatementPlaceholder: true, - useLineLimit: false, + ctx, }) - /* - DRAW PENDING CONNECTION FOR OUTCOME FORM - */ - // render the connection that is pending to be created to the open outcome form - + // Draw a Connection to a potential new Outcome if (outcomeFormIsOpen && outcomeFormMaybeLinkedOutcome) { - const outcomeFormFromActionHash = outcomeFormMaybeLinkedOutcome.outcomeActionHash - const outcomeFormRelation = outcomeFormMaybeLinkedOutcome.relation - const [ - connection1port, - connection2port, - ] = calculateConnectionCoordsByOutcomeCoords( - coordinates[outcomeFormFromActionHash], - allOutcomeDimensions[outcomeFormFromActionHash], - { - x: outcomeFormLeftConnectionX, - y: outcomeFormTopConnectionY, - }, - { width: newOutcomeWidth, height: newOutcomeHeight }, - outcomeFormRelation - ) - drawConnection({ - connection1port, - connection2port, + // if there is one in the process of being created. + drawCreateOutcomeConnection({ ctx, - isAchieved: false, - isSelected: false, - isHovered: false, + coordinates, + allOutcomeDimensions, zoomLevel, + translate, + outcomeFormMaybeLinkedOutcome, + outcomeFormLeftConnectionX, + outcomeFormTopConnectionY, }) } - /* - DRAW PENDING CONNECTION FOR "CONNECTION CONNECTOR" - */ - // render the connection that is pending to be created between existing Outcomes - // if there's an Outcome this is pending - // as being "to", then we will be drawing the connection to its correct - // upper or lower port - // the opposite of whichever the "from" port is connected to + // Draw the line during Connection creation + // (when the user drags on the canvas from an Outcome to another Outcome) if (outcomeConnectorMaybeLinkedOutcome) { - const outcomeConnectorFromAddress = outcomeConnectorMaybeLinkedOutcome.outcomeActionHash - const outcomeConnectorRelation = outcomeConnectorMaybeLinkedOutcome.relation - const fromCoords = coordinates[outcomeConnectorFromAddress] - const [ - childCoords, - parentCoords, - ] = calculateConnectionCoordsByOutcomeCoords( - fromCoords, - allOutcomeDimensions[outcomeConnectorFromAddress], - // use the current mouse coordinate position, liveCoordinate, by default - outcomeConnectorToAddress - ? coordinates[outcomeConnectorToAddress] - : mouseLiveCoordinate, - outcomeConnectorToAddress - ? allOutcomeDimensions[outcomeConnectorToAddress] - : { width: 0, height: 0 }, - outcomeConnectorRelation - ) - // in drawConnection, it draws at exactly the two coordinates given, - // so we could pass them in either order/position - drawConnection({ - connection1port: childCoords, - connection2port: parentCoords, + drawConnectionConnector({ ctx, - isAchieved: false, - isHovered: false, - isSelected: false, + coordinates, + allOutcomeDimensions, + mouseLiveCoordinate, zoomLevel, - }) - } - - /* - DRAW NEW OUTCOME PLACEHOLDER - */ - // creating a new Outcome - if (false && outcomeFormIsOpen) { - const isHovered = false - const isSelected = false - const isEditing = true - const isTopPriorityOutcome = false - const placeholderOutcomeWithText = getPlaceholderOutcome(outcomeFormContent) - drawOutcomeCard({ - // draw the Outcome with empty text - // since the text is presented in the - // MapViewCreateOutcome - skipStatementRender: true, - useLineLimit: false, - zoomLevel: zoomLevel, - outcome: placeholderOutcomeWithText, - outcomeHeight: newOutcomeHeight, - outcomeWidth: newOutcomeWidth, - projectTags, - outcomeLeftX: outcomeFormLeftConnectionX, - outcomeTopY: outcomeFormTopConnectionY, - isSelected: isSelected, - ctx: ctx, - isTopPriority: isTopPriorityOutcome, - // members: [], - // isEditing: isEditing, - // editText: state.ui.outcomeForm.content, - // isHovered: isHovered, - // isBeingEdited: false, - // isBeingEditedBy: '', - // allMembersActiveOnOutcome: [], + outcomeConnectorMaybeLinkedOutcome, + outcomeConnectorToAddress, }) } } - -export default render diff --git a/web/src/drawing/setupCanvas.ts b/web/src/drawing/setupCanvas.ts new file mode 100644 index 00000000..abd13c39 --- /dev/null +++ b/web/src/drawing/setupCanvas.ts @@ -0,0 +1,33 @@ + +export default function setupCanvas( + canvas: HTMLCanvasElement, + screenWidth: number, + screenHeight: number, + zoomLevel: number, + translate: { x: number; y: number } +) { + // Get the device pixel ratio, falling back to 1. + const dpr = window.devicePixelRatio || 1 + // Get the size of the canvas in CSS pixels. + const rect = canvas.getBoundingClientRect() + // Give the canvas pixel dimensions of their CSS + // size * the device pixel ratio. + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d') + // zoomLevel x, skew x, skew y, zoomLevel y, translate x, and translate y + ctx.setTransform(1, 0, 0, 1, 0, 0) // normalize + // clear the entirety of the canvas + ctx.clearRect(0, 0, screenWidth, screenHeight) + // zoomLevel all drawing operations by the dpr, as well as the zoom, so you + // don't have to worry about the difference. + ctx.setTransform( + zoomLevel * dpr, + 0, + 0, + zoomLevel * dpr, + translate.x * dpr, + translate.y * dpr + ) + return ctx +} diff --git a/web/src/event-listeners/bodyKeydown.ts b/web/src/event-listeners/bodyKeydown.ts new file mode 100644 index 00000000..971b7894 --- /dev/null +++ b/web/src/event-listeners/bodyKeydown.ts @@ -0,0 +1,259 @@ +import _ from 'lodash' +import ProjectsZomeApi from '../api/projectsApi' +import { alterSiblingOrder } from '../connections' +import { getAppWs } from '../hcWebsockets' +import { openExpandedView } from '../redux/ephemeral/expanded-view/actions' +import { setShiftKeyDown } from '../redux/ephemeral/keyboard/actions' +import { triggerUpdateLayout } from '../redux/ephemeral/layout/actions' +import { COORDINATES } from '../redux/ephemeral/local-preferences/reducer' +import { + setNavModalOpenChildren, + setNavModalOpenParents, +} from '../redux/ephemeral/navigation-modal/actions' +import { setOutcomeClone } from '../redux/ephemeral/outcome-clone/actions' +import { resetOutcomeConnector } from '../redux/ephemeral/outcome-connector/actions' +import { closeOutcomeForm } from '../redux/ephemeral/outcome-form/actions' +import { unselectAll } from '../redux/ephemeral/selection/actions' +import { animatePanAndZoom } from '../redux/ephemeral/viewport/actions' +import { deleteConnection } from '../redux/persistent/projects/connections/actions' +import { deleteOutcomeFully } from '../redux/persistent/projects/outcomes/actions' +import { RootState } from '../redux/reducer' +import { + findChildrenActionHashes, + findParentsActionHashes, + findSiblingActionHash, + RightOrLeft, +} from '../tree-logic' +import { ActionHashB64 } from '../types/shared' +import { cellIdFromString } from '../utils' +import cloneOutcomes from './helpers/cloneOutcomes' +import checkForKeyboardKeyModifier from './helpers/osPlatformHelper' + +function leftMostOutcome( + outcomeActionHashes: ActionHashB64[], + state: RootState +): ActionHashB64 { + return _.minBy(outcomeActionHashes, (actionHash) => { + return state.ui.layout.coordinates[actionHash].x + }) +} + +export default async function bodyKeydown(store: any, event: KeyboardEvent) { + function canPerformKeyboardAction(state: RootState): boolean { + return ( + state.ui.selection.selectedOutcomes.length === 1 && + !state.ui.outcomeForm.isOpen && + !state.ui.expandedView.isOpen && + !state.ui.navigationModal.open + ) + } + + function getKeyboardNavigationPreference(state: RootState): string { + return state.ui.localPreferences.keyboardNavigation + } + + function panAndZoom(actionHash: string) { + store.dispatch(animatePanAndZoom(actionHash, false)) + } + + const appWebsocket = await getAppWs() + const projectsZomeApi = new ProjectsZomeApi(appWebsocket) + let state: RootState = store.getState() + const { + ui: { activeProject }, + } = state + const cellId = cellIdFromString(activeProject) + // there are event.code and event.key ... + // event.key is keyboard layout independent, so works for Dvorak users + switch (event.key) { + case 'Enter': + if (canPerformKeyboardAction(state)) { + event.stopPropagation() + store.dispatch(openExpandedView(state.ui.selection.selectedOutcomes[0])) + } + + break + + // Used for navigating to a child + case 'ArrowDown': + if (canPerformKeyboardAction(state)) { + const selectedOutcome = state.ui.selection.selectedOutcomes[0] + + const childrenActionHashes = findChildrenActionHashes( + selectedOutcome, + state + ) + if (childrenActionHashes.length) { + event.stopPropagation() + const keyboardNavPreference = getKeyboardNavigationPreference(state) + if (childrenActionHashes.length === 1) { + panAndZoom(childrenActionHashes[0]) + } else if (keyboardNavPreference === COORDINATES) { + // navigate to the left-most child + const leftMostChild = leftMostOutcome(childrenActionHashes, state) + panAndZoom(leftMostChild) + } else { + store.dispatch(setNavModalOpenChildren(childrenActionHashes)) + } + } + } + break + + // Used for navigating to a parent + case 'ArrowUp': + if (canPerformKeyboardAction(state)) { + const selectedOutcome = state.ui.selection.selectedOutcomes[0] + const parentsActionHashes = findParentsActionHashes( + selectedOutcome, + state + ) + if (parentsActionHashes.length) { + event.stopPropagation() + const keyboardNavPreference = getKeyboardNavigationPreference(state) + if (parentsActionHashes.length === 1) { + panAndZoom(parentsActionHashes[0]) + } else if (keyboardNavPreference === COORDINATES) { + // navigate to the left most parent + const leftMostParent = leftMostOutcome(parentsActionHashes, state) + panAndZoom(leftMostParent) + } else { + store.dispatch(setNavModalOpenParents(parentsActionHashes)) + } + } + } + + break + + // Used for navigating to the left sibling + case 'ArrowLeft': + if (canPerformKeyboardAction(state)) { + const selectedOutcome = state.ui.selection.selectedOutcomes[0] + const targetActionHash = findSiblingActionHash( + selectedOutcome, + state, + RightOrLeft.Left + ) + if (event.shiftKey && targetActionHash) { + // only do this if selected outcome has a left sibling + // move the selected outcome to the left side of the left sibling + // (swap positions with the left sibling) + alterSiblingOrder( + store, + state, + selectedOutcome, + targetActionHash, + RightOrLeft.Left + ) + } else if (targetActionHash) { + // select and pan and zoom to + // the parent + store.dispatch(animatePanAndZoom(targetActionHash, false)) + } + } + break + + // Used for navigating to the right sibling + case 'ArrowRight': + if (canPerformKeyboardAction(state)) { + const selectedOutcome = state.ui.selection.selectedOutcomes[0] + const targetActionHash = findSiblingActionHash( + selectedOutcome, + state, + RightOrLeft.Right + ) + if (event.shiftKey && targetActionHash) { + // only do this if selected outcome has a right sibling + // move the selected outcome to the right side of the right sibling + // (swap positions with the right sibling) + alterSiblingOrder( + store, + state, + selectedOutcome, + targetActionHash, + RightOrLeft.Right + ) + } else if (targetActionHash) { + // select and pan and zoom to + // the parent + store.dispatch(animatePanAndZoom(targetActionHash, false)) + } + } + break + + // Used in multi selecting Outcomes + case 'Shift': + store.dispatch(setShiftKeyDown()) + break + case 'Escape': + // Only unselect all Outcomes if the expanded view + // is not open + if (!state.ui.expandedView.isOpen && !state.ui.navigationModal.open) { + store.dispatch(unselectAll()) + } + store.dispatch(closeOutcomeForm()) + store.dispatch(resetOutcomeConnector()) + break + case 'Backspace': + let selection = state.ui.selection + // only dispatch if something's selected and the OutcomeForm and ExpandedView are + // not open + if ( + selection.selectedConnections.length > 0 && + !state.ui.outcomeForm.isOpen && + !state.ui.expandedView.isOpen + ) { + // if on firefox, and matched this case + // prevent the browser from navigating back to the last page + event.preventDefault() + for await (const connection of selection.selectedConnections) { + await projectsZomeApi.connection.delete(cellId, connection) + store.dispatch(deleteConnection(activeProject, connection)) + // this action will trigger a recalc + // and layout animation update, which is natural in this context. + // we have to trigger it manually because there is a scenario where + // deleteConnection should NOT trigger a layout recalc + store.dispatch(triggerUpdateLayout()) + } + } else if ( + selection.selectedOutcomes.length > 0 && + !state.ui.outcomeForm.isOpen && + !state.ui.expandedView.isOpen + ) { + // if on firefox, and matched this case + // prevent the browser from navigating back to the last page + event.preventDefault() + for await (const outcome of selection.selectedOutcomes) { + const fullyDeletedOutcome = await projectsZomeApi.outcome.deleteOutcomeFully( + cellId, + outcome + ) + store.dispatch(deleteOutcomeFully(activeProject, fullyDeletedOutcome)) + } + } + break + case 'c': + if ( + checkForKeyboardKeyModifier(event) && + state.ui.selection.selectedOutcomes.length && + !state.ui.outcomeForm.isOpen && + !state.ui.expandedView.isOpen + ) { + store.dispatch(setOutcomeClone(state.ui.selection.selectedOutcomes)) + } + break + case 'v': + if ( + checkForKeyboardKeyModifier(event) && + state.ui.outcomeClone.outcomes.length && + !state.ui.outcomeForm.isOpen && + !state.ui.expandedView.isOpen + ) { + cloneOutcomes(store) + } + break + default: + // console.log(event) + break + } + // console.log(event) +} diff --git a/web/src/event-listeners/bodyKeyup.ts b/web/src/event-listeners/bodyKeyup.ts new file mode 100644 index 00000000..873d47dd --- /dev/null +++ b/web/src/event-listeners/bodyKeyup.ts @@ -0,0 +1,14 @@ +import { unsetShiftKeyDown } from '../redux/ephemeral/keyboard/actions' + +export default function bodyKeyup(store: any, event: KeyboardEvent) { + // there are event.code and event.key ... + // event.key is keyboard layout independent, so works for Dvorak users + switch (event.key) { + case 'Shift': + store.dispatch(unsetShiftKeyDown()) + break + default: + // console.log(event) + break + } +} diff --git a/web/src/event-listeners/canvasClick.ts b/web/src/event-listeners/canvasClick.ts new file mode 100644 index 00000000..0b436590 --- /dev/null +++ b/web/src/event-listeners/canvasClick.ts @@ -0,0 +1,75 @@ +import { + unsetCoordinate, + unsetOutcomes, + unsetContextMenu, +} from '../redux/ephemeral/mouse/actions' +import { + selectOutcome, + unselectAll, + selectConnection, + unselectOutcome, +} from '../redux/ephemeral/selection/actions' +import { RootState } from '../redux/reducer' +import { ComputedOutcome } from '../types' +import { ActionHashB64 } from '../types/shared' +import checkForOutcomeOrConnection, { + OutcomeConnectionOrBoth, +} from './helpers/checkForOutcomeOrConnection' + +export default function canvasClick( + store: any, + outcomes: { [actionHash: ActionHashB64]: ComputedOutcome }, + event: MouseEvent +) { + const state: RootState = store.getState() + // outcomesAddresses are Outcomes to be selected + const { + ui: { + mouse: { outcomesAddresses }, + }, + } = state + + if (outcomesAddresses) { + // finishing a drag box selection action + outcomesAddresses.forEach((value) => store.dispatch(selectOutcome(value))) + } else { + // check for Outcome or Connection at clicked location + // select it if so + const checks = checkForOutcomeOrConnection( + OutcomeConnectionOrBoth.Both, + state, + event.clientX, + event.clientY, + outcomes + ) + if (checks.connectionActionHash) { + store.dispatch(unselectAll()) + store.dispatch(selectConnection(checks.connectionActionHash)) + } else if (checks.outcomeActionHash) { + // if the shift key is being use, do an 'additive' select + // where you add the Outcome to the list of selected + if (!event.shiftKey) { + store.dispatch(unselectAll()) + } + // if using shift, and Outcome is already selected, unselect it + if ( + event.shiftKey && + state.ui.selection.selectedOutcomes.indexOf(checks.outcomeActionHash) > + -1 + ) { + store.dispatch(unselectOutcome(checks.outcomeActionHash)) + } else { + store.dispatch(selectOutcome(checks.outcomeActionHash)) + } + } else { + // If nothing was selected, that means empty + // spaces was clicked: deselect everything + store.dispatch(unselectAll()) + } + } + + // clear box selection vars + store.dispatch(unsetCoordinate()) + store.dispatch(unsetOutcomes()) + store.dispatch(unsetContextMenu()) +} diff --git a/web/src/event-listeners/canvasContextMenu.ts b/web/src/event-listeners/canvasContextMenu.ts new file mode 100644 index 00000000..68dcc94f --- /dev/null +++ b/web/src/event-listeners/canvasContextMenu.ts @@ -0,0 +1,33 @@ +import { setContextMenu } from '../redux/ephemeral/mouse/actions' +import { RootState } from '../redux/reducer' +import { ComputedOutcome } from '../types' +import { ActionHashB64 } from '../types/shared' +import checkForOutcomeOrConnection, { + OutcomeConnectionOrBoth, +} from './helpers/checkForOutcomeOrConnection' + +export default function canvasContextMenu( + store: any, + outcomes: { [actionHash: ActionHashB64]: ComputedOutcome }, + event: MouseEvent +) { + event.preventDefault() + const state: RootState = store.getState() + const checks = checkForOutcomeOrConnection( + OutcomeConnectionOrBoth.Outcome, + state, + event.clientX, + event.clientY, + outcomes + ) + // at this time, we are only displaying the ContextMenu if you + // right-clicked ON an Outcome + if (checks.outcomeActionHash) { + store.dispatch( + setContextMenu(checks.outcomeActionHash, { + x: event.clientX, + y: event.clientY, + }) + ) + } +} diff --git a/web/src/event-listeners/canvasDoubleclick.ts b/web/src/event-listeners/canvasDoubleclick.ts new file mode 100644 index 00000000..6e09a68e --- /dev/null +++ b/web/src/event-listeners/canvasDoubleclick.ts @@ -0,0 +1,50 @@ +import { coordsPageToCanvas } from '../drawing/coordinateSystems' +import { openExpandedView } from '../redux/ephemeral/expanded-view/actions' +import { openOutcomeForm } from '../redux/ephemeral/outcome-form/actions' +import { RootState } from '../redux/reducer' +import { ComputedOutcome } from '../types/outcome' +import { ActionHashB64 } from '../types/shared' +import checkForOutcomeOrConnection, { + OutcomeConnectionOrBoth, +} from './helpers/checkForOutcomeOrConnection' + +export default function canvasDoubleclick( + store: any, + outcomes: { [actionHash: ActionHashB64]: ComputedOutcome }, + event: MouseEvent +) { + const state: RootState = store.getState() + const { + ui: { + viewport: { translate, scale }, + }, + } = state + const checks = checkForOutcomeOrConnection( + OutcomeConnectionOrBoth.Outcome, + state, + event.clientX, + event.clientY, + outcomes + ) + if (checks.outcomeActionHash) { + store.dispatch(openExpandedView(checks.outcomeActionHash)) + } else { + const canvasPoint = coordsPageToCanvas( + { + x: event.clientX, + y: event.clientY, + }, + translate, + scale + ) + store.dispatch( + openOutcomeForm({ + leftConnectionXPosition: canvasPoint.x, + topConnectionYPosition: canvasPoint.y, + editAddress: null, + maybeLinkedOutcome: null, + existingParentConnectionAddress: null, + }) + ) + } +} diff --git a/web/src/event-listeners/canvasMousedown.ts b/web/src/event-listeners/canvasMousedown.ts new file mode 100644 index 00000000..4e0bef91 --- /dev/null +++ b/web/src/event-listeners/canvasMousedown.ts @@ -0,0 +1,28 @@ +import { coordsPageToCanvas } from "../drawing/coordinateSystems" +import { setMousedown, unsetContextMenu, setCoordinate } from "../redux/ephemeral/mouse/actions" +import { RootState } from "../redux/reducer" + + +export default function canvasMousedown(store: any, event: MouseEvent) { + const state: RootState = store.getState() + const { + ui: { + viewport: { translate, scale }, + }, + } = state + // don't set mouseDown if it's a right click + const RIGHT_CLICK_BUTTON = 2 + if (event.button !== RIGHT_CLICK_BUTTON) { + store.dispatch(setMousedown()) + store.dispatch(unsetContextMenu()) + const convertedCurrentMouse = coordsPageToCanvas( + { + x: event.clientX, + y: event.clientY, + }, + translate, + scale + ) + store.dispatch(setCoordinate(convertedCurrentMouse)) + } + } \ No newline at end of file diff --git a/web/src/event-listeners/canvasMousemove.ts b/web/src/event-listeners/canvasMousemove.ts new file mode 100644 index 00000000..bd875fa7 --- /dev/null +++ b/web/src/event-listeners/canvasMousemove.ts @@ -0,0 +1,186 @@ +import { coordsPageToCanvas } from '../drawing/coordinateSystems' +import { OUTCOME_VERTICAL_HOVER_ALLOWANCE } from '../drawing/dimensions' +import { checkForOutcomeAtCoordinatesInBox } from '../drawing/eventDetection' +import { + hoverConnection, + unhoverConnection, + hoverOutcome, + unhoverOutcome, +} from '../redux/ephemeral/hover/actions' +import { + setLiveCoordinate, + setClosestOutcome, + setOutcomes, +} from '../redux/ephemeral/mouse/actions' +import { + nearEdgePanning, + setOutcomeConnectorTo, +} from '../redux/ephemeral/outcome-connector/actions' +import { changeTranslate } from '../redux/ephemeral/viewport/actions' +import { RootState } from '../redux/reducer' +import { ComputedOutcome } from '../types' +import { ActionHashB64 } from '../types/shared' +import checkForOutcomeOrConnection, { + OutcomeConnectionOrBoth, +} from './helpers/checkForOutcomeOrConnection' +import closestOutcomeToPageCoord from './helpers/closestOutcome' + +// this method is being called super frequently, and is not performance optimized +// and seems to be dragging down the performance as a bottleneck. +export default function canvasMousemove( + store: any, + outcomes: { [actionHash: ActionHashB64]: ComputedOutcome }, + event: MouseEvent +) { + const state: RootState = store.getState() + const { + ui: { + viewport: { translate, scale }, + mouse: { + coordinate: { x: initialSelectX, y: initialSelectY }, + }, + layout: { + coordinates: outcomesCoordinates, + dimensions: outcomesDimensions, + }, + }, + } = state + + const convertedCurrentMouse = coordsPageToCanvas( + { + x: event.clientX, + y: event.clientY, + }, + translate, + scale + ) + store.dispatch(setLiveCoordinate(convertedCurrentMouse)) + + const closestOutcome = closestOutcomeToPageCoord( + convertedCurrentMouse, + outcomesCoordinates + ) + // store the closest outcome, if there is one + store.dispatch(setClosestOutcome(closestOutcome)) + + // this only is true if the CANVAS was clicked + // meaning it is not true if e.g. an OutcomeConnector html element + // was clicked + if (state.ui.mouse.mousedown) { + if (event.shiftKey) { + const outcomeActionHashesToSelect = checkForOutcomeAtCoordinatesInBox( + outcomesCoordinates, + outcomesDimensions, + outcomes, + convertedCurrentMouse, + { x: initialSelectX, y: initialSelectY } + ) + store.dispatch(setOutcomes(outcomeActionHashesToSelect)) + } else { + store.dispatch(changeTranslate(event.movementX, event.movementY)) + } + // return + } + + // for hover, we use OUTCOME_VERTICAL_HOVER_ALLOWANCE + // to make it so that the OutcomeConnector can display + // without glitchiness + const checks = checkForOutcomeOrConnection( + OutcomeConnectionOrBoth.Both, + state, + event.clientX, + event.clientY, + outcomes, + OUTCOME_VERTICAL_HOVER_ALLOWANCE + ) + if ( + checks.connectionActionHash && + state.ui.hover.hoveredConnection !== checks.connectionActionHash + ) { + store.dispatch(hoverConnection(checks.connectionActionHash)) + } else if (!checks.connectionActionHash && state.ui.hover.hoveredConnection) { + store.dispatch(unhoverConnection()) + } + + // edge-of-screen panning, during Outcome linking user action + + // check if 'near the edge of the screen', per a threshold + // Define the threshold in pixels + const threshold = 40 + // Get mouse position + const mouseX = event.clientX + const mouseY = event.clientY + // Get window dimensions + const windowWidth = window.innerWidth + const windowHeight = window.innerHeight + // Calculate distance from each edge + const distanceFromLeft = mouseX + const distanceFromRight = windowWidth - mouseX + const distanceFromTop = mouseY + const distanceFromBottom = windowHeight - mouseY + + const isNearEdge = + distanceFromLeft < threshold || + distanceFromRight < threshold || + distanceFromTop < threshold || + distanceFromBottom < threshold + // if near, then start panning + if ( + state.ui.outcomeConnector.maybeLinkedOutcome && + !state.ui.outcomeConnector.nearEdgePanning && + isNearEdge + ) { + // which direction to pan in X + const xSign = distanceFromLeft < threshold ? 1 : -1 + // which direction to pan in Y + const ySign = distanceFromTop < threshold ? 1 : -1 + + // how much to pan on each repition in X (in points) + const xAmount = + distanceFromLeft < threshold || distanceFromRight < threshold ? 8 : 0 + // how much to pan on each repition in Y (in points) + const yAmount = + distanceFromTop < threshold || distanceFromBottom < threshold ? 8 : 0 + + // how frequently in milliseconds to pan + const panFrequencyMilliseconds = 10 + const t = window.setInterval(() => { + store.dispatch( + changeTranslate(xSign * xAmount, ySign * yAmount, { + scale: state.ui.viewport.scale, + }) + ) + }, panFrequencyMilliseconds) + store.dispatch(nearEdgePanning(t)) + } + + if (state.ui.outcomeConnector.nearEdgePanning && !isNearEdge) { + // setting to false/undefined + window.clearInterval(state.ui.outcomeConnector.nearEdgePanning) + store.dispatch(nearEdgePanning()) + } + // if was near, and now no longer, then stop panning + + // outcome hover state, and unhover + // PLUS 'connection connector' + if ( + checks.outcomeActionHash && + state.ui.hover.hoveredOutcome !== checks.outcomeActionHash + ) { + store.dispatch(hoverOutcome(checks.outcomeActionHash)) + // hook up if the connection connector to a new Outcome + // if we are using the connection connector + // and IMPORTANTLY if Outcome is in the list of `validToAddresses` + if ( + state.ui.outcomeConnector.maybeLinkedOutcome && + state.ui.outcomeConnector.validToAddresses.includes( + checks.outcomeActionHash + ) + ) { + store.dispatch(setOutcomeConnectorTo(checks.outcomeActionHash)) + } + } else if (!checks.outcomeActionHash && state.ui.hover.hoveredOutcome) { + store.dispatch(unhoverOutcome()) + store.dispatch(setOutcomeConnectorTo(null)) + } +} diff --git a/web/src/event-listeners/canvasMouseup.ts b/web/src/event-listeners/canvasMouseup.ts new file mode 100644 index 00000000..7887dfbe --- /dev/null +++ b/web/src/event-listeners/canvasMouseup.ts @@ -0,0 +1,86 @@ +import { coordsPageToCanvas } from '../drawing/coordinateSystems' +import { unsetMousedown } from '../redux/ephemeral/mouse/actions' +import handleOutcomeConnectorMouseUp from '../redux/ephemeral/outcome-connector/handler' +import { nearEdgePanning } from '../redux/ephemeral/outcome-connector/actions' +import { openOutcomeForm } from '../redux/ephemeral/outcome-form/actions' +import { RootState } from '../redux/reducer' +import { LinkedOutcomeDetails } from '../types' +import { ActionHashB64, Option } from '../types/shared' + +export default function canvasMouseup(store: any, event: MouseEvent) { + const state: RootState = store.getState() + const { + maybeLinkedOutcome, + toAddress, + existingParentConnectionAddress, + } = state.ui.outcomeConnector + const { activeProject } = state.ui + + // if we are using the Connection Connector + if (maybeLinkedOutcome) { + // covers the case where we are hovered over an Outcome + // and thus making a connection to an existing Outcome + // AS WELL AS the case where we are not + // (to reset the connection connector) + handleOutcomeConnectorMouseUp( + maybeLinkedOutcome, + toAddress, + existingParentConnectionAddress, + activeProject, + store.dispatch + ) + // covers the case where we are not hovered over an Outcome + // and thus making a new Outcome and connection/Connection + if (!toAddress) { + // here we transfer the `maybeLinkedOutcome` from the Outcome Connector + // state over to the Outcome Form state + handleMouseUpForOutcomeForm({ + state, + event, + store, + maybeLinkedOutcome, + existingParentConnectionAddress, + }) + } + } + + if (state.ui.outcomeConnector.nearEdgePanning) { + window.clearInterval(state.ui.outcomeConnector.nearEdgePanning) + store.dispatch(nearEdgePanning()) + } + + // update the mouse aware state + store.dispatch(unsetMousedown()) +} + +function handleMouseUpForOutcomeForm({ + state, + event, + store, + maybeLinkedOutcome, + existingParentConnectionAddress, +}: { + state: RootState + event: MouseEvent + store: any // redux store, for the sake of dispatch + maybeLinkedOutcome: Option + existingParentConnectionAddress?: ActionHashB64 +}) { + const calcedPoint = coordsPageToCanvas( + { + x: event.clientX, + y: event.clientY, + }, + state.ui.viewport.translate, + state.ui.viewport.scale + ) + store.dispatch( + openOutcomeForm({ + topConnectionYPosition: calcedPoint.y, + leftConnectionXPosition: calcedPoint.x, + editAddress: null, + maybeLinkedOutcome, + existingParentConnectionAddress, + }) + ) +} diff --git a/web/src/event-listeners/canvasWheel.ts b/web/src/event-listeners/canvasWheel.ts new file mode 100644 index 00000000..2ca520ec --- /dev/null +++ b/web/src/event-listeners/canvasWheel.ts @@ -0,0 +1,35 @@ +import { unhoverOutcome } from "../redux/ephemeral/hover/actions" +import { MOUSE, TRACKPAD } from "../redux/ephemeral/local-preferences/reducer" +import { unsetContextMenu } from "../redux/ephemeral/mouse/actions" +import { changeScale, changeTranslate } from "../redux/ephemeral/viewport/actions" + + +export default function canvasWheel(store: any, event: WheelEvent) { + const state = store.getState() + const { + ui: { + localPreferences: { navigation }, + }, + } = state + if (!state.ui.outcomeForm.isOpen) { + store.dispatch(unhoverOutcome()) + store.dispatch(unsetContextMenu()) + // from https://medium.com/@auchenberg/detecting-multi-touch-trackpad-gestures-in-javascript-a2505babb10e + // and https://stackoverflow.com/questions/2916081/zoom-in-on-a-point-using-scale-and-translate + if (navigation === MOUSE || (navigation === TRACKPAD && event.ctrlKey)) { + // Normalize wheel to +1 or -1. + const wheel = event.deltaY < 0 ? 1 : -1 + const zoomIntensity = 0.07 // 0.05 + // Compute zoom factor. + const zoom = Math.exp(wheel * zoomIntensity) + const pageCoord = { x: event.clientX, y: event.clientY } + const instant = true + store.dispatch(changeScale(zoom, pageCoord, instant)) + } else { + // invert the pattern so that it uses new mac style + // of panning + store.dispatch(changeTranslate(-1 * event.deltaX, -1 * event.deltaY)) + } + } + event.preventDefault() + } \ No newline at end of file diff --git a/web/src/event-listeners/checkForOutcomeOrConnection.ts b/web/src/event-listeners/helpers/checkForOutcomeOrConnection.ts similarity index 89% rename from web/src/event-listeners/checkForOutcomeOrConnection.ts rename to web/src/event-listeners/helpers/checkForOutcomeOrConnection.ts index c4bfa549..1ee84706 100644 --- a/web/src/event-listeners/checkForOutcomeOrConnection.ts +++ b/web/src/event-listeners/helpers/checkForOutcomeOrConnection.ts @@ -1,10 +1,10 @@ import { checkForOutcomeAtCoordinates, checkForConnectionAtCoordinates, -} from '../drawing/eventDetection' -import { RootState } from '../redux/reducer' -import { ComputedOutcome } from '../types' -import { ActionHashB64 } from '../types/shared' +} from '../../drawing/eventDetection' +import { RootState } from '../../redux/reducer' +import { ComputedOutcome } from '../../types' +import { ActionHashB64 } from '../../types/shared' export enum OutcomeConnectionOrBoth { Both, diff --git a/web/src/event-listeners/cloneOutcomes.js b/web/src/event-listeners/helpers/cloneOutcomes.js similarity index 81% rename from web/src/event-listeners/cloneOutcomes.js rename to web/src/event-listeners/helpers/cloneOutcomes.js index 8fa69c0c..f4eb7088 100644 --- a/web/src/event-listeners/cloneOutcomes.js +++ b/web/src/event-listeners/helpers/cloneOutcomes.js @@ -1,10 +1,10 @@ -import { selectOutcome } from '../redux/ephemeral/selection/actions' -import { createOutcome } from '../redux/persistent/projects/outcomes/actions' -import { createOutcomeMember } from '../redux/persistent/projects/outcome-members/actions' +import { selectOutcome } from '../../redux/ephemeral/selection/actions' +import { createOutcome } from '../../redux/persistent/projects/outcomes/actions' +import { createOutcomeMember } from '../../redux/persistent/projects/outcome-members/actions' import moment from 'moment' -import ProjectsZomeApi from '../api/projectsApi' -import { getAppWs } from '../hcWebsockets' -import { cellIdFromString } from '../utils' +import ProjectsZomeApi from '../../api/projectsApi' +import { getAppWs } from '../../hcWebsockets' +import { cellIdFromString } from '../../utils' export default async function cloneOutcomes(store) { const state = store.getState() diff --git a/web/src/event-listeners/closestOutcome.ts b/web/src/event-listeners/helpers/closestOutcome.ts similarity index 93% rename from web/src/event-listeners/closestOutcome.ts rename to web/src/event-listeners/helpers/closestOutcome.ts index 58c904a8..01548a1f 100644 --- a/web/src/event-listeners/closestOutcome.ts +++ b/web/src/event-listeners/helpers/closestOutcome.ts @@ -1,4 +1,4 @@ -import { ActionHashB64 } from '../types/shared' +import { ActionHashB64 } from '../../types/shared' export default function closestOutcomeToPageCoord( canvasCoords: { x: number; y: number }, diff --git a/web/src/event-listeners/osPlatformHelper.ts b/web/src/event-listeners/helpers/osPlatformHelper.ts similarity index 100% rename from web/src/event-listeners/osPlatformHelper.ts rename to web/src/event-listeners/helpers/osPlatformHelper.ts diff --git a/web/src/event-listeners/index.ts b/web/src/event-listeners/index.ts index 162aec86..fd642c20 100644 --- a/web/src/event-listeners/index.ts +++ b/web/src/event-listeners/index.ts @@ -1,717 +1,63 @@ -import _ from 'lodash' -import { coordsPageToCanvas } from '../drawing/coordinateSystems' -import { checkForOutcomeAtCoordinatesInBox } from '../drawing/eventDetection' -import { - selectConnection, - selectOutcome, - unselectOutcome, - unselectAll, -} from '../redux/ephemeral/selection/actions' -import { - hoverOutcome, - unhoverOutcome, - hoverConnection, - unhoverConnection, -} from '../redux/ephemeral/hover/actions' -import { - setShiftKeyDown, - unsetShiftKeyDown, -} from '../redux/ephemeral/keyboard/actions' -import { - setMousedown, - unsetMousedown, - setLiveCoordinate, - setCoordinate, - unsetCoordinate, - unsetOutcomes, - setOutcomes, - setContextMenu, - unsetContextMenu, - setClosestOutcome, -} from '../redux/ephemeral/mouse/actions' -import { - openOutcomeForm, - closeOutcomeForm, -} from '../redux/ephemeral/outcome-form/actions' -import { deleteOutcomeFully } from '../redux/persistent/projects/outcomes/actions' -import { setScreenDimensions } from '../redux/ephemeral/screensize/actions' -import { - changeTranslate, - changeScale, - animatePanAndZoom, -} from '../redux/ephemeral/viewport/actions' -import { - closeExpandedView, - openExpandedView, -} from '../redux/ephemeral/expanded-view/actions' -import { - COORDINATES, - MOUSE, - TRACKPAD, -} from '../redux/ephemeral/local-preferences/reducer' -import { setOutcomeClone } from '../redux/ephemeral/outcome-clone/actions' -import cloneOutcomes from './cloneOutcomes' -import { - resetOutcomeConnector, - setOutcomeConnectorTo, -} from '../redux/ephemeral/outcome-connector/actions' -import ProjectsZomeApi from '../api/projectsApi' -import { getAppWs } from '../hcWebsockets' -import { cellIdFromString } from '../utils' -import { triggerUpdateLayout } from '../redux/ephemeral/layout/actions' -import { - deleteConnection, -} from '../redux/persistent/projects/connections/actions' -import { ActionHashB64, Option } from '../types/shared' -import { ComputedOutcome, LinkedOutcomeDetails } from '../types' -import { RootState } from '../redux/reducer' -import { - findChildrenActionHashes, - findParentsActionHashes, - findSiblingActionHash, - RightOrLeft, -} from '../tree-logic' -import { OUTCOME_VERTICAL_HOVER_ALLOWANCE } from '../drawing/dimensions' -import checkForOutcomeOrConnection, { - OutcomeConnectionOrBoth, -} from './checkForOutcomeOrConnection' -import closestOutcomeToPageCoord from './closestOutcome' -import { - setNavModalOpenChildren, - setNavModalOpenParents, -} from '../redux/ephemeral/navigation-modal/actions' -import { - alterSiblingOrder, -} from '../connections' -import handleOutcomeConnectorMouseUp from '../redux/ephemeral/outcome-connector/handler' -import checkForKeyboardKeyModifier from './osPlatformHelper' - - -function handleMouseUpForOutcomeForm({ - state, - event, - store, - maybeLinkedOutcome, - existingParentConnectionAddress, -}: { - state: RootState - event: MouseEvent - store: any // redux store, for the sake of dispatch - maybeLinkedOutcome: Option - existingParentConnectionAddress?: ActionHashB64 -}) { - const calcedPoint = coordsPageToCanvas( - { - x: event.clientX, - y: event.clientY, - }, - state.ui.viewport.translate, - state.ui.viewport.scale - ) - store.dispatch( - openOutcomeForm({ - topConnectionYPosition: calcedPoint.y, - leftConnectionXPosition: calcedPoint.x, - editAddress: null, - maybeLinkedOutcome, - existingParentConnectionAddress - }) - ) -} - -function leftMostOutcome( - outcomeActionHashes: ActionHashB64[], - state: RootState -): ActionHashB64 { - return _.minBy(outcomeActionHashes, (actionHash) => { - return state.ui.layout.coordinates[actionHash].x - }) -} - +import { ActionHashB64 } from '../types/shared' +import { ComputedOutcome} from '../types' +import windowResize from './windowResize' +import bodyKeydown from './bodyKeydown' +import bodyKeyup from './bodyKeyup' +import canvasMousemove from './canvasMousemove' +import canvasWheel from './canvasWheel' +import canvasClick from './canvasClick' +import canvasMousedown from './canvasMousedown' +import canvasMouseup from './canvasMouseup' +import canvasDoubleclick from './canvasDoubleclick' +import canvasContextMenu from './canvasContextMenu' + +// This function is called within a useEffect and that's why it follows the same +// pattern as useEffects, which is to return an unsubscribe/cleanup function. // outcomes is ComputedOutcomes in an object, keyed by their actionHash export default function setupEventListeners( store: any, canvas: HTMLCanvasElement, outcomes: { [actionHash: ActionHashB64]: ComputedOutcome } ) { - function windowResize() { - // Get the device pixel ratio, falling back to 1. - const dpr = window.devicePixelRatio || 1 - // Get the size of the canvas in CSS pixels. - const rect = canvas.getBoundingClientRect() - // Give the canvas pixel dimensions of their CSS - // size * the device pixel ratio. - store.dispatch(setScreenDimensions(rect.width * dpr, rect.height * dpr)) - } - - function canPerformKeyboardAction(state: RootState): boolean { - return ( - state.ui.selection.selectedOutcomes.length === 1 && - !state.ui.outcomeForm.isOpen && - !state.ui.expandedView.isOpen && - !state.ui.navigationModal.open - ) - } - - function getKeyboardNavigationPreference(state: RootState): string { - return state.ui.localPreferences.keyboardNavigation - } - - function panAndZoom(actionHash: string) { - store.dispatch(animatePanAndZoom(actionHash, false)) - } - - async function bodyKeydown(event: KeyboardEvent) { - const appWebsocket = await getAppWs() - const projectsZomeApi = new ProjectsZomeApi(appWebsocket) - let state: RootState = store.getState() - const { - ui: { activeProject }, - } = state - const cellId = cellIdFromString(activeProject) - // there are event.code and event.key ... - // event.key is keyboard layout independent, so works for Dvorak users - switch (event.key) { - case 'Enter': - if (canPerformKeyboardAction(state)) { - event.stopPropagation() - store.dispatch( - openExpandedView(state.ui.selection.selectedOutcomes[0]) - ) - } - - break - - // Used for navigating to a child - case 'ArrowDown': - if (canPerformKeyboardAction(state)) { - const selectedOutcome = state.ui.selection.selectedOutcomes[0] - - const childrenActionHashes = findChildrenActionHashes( - selectedOutcome, - state - ) - if (childrenActionHashes.length) { - event.stopPropagation() - const keyboardNavPreference = getKeyboardNavigationPreference(state) - if (childrenActionHashes.length === 1) { - panAndZoom(childrenActionHashes[0]) - } else if (keyboardNavPreference === COORDINATES) { - // navigate to the left-most child - const leftMostChild = leftMostOutcome(childrenActionHashes, state) - panAndZoom(leftMostChild) - } else { - store.dispatch(setNavModalOpenChildren(childrenActionHashes)) - } - } - } - break - - // Used for navigating to a parent - case 'ArrowUp': - if (canPerformKeyboardAction(state)) { - const selectedOutcome = state.ui.selection.selectedOutcomes[0] - const parentsActionHashes = findParentsActionHashes( - selectedOutcome, - state - ) - if (parentsActionHashes.length) { - event.stopPropagation() - const keyboardNavPreference = getKeyboardNavigationPreference(state) - if (parentsActionHashes.length === 1) { - panAndZoom(parentsActionHashes[0]) - } else if (keyboardNavPreference === COORDINATES) { - // navigate to the left most parent - const leftMostParent = leftMostOutcome(parentsActionHashes, state) - panAndZoom(leftMostParent) - } else { - store.dispatch(setNavModalOpenParents(parentsActionHashes)) - } - } - } - - break - - // Used for navigating to the left sibling - case 'ArrowLeft': - if (canPerformKeyboardAction(state)) { - const selectedOutcome = state.ui.selection.selectedOutcomes[0] - const targetActionHash = findSiblingActionHash( - selectedOutcome, - state, - RightOrLeft.Left - ) - if (event.shiftKey && targetActionHash) { - // only do this if selected outcome has a left sibling - // move the selected outcome to the left side of the left sibling - // (swap positions with the left sibling) - alterSiblingOrder( - store, - state, - selectedOutcome, - targetActionHash, - RightOrLeft.Left - ) - } else if (targetActionHash) { - // select and pan and zoom to - // the parent - store.dispatch(animatePanAndZoom(targetActionHash, false)) - } - } - break - - // Used for navigating to the right sibling - case 'ArrowRight': - if (canPerformKeyboardAction(state)) { - const selectedOutcome = state.ui.selection.selectedOutcomes[0] - const targetActionHash = findSiblingActionHash( - selectedOutcome, - state, - RightOrLeft.Right - ) - if (event.shiftKey && targetActionHash) { - // only do this if selected outcome has a right sibling - // move the selected outcome to the right side of the right sibling - // (swap positions with the right sibling) - alterSiblingOrder( - store, - state, - selectedOutcome, - targetActionHash, - RightOrLeft.Right - ) - } else if (targetActionHash) { - // select and pan and zoom to - // the parent - store.dispatch(animatePanAndZoom(targetActionHash, false)) - } - } - break - - // Used in multi selecting Outcomes - case 'Shift': - store.dispatch(setShiftKeyDown()) - break - case 'Escape': - // Only unselect all Outcomes if the expanded view - // is not open - if (!state.ui.expandedView.isOpen && !state.ui.navigationModal.open) { - store.dispatch(unselectAll()) - } - store.dispatch(closeOutcomeForm()) - store.dispatch(resetOutcomeConnector()) - break - case 'Backspace': - let selection = state.ui.selection - // only dispatch if something's selected and the OutcomeForm and ExpandedView are - // not open - if ( - selection.selectedConnections.length > 0 && - !state.ui.outcomeForm.isOpen && - !state.ui.expandedView.isOpen - ) { - // if on firefox, and matched this case - // prevent the browser from navigating back to the last page - event.preventDefault() - for await (const connection of selection.selectedConnections) { - await projectsZomeApi.connection.delete(cellId, connection) - store.dispatch(deleteConnection(activeProject, connection)) - // this action will trigger a recalc - // and layout animation update, which is natural in this context. - // we have to trigger it manually because there is a scenario where - // deleteConnection should NOT trigger a layout recalc - store.dispatch(triggerUpdateLayout()) - } - } else if ( - selection.selectedOutcomes.length > 0 && - !state.ui.outcomeForm.isOpen && - !state.ui.expandedView.isOpen - ) { - // if on firefox, and matched this case - // prevent the browser from navigating back to the last page - event.preventDefault() - for await (const outcome of selection.selectedOutcomes) { - const fullyDeletedOutcome = await projectsZomeApi.outcome.deleteOutcomeFully( - cellId, - outcome - ) - store.dispatch( - deleteOutcomeFully(activeProject, fullyDeletedOutcome) - ) - } - } - break - case 'c': - if ( - checkForKeyboardKeyModifier(event) && - state.ui.selection.selectedOutcomes.length && - !state.ui.outcomeForm.isOpen && - !state.ui.expandedView.isOpen - ) { - store.dispatch(setOutcomeClone(state.ui.selection.selectedOutcomes)) - } - break - case 'v': - if ( - checkForKeyboardKeyModifier(event) && - state.ui.outcomeClone.outcomes.length && - !state.ui.outcomeForm.isOpen && - !state.ui.expandedView.isOpen - ) { - cloneOutcomes(store) - } - break - default: - // console.log(event) - break - } - // console.log(event) - } - - function bodyKeyup(event) { - // there are event.code and event.key ... - // event.key is keyboard layout independent, so works for Dvorak users - switch (event.key) { - case 'Shift': - store.dispatch(unsetShiftKeyDown()) - break - default: - // console.log(event) - break - } - } - - // this method is being called super frequently, and is not performance optimized - // and seems to be dragging down the performance as a bottleneck. - function canvasMousemove(event: MouseEvent) { - const state: RootState = store.getState() - const { - ui: { - viewport: { translate, scale }, - mouse: { - coordinate: { x: initialSelectX, y: initialSelectY }, - }, - layout: { - coordinates: outcomesCoordinates, - dimensions: outcomesDimensions, - }, - }, - } = state - - const convertedCurrentMouse = coordsPageToCanvas( - { - x: event.clientX, - y: event.clientY, - }, - translate, - scale - ) - store.dispatch(setLiveCoordinate(convertedCurrentMouse)) - - const closestOutcome = closestOutcomeToPageCoord( - convertedCurrentMouse, - outcomesCoordinates - ) - // store the closest outcome, if there is one - store.dispatch(setClosestOutcome(closestOutcome)) - - // this only is true if the CANVAS was clicked - // meaning it is not true if e.g. an OutcomeConnector html element - // was clicked - if (state.ui.mouse.mousedown) { - if (event.shiftKey) { - const outcomeActionHashesToSelect = checkForOutcomeAtCoordinatesInBox( - outcomesCoordinates, - outcomesDimensions, - outcomes, - convertedCurrentMouse, - { x: initialSelectX, y: initialSelectY } - ) - store.dispatch(setOutcomes(outcomeActionHashesToSelect)) - } else { - store.dispatch(changeTranslate(event.movementX, event.movementY)) - } - return - } - - // for hover, we use OUTCOME_VERTICAL_HOVER_ALLOWANCE - // to make it so that the OutcomeConnector can display - // without glitchiness - const checks = checkForOutcomeOrConnection( - OutcomeConnectionOrBoth.Both, - state, - event.clientX, - event.clientY, - outcomes, - OUTCOME_VERTICAL_HOVER_ALLOWANCE - ) - if ( - checks.connectionActionHash && - state.ui.hover.hoveredConnection !== checks.connectionActionHash - ) { - store.dispatch(hoverConnection(checks.connectionActionHash)) - } else if ( - !checks.connectionActionHash && - state.ui.hover.hoveredConnection - ) { - store.dispatch(unhoverConnection()) - } - - if ( - checks.outcomeActionHash && - state.ui.hover.hoveredOutcome !== checks.outcomeActionHash - ) { - store.dispatch(hoverOutcome(checks.outcomeActionHash)) - // hook up if the connection connector to a new Outcome - // if we are using the connection connector - // and IMPORTANTLY if Outcome is in the list of `validToAddresses` - if ( - state.ui.outcomeConnector.maybeLinkedOutcome && - state.ui.outcomeConnector.validToAddresses.includes( - checks.outcomeActionHash - ) - ) { - store.dispatch(setOutcomeConnectorTo(checks.outcomeActionHash)) - } - } else if (!checks.outcomeActionHash && state.ui.hover.hoveredOutcome) { - store.dispatch(unhoverOutcome()) - store.dispatch(setOutcomeConnectorTo(null)) - } - } - - function canvasWheel(event: WheelEvent) { - const state = store.getState() - const { - ui: { - localPreferences: { navigation }, - }, - } = state - if (!state.ui.outcomeForm.isOpen) { - store.dispatch(unhoverOutcome()) - store.dispatch(unsetContextMenu()) - // from https://medium.com/@auchenberg/detecting-multi-touch-trackpad-gestures-in-javascript-a2505babb10e - // and https://stackoverflow.com/questions/2916081/zoom-in-on-a-point-using-scale-and-translate - if (navigation === MOUSE || (navigation === TRACKPAD && event.ctrlKey)) { - // Normalize wheel to +1 or -1. - const wheel = event.deltaY < 0 ? 1 : -1 - const zoomIntensity = 0.07 // 0.05 - // Compute zoom factor. - const zoom = Math.exp(wheel * zoomIntensity) - const pageCoord = { x: event.clientX, y: event.clientY } - const instant = true - store.dispatch(changeScale(zoom, pageCoord, instant)) - } else { - // invert the pattern so that it uses new mac style - // of panning - store.dispatch(changeTranslate(-1 * event.deltaX, -1 * event.deltaY)) - } - } - event.preventDefault() - } - - function canvasClick(event: MouseEvent) { - const state: RootState = store.getState() - // outcomesAddresses are Outcomes to be selected - const { - ui: { - mouse: { outcomesAddresses }, - }, - } = state - - if (outcomesAddresses) { - // finishing a drag box selection action - outcomesAddresses.forEach((value) => store.dispatch(selectOutcome(value))) - } else { - // check for Outcome or Connection at clicked location - // select it if so - const checks = checkForOutcomeOrConnection( - OutcomeConnectionOrBoth.Both, - state, - event.clientX, - event.clientY, - outcomes - ) - if (checks.connectionActionHash) { - store.dispatch(unselectAll()) - store.dispatch(selectConnection(checks.connectionActionHash)) - } else if (checks.outcomeActionHash) { - // if the shift key is being use, do an 'additive' select - // where you add the Outcome to the list of selected - if (!event.shiftKey) { - store.dispatch(unselectAll()) - } - // if using shift, and Outcome is already selected, unselect it - if ( - event.shiftKey && - state.ui.selection.selectedOutcomes.indexOf( - checks.outcomeActionHash - ) > -1 - ) { - store.dispatch(unselectOutcome(checks.outcomeActionHash)) - } else { - store.dispatch(selectOutcome(checks.outcomeActionHash)) - } - } else { - // If nothing was selected, that means empty - // spaces was clicked: deselect everything - store.dispatch(unselectAll()) - } - } - - // clear box selection vars - store.dispatch(unsetCoordinate()) - store.dispatch(unsetOutcomes()) - store.dispatch(unsetContextMenu()) - } - - function canvasMousedown(event: MouseEvent) { - const state: RootState = store.getState() - const { - ui: { - viewport: { translate, scale }, - }, - } = state - // don't set mouseDown if it's a right click - const RIGHT_CLICK_BUTTON = 2 - if (event.button !== RIGHT_CLICK_BUTTON) { - store.dispatch(setMousedown()) - store.dispatch(unsetContextMenu()) - const convertedCurrentMouse = coordsPageToCanvas( - { - x: event.clientX, - y: event.clientY, - }, - translate, - scale - ) - store.dispatch(setCoordinate(convertedCurrentMouse)) - } - } - - function canvasMouseup(event: MouseEvent) { - const state: RootState = store.getState() - const { - maybeLinkedOutcome, - toAddress, - existingParentConnectionAddress, - } = state.ui.outcomeConnector - const { activeProject } = state.ui - - // if we are using the Connection Connector - if (maybeLinkedOutcome) { - // covers the case where we are hovered over an Outcome - // and thus making a connection to an existing Outcome - // AS WELL AS the case where we are not - // (to reset the connection connector) - handleOutcomeConnectorMouseUp( - maybeLinkedOutcome, - toAddress, - existingParentConnectionAddress, - activeProject, - store.dispatch - ) - // covers the case where we are not hovered over an Outcome - // and thus making a new Outcome and connection/Connection - if (!toAddress) { - // here we transfer the `maybeLinkedOutcome` from the Outcome Connector - // state over to the Outcome Form state - handleMouseUpForOutcomeForm({ - state, - event, - store, - maybeLinkedOutcome, - existingParentConnectionAddress, - }) - } - } - - // update the mouse aware state - store.dispatch(unsetMousedown()) - } - - // DOUBLE CLICK - function canvasDoubleclick(event: MouseEvent) { - const state: RootState = store.getState() - const { - ui: { - viewport: { translate, scale }, - }, - } = state - const checks = checkForOutcomeOrConnection( - OutcomeConnectionOrBoth.Outcome, - state, - event.clientX, - event.clientY, - outcomes - ) - if (checks.outcomeActionHash) { - store.dispatch(openExpandedView(checks.outcomeActionHash)) - } else { - const canvasPoint = coordsPageToCanvas( - { - x: event.clientX, - y: event.clientY, - }, - translate, - scale - ) - store.dispatch(openOutcomeForm({ - leftConnectionXPosition: canvasPoint.x, - topConnectionYPosition: canvasPoint.y, - editAddress: null, - maybeLinkedOutcome: null, - existingParentConnectionAddress: null, - })) - } - } - - function canvasContextMenu(event: MouseEvent) { - event.preventDefault() - const state: RootState = store.getState() - const checks = checkForOutcomeOrConnection( - OutcomeConnectionOrBoth.Outcome, - state, - event.clientX, - event.clientY, - outcomes - ) - // at this time, we are only displaying the ContextMenu if you - // right-clicked ON an Outcome - if (checks.outcomeActionHash) { - store.dispatch( - setContextMenu(checks.outcomeActionHash, { - x: event.clientX, - y: event.clientY, - }) - ) - } - } - - window.addEventListener('resize', windowResize) - document.body.addEventListener('keydown', bodyKeydown) - document.body.addEventListener('keyup', bodyKeyup) - canvas.addEventListener('mousemove', canvasMousemove) - canvas.addEventListener('wheel', canvasWheel) - canvas.addEventListener('mousedown', canvasMousedown) - canvas.addEventListener('mouseup', canvasMouseup) - canvas.addEventListener('dblclick', canvasDoubleclick) + // prepare the event listeners + const onWindowResize = windowResize.bind(null, store, canvas) + const onBodyKeydown = bodyKeydown.bind(null, store) + const onBodyKeyup = bodyKeyup.bind(null, store) + const onCanvasMousemove = canvasMousemove.bind(null, store, outcomes) + const onCanvasWheel = canvasWheel.bind(null, store) + const onCanvasClick = canvasClick.bind(null, store, outcomes) + const onCanvasMousedown = canvasMousedown.bind(null, store) + const onCanvasMouseup = canvasMouseup.bind(null, store) + const onCanvasDoubleclick = canvasDoubleclick.bind(null, store, outcomes) + const onCanvasContextMenu = canvasContextMenu.bind(null, store, outcomes) + + // attach the event listeners + window.addEventListener('resize', onWindowResize) + document.body.addEventListener('keydown', onBodyKeydown) + document.body.addEventListener('keyup', onBodyKeyup) + canvas.addEventListener('mousemove', onCanvasMousemove) + canvas.addEventListener('wheel', onCanvasWheel) + canvas.addEventListener('mousedown', onCanvasMousedown) + canvas.addEventListener('mouseup', onCanvasMouseup) + canvas.addEventListener('dblclick', onCanvasDoubleclick) // This listener is bound to the canvas only so clicks on other parts of // the UI like the OutcomeForm won't trigger it. - canvas.addEventListener('click', canvasClick) - canvas.addEventListener('contextmenu', canvasContextMenu) + canvas.addEventListener('click', onCanvasClick) + canvas.addEventListener('contextmenu', onCanvasContextMenu) + // return a function that will remove the event listeners return function cleanup() { - window.removeEventListener('resize', windowResize) - document.body.removeEventListener('keydown', bodyKeydown) - document.body.removeEventListener('keyup', bodyKeyup) - canvas.removeEventListener('mousemove', canvasMousemove) - canvas.removeEventListener('wheel', canvasWheel) - canvas.removeEventListener('mousedown', canvasMousedown) - canvas.removeEventListener('mouseup', canvasMouseup) - canvas.removeEventListener('dblclick', canvasDoubleclick) + window.removeEventListener('resize', onWindowResize) + document.body.removeEventListener('keydown', onBodyKeydown) + document.body.removeEventListener('keyup', onBodyKeyup) + canvas.removeEventListener('mousemove', onCanvasMousemove) + canvas.removeEventListener('wheel', onCanvasWheel) + canvas.removeEventListener('mousedown', onCanvasMousedown) + canvas.removeEventListener('mouseup', onCanvasMouseup) + canvas.removeEventListener('dblclick', onCanvasDoubleclick) // This listener is bound to the canvas only so clicks on other parts of // the UI like the OutcomeForm won't trigger it. - canvas.removeEventListener('click', canvasClick) - canvas.removeEventListener('contextmenu', canvasContextMenu) + canvas.removeEventListener('click', onCanvasClick) + canvas.removeEventListener('contextmenu', onCanvasContextMenu) } } diff --git a/web/src/event-listeners/windowResize.ts b/web/src/event-listeners/windowResize.ts new file mode 100644 index 00000000..dd0f3494 --- /dev/null +++ b/web/src/event-listeners/windowResize.ts @@ -0,0 +1,11 @@ +import { setScreenDimensions } from '../redux/ephemeral/screensize/actions' + +export default function windowResize(store: any, canvas: HTMLCanvasElement) { + // Get the device pixel ratio, falling back to 1. + const dpr = window.devicePixelRatio || 1 + // Get the size of the canvas in CSS pixels. + const rect = canvas.getBoundingClientRect() + // Give the canvas pixel dimensions of their CSS + // size * the device pixel ratio. + store.dispatch(setScreenDimensions(rect.width * dpr, rect.height * dpr)) +} diff --git a/web/src/hooks/useContainWithinScreen.ts b/web/src/hooks/useContainWithinScreen.ts new file mode 100644 index 00000000..5747175d --- /dev/null +++ b/web/src/hooks/useContainWithinScreen.ts @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' + +export default function useContainWithinScreen({ + cursorCoordinate, + initialWidth, + initialHeight, +}: { + cursorCoordinate: { x: number; y: number } + initialWidth: number + initialHeight: number +}) { + // make the code below general purpose for any popup div that needs to be contained within the screen + // the popup div will be rendered at the mouse click coordinate + + const [initialized, setInitialized] = useState(false) + const [itemWidth, setItemWidth] = useState(initialWidth) + // with useState store the height of the menu + const [itemHeight, setItemHeight] = useState(initialHeight) + const [renderCoordinate, setRenderCoordinate] = useState({ + x: cursorCoordinate.x, + y: cursorCoordinate.y, + }) + + // set menu width in pixels + // const menuWidth = 176 + + // when the item height changes, determine weather to show the item above or below the mouse + // if the menu will go off the screen, move it up so that it is fully visible + + useEffect(() => { + if (itemHeight === 0) { + return + } + // if both x and y are off the screen, move the item up and left + setInitialized(true) + // the amount that it (maybe) exceeds the screen width + const overflowX = window.innerWidth - (cursorCoordinate.x + itemWidth) + // the amount that it (maybe) exceeds the screen height + const overflowY = window.innerHeight - (cursorCoordinate.y + itemHeight) + setRenderCoordinate({ + x: cursorCoordinate.x + Math.min(overflowX, 0), + y: cursorCoordinate.y + Math.min(overflowY, 0), + }) + }, [itemWidth, itemHeight, cursorCoordinate.x, cursorCoordinate.y]) + + return { + initialized, + itemWidth, + itemHeight, + setItemWidth, + setItemHeight, + renderCoordinate, + } +} diff --git a/web/src/images/collapse.svg b/web/src/images/collapse.svg new file mode 100644 index 00000000..0af540be --- /dev/null +++ b/web/src/images/collapse.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/web/src/images/expand.svg b/web/src/images/expand.svg index f327a0c2..0ecc9fe2 100644 --- a/web/src/images/expand.svg +++ b/web/src/images/expand.svg @@ -1 +1,8 @@ - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/web/src/images/expand2.svg b/web/src/images/expand2.svg new file mode 100644 index 00000000..928589dc --- /dev/null +++ b/web/src/images/expand2.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/web/src/redux/ephemeral/active-entry-points/actions.js b/web/src/redux/ephemeral/active-entry-points/actions.ts similarity index 76% rename from web/src/redux/ephemeral/active-entry-points/actions.js rename to web/src/redux/ephemeral/active-entry-points/actions.ts index c92ea7f9..7f293ffa 100644 --- a/web/src/redux/ephemeral/active-entry-points/actions.js +++ b/web/src/redux/ephemeral/active-entry-points/actions.ts @@ -5,11 +5,13 @@ that can be taken within that feature. */ +import { ActionHashB64 } from '../../../types/shared' + const SET_ACTIVE_ENTRY_POINTS = 'SET_ACTIVE_ENTRY_POINTS' /* action creator functions */ -const setActiveEntryPoints = (entryPointAddresses) => { +const setActiveEntryPoints = (entryPointAddresses: ActionHashB64[]) => { return { type: SET_ACTIVE_ENTRY_POINTS, payload: entryPointAddresses, diff --git a/web/src/redux/ephemeral/active-entry-points/reducer.js b/web/src/redux/ephemeral/active-entry-points/reducer.ts similarity index 56% rename from web/src/redux/ephemeral/active-entry-points/reducer.js rename to web/src/redux/ephemeral/active-entry-points/reducer.ts index fe0c3c94..841c7b78 100644 --- a/web/src/redux/ephemeral/active-entry-points/reducer.js +++ b/web/src/redux/ephemeral/active-entry-points/reducer.ts @@ -1,10 +1,10 @@ import _ from 'lodash' - import { SET_ACTIVE_ENTRY_POINTS } from './actions' +import { ActionHashB64 } from '../../../types/shared' -const defaultState = [] +const defaultState: ActionHashB64[] = [] -export default function (state = defaultState, action) { +export default function (state = defaultState, action: any): ActionHashB64[] { const { payload, type } = action switch (type) { case SET_ACTIVE_ENTRY_POINTS: diff --git a/web/src/redux/ephemeral/mouse/reducer.ts b/web/src/redux/ephemeral/mouse/reducer.ts index 7bbcbe75..4695fb8c 100644 --- a/web/src/redux/ephemeral/mouse/reducer.ts +++ b/web/src/redux/ephemeral/mouse/reducer.ts @@ -1,5 +1,6 @@ +import { coordsPageToCanvas } from '../../../drawing/coordinateSystems' import { ActionHashB64 } from '../../../types/shared' -import { CHANGE_SCALE } from '../viewport/actions' +import { CHANGE_SCALE, CHANGE_TRANSLATE } from '../viewport/actions' import { SET_MOUSEDOWN, UNSET_MOUSEDOWN, @@ -89,6 +90,22 @@ export default function (state = defaultState, action: any): MouseState { ...state, mousedown: false, } + case CHANGE_TRANSLATE: + if (!payload.meta) { + return state + } + const adjusted = coordsPageToCanvas({ x: payload.x, y: payload.y }, { x: 0, y: 0 }, payload.meta.scale) + return { + ...state, + liveCoordinate: { + // this is the opposite of what's in + // web/src/redux/ephemeral/viewport/reducer.ts + // for CHANGE_TRANSLATE + // because we're assuming that the mouse is not moving, but the 'background' is + x: state.liveCoordinate.x - adjusted.x, + y: state.liveCoordinate.y - adjusted.y, + }, + } case SET_LIVE_COORDINATE: return { ...state, diff --git a/web/src/redux/ephemeral/outcome-connector/actions.ts b/web/src/redux/ephemeral/outcome-connector/actions.ts index a2fdd29e..231112cb 100644 --- a/web/src/redux/ephemeral/outcome-connector/actions.ts +++ b/web/src/redux/ephemeral/outcome-connector/actions.ts @@ -4,11 +4,12 @@ import { ActionHashB64, Option } from '../../../types/shared' const SET_CONNECTION_CONNECTOR_FROM = 'SET_CONNECTION_CONNECTOR_FROM' const SET_CONNECTION_CONNECTOR_TO = 'SET_CONNECTION_CONNECTOR_TO' const RESET_CONNECTION_CONNECTOR = 'RESET_CONNECTION_CONNECTOR' +const NEAR_EDGE_PANNING = 'NEAR_EDGE_PANNING' export type OutcomeConnectorFromPayload = { maybeLinkedOutcome: Option validToAddresses: ActionHashB64[] - existingParentConnectionAddress: ActionHashB64 + existingParentConnectionAddress: Option } function setOutcomeConnectorFrom(payload: OutcomeConnectorFromPayload) { @@ -25,6 +26,15 @@ function setOutcomeConnectorTo(actionHash: ActionHashB64) { } } +// payload is a window.setInterval ID +// or else is undefined +function nearEdgePanning(payload?: number) { + return { + type: NEAR_EDGE_PANNING, + payload, + } +} + function resetOutcomeConnector() { return { type: RESET_CONNECTION_CONNECTOR, @@ -35,7 +45,9 @@ export { SET_CONNECTION_CONNECTOR_FROM, SET_CONNECTION_CONNECTOR_TO, RESET_CONNECTION_CONNECTOR, + NEAR_EDGE_PANNING, setOutcomeConnectorFrom, setOutcomeConnectorTo, resetOutcomeConnector, + nearEdgePanning, } diff --git a/web/src/redux/ephemeral/outcome-connector/reducer.ts b/web/src/redux/ephemeral/outcome-connector/reducer.ts index e281fca1..308ed28b 100644 --- a/web/src/redux/ephemeral/outcome-connector/reducer.ts +++ b/web/src/redux/ephemeral/outcome-connector/reducer.ts @@ -4,23 +4,30 @@ import { SET_CONNECTION_CONNECTOR_FROM, SET_CONNECTION_CONNECTOR_TO, RESET_CONNECTION_CONNECTOR, + NEAR_EDGE_PANNING, OutcomeConnectorFromPayload, } from './actions' export type ConnectionConnectorState = { + // the Outcome being linked from, the "source" maybeLinkedOutcome: Option + // Outcomes that it is valid for the "source" Outcome to be linked to validToAddresses: ActionHashB64[] - toAddress: ActionHashB64 + // the Outcome to be linked to, if the user completes + // the action, the "target" + toAddress: Option // existingParentConnectionAddress is the actionHash of the Connection that // we would delete, if any, while creating the new one - existingParentConnectionAddress: ActionHashB64 + existingParentConnectionAddress: Option + nearEdgePanning: Option } const defaultState: ConnectionConnectorState = { maybeLinkedOutcome: null, validToAddresses: [], toAddress: null, - existingParentConnectionAddress: null + existingParentConnectionAddress: null, + nearEdgePanning: null } export default function reducer(state = defaultState, action: any): ConnectionConnectorState { @@ -36,6 +43,11 @@ export default function reducer(state = defaultState, action: any): ConnectionCo ...state, toAddress: payload as ActionHashB64, } + case NEAR_EDGE_PANNING: + return { + ...state, + nearEdgePanning: payload as Option + } case RESET_CONNECTION_CONNECTOR: return defaultState default: diff --git a/web/src/redux/ephemeral/viewport/actions.ts b/web/src/redux/ephemeral/viewport/actions.ts index 1cdbcc92..3c2d59ba 100644 --- a/web/src/redux/ephemeral/viewport/actions.ts +++ b/web/src/redux/ephemeral/viewport/actions.ts @@ -40,12 +40,13 @@ function animatePanAndZoom( } } -function changeTranslate(x: number, y: number) { +function changeTranslate(x: number, y: number, meta?: { scale: number }) { return { type: CHANGE_TRANSLATE, payload: { x, y, + meta }, } } diff --git a/web/src/routes/ProjectView/MapView/MapView.component.tsx b/web/src/routes/ProjectView/MapView/MapView.component.tsx index d0fefba5..751567df 100644 --- a/web/src/routes/ProjectView/MapView/MapView.component.tsx +++ b/web/src/routes/ProjectView/MapView/MapView.component.tsx @@ -122,13 +122,7 @@ const MapView: React.FC = ({ useEffect(() => { const canvas = refCanvas.current if (projectId && renderProps) { - render( - { - ...renderProps, - computedOutcomesKeyed, - }, - canvas - ) + render(renderProps, computedOutcomesKeyed, canvas) } }, [renderProps, projectId, computedOutcomesKeyed]) @@ -185,8 +179,8 @@ const MapView: React.FC = ({ {/* because otherwise the font size gets to small and the text is cut off */}
- - {outcomeFormIsOpen && } + + {outcomeFormIsOpen && } {/* below items inside 'mapview-elements-container' maintain their normal scale */} {/* while positioning themselves absolutely (position: absolute) on the screen */} @@ -235,7 +229,7 @@ const MapView: React.FC = ({ hasChildren={contextMenuOutcomeHasChildren} outcomeActionHash={contextMenuOutcomeActionHash} outcomeStatement={contextMenuOutcomeStatement} - contextMenuCoordinate={contextMenuCoordinate} + contextMenuClickCoordinate={contextMenuCoordinate} expandOutcome={expandOutcome} collapseOutcome={collapseOutcome} unsetContextMenu={unsetContextMenu} diff --git a/web/src/routes/ProjectView/MapView/selectRenderProps.ts b/web/src/routes/ProjectView/MapView/selectRenderProps.ts index bfe2a444..428ada3f 100644 --- a/web/src/routes/ProjectView/MapView/selectRenderProps.ts +++ b/web/src/routes/ProjectView/MapView/selectRenderProps.ts @@ -18,7 +18,8 @@ const selectRenderProps = createSelector( (state: RootState) => state.projects.connections[state.ui.activeProject], (state: RootState) => state.ui.outcomeConnector.maybeLinkedOutcome, (state: RootState) => state.ui.outcomeConnector.toAddress, - (state: RootState) => state.ui.outcomeConnector.existingParentConnectionAddress, + (state: RootState) => + state.ui.outcomeConnector.existingParentConnectionAddress, (state: RootState) => state.ui.outcomeForm.maybeLinkedOutcome, (state: RootState) => state.ui.outcomeForm.content, (state: RootState) => state.ui.outcomeForm.leftConnectionXPosition, @@ -94,4 +95,6 @@ const selectRenderProps = createSelector( } ) +export type RenderProps = ReturnType + export default selectRenderProps diff --git a/web/src/styles.ts b/web/src/styles.ts index 3de1db0e..ae48743b 100644 --- a/web/src/styles.ts +++ b/web/src/styles.ts @@ -43,7 +43,6 @@ const colors = { } // map view colors - const SELECTED_COLOR = '#344cff' const HIGH_PRIORITY_GLOW_COLOR = '#334CF8' @@ -86,6 +85,8 @@ const PROGRESS_BAR_FOREGROUND_COLOR = '#334CF8' // canvas connection colors const CONNECTION_ACHIEVED_COLOR = '#82B282' const CONNECTION_NOT_ACHIEVED_COLOR = '#CDC2A2' +const CONNECTION_ACHIEVED_SELECTED_OUTCOME_COLOR = '#82B282' +const CONNECTION_NOT_ACHIEVED_SELECTED_OUTCOME_COLOR = '#CDC2A2' const SELF_ASSIGNED_STATUS_COLORS = { Online: '#00d0c0', @@ -119,4 +120,6 @@ export { SELF_ASSIGNED_STATUS_COLORS, CONNECTION_ACHIEVED_COLOR, CONNECTION_NOT_ACHIEVED_COLOR, + CONNECTION_ACHIEVED_SELECTED_OUTCOME_COLOR, + CONNECTION_NOT_ACHIEVED_SELECTED_OUTCOME_COLOR, }