Fix component browser interactions. (#8303)

Fixes: #8064

Fixes broken interactions with of the component browser.
This commit is contained in:
Michael Mauderer 2023-11-17 15:28:45 +00:00 committed by GitHub
parent a286ab7daa
commit 85e2c72204
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 76 additions and 46 deletions

View File

@ -11,7 +11,8 @@ export const interactionBindings = defineKeybinds('current-interaction', {
export const componentBrowserBindings = defineKeybinds('component-browser', {
applySuggestion: ['Tab'],
acceptInput: ['Enter'],
acceptSuggestion: ['Enter'],
acceptInput: ['Mod+Enter'],
cancelEditing: ['Escape'],
moveUp: ['ArrowUp'],
moveDown: ['ArrowDown'],

View File

@ -37,7 +37,9 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
finished: [selectedExpression: string]
accepted: [searcherExpression: string]
closed: [searcherExpression: string]
canceled: []
}>()
function getInitialContent(): string {
@ -147,7 +149,7 @@ function handleDefocus(e: FocusEvent) {
inputField.value.focus({ preventScroll: true })
}
} else {
emit('finished', input.code.value)
emit('closed', input.code.value)
}
}
@ -297,11 +299,11 @@ function applySuggestion(component: Opt<Component> = null): SuggestionEntry | nu
function acceptSuggestion(index: Opt<Component> = null) {
const applied = applySuggestion(index)
const shouldFinish = applied != null && applied.kind !== SuggestionKind.Module
if (shouldFinish) emit('finished', input.code.value)
if (shouldFinish) acceptInput()
}
function acceptInput() {
emit('finished', input.code.value)
emit('accepted', input.code.value)
}
// === Key Events Handler ===
@ -310,6 +312,10 @@ const handler = componentBrowserBindings.handler({
applySuggestion() {
applySuggestion()
},
acceptSuggestion() {
applySuggestion()
acceptInput()
},
acceptInput() {
acceptInput()
},
@ -328,7 +334,7 @@ const handler = componentBrowserBindings.handler({
scrollToSelected()
},
cancelEditing() {
emit('finished', props.initialContent)
emit('canceled')
},
})
</script>

View File

@ -429,7 +429,7 @@ describe('Mouse dictated placement', () => {
),
).toEqual<Placement>({
// Note: Currently, this is a reimplementation of the entire mouse dictated placement algorithm.
position: new Vec2(x - radius, y - radius),
position: new Vec2(x + radius, y + radius),
})
// Non-overlap test omitted, as mouse-dictated node placement MAY overlap existing nodes.
expect(getScreenBounds, 'Should not depend on `screenBounds`').not.toHaveBeenCalled()

View File

@ -114,7 +114,7 @@ export function previousNodeDictatedPlacement(
/** The new node should appear exactly below the mouse.
*
* Specifically, this code assumes the node is fully rounded on the left and right sides,
* so it subtracts half the node height (assumed to be the node radius) from the mouse x and y
* so it adds half the node height (assumed to be the node radius) from the mouse x and y
* positions.
*
* [Documentation](https://github.com/enso-org/design/blob/main/epics/component-browser/design.md#placement-of-newly-opened-component-browser) */
@ -124,5 +124,5 @@ export function mouseDictatedPlacement(
_opts?: PlacementOptions,
): Placement {
const nodeRadius = nodeSize.y / 2
return { position: mousePosition.sub(new Vec2(nodeRadius, nodeRadius)) }
return { position: mousePosition.add(new Vec2(nodeRadius, nodeRadius)) }
}

View File

@ -4,6 +4,7 @@ import CodeEditor from '@/components/CodeEditor.vue'
import ComponentBrowser from '@/components/ComponentBrowser.vue'
import {
mouseDictatedPlacement,
previousNodeDictatedPlacement,
type Environment,
} from '@/components/ComponentBrowser/placement.ts'
import { Uploader, uploadedExpression } from '@/components/GraphEditor/upload'
@ -27,6 +28,9 @@ import GraphNodes from './GraphEditor/GraphNodes.vue'
import GraphMouse from './GraphMouse.vue'
const EXECUTION_MODES = ['design', 'live']
// Difference in position between the component browser and a node for the input of the component browser to
// be placed at the same position as the node.
const COMPONENT_BROWSER_TO_NODE_OFFSET = new Vec2(20, 35)
const viewportNode = ref<HTMLElement>()
const graphNavigator = provideGraphNavigator(viewportNode)
@ -36,7 +40,6 @@ widgetRegistry.loadBuiltins()
const projectStore = useProjectStore()
const componentBrowserVisible = ref(false)
const componentBrowserInputContent = ref('')
const componentBrowserPosition = ref(Vec2.Zero)
const suggestionDb = useSuggestionDbStore()
const interaction = provideInteractionHandler()
@ -51,6 +54,29 @@ const interactionBindingsHandler = interactionBindings.handler({
click: (e) => (e instanceof MouseEvent ? interaction.handleClick(e) : false),
})
// This is where the component browser should be placed when it is opened.
function targetComponentBrowserPosition() {
const editedInfo = graphStore.editedNodeInfo
const isEditingNode = editedInfo != null
const hasNodeSelected = nodeSelection.selected.size > 0
const nodeSize = new Vec2(0, 24)
if (isEditingNode) {
const targetNode = graphStore.db.nodes.get(editedInfo.id)
const targetPos = targetNode?.position ?? Vec2.Zero
return targetPos.add(COMPONENT_BROWSER_TO_NODE_OFFSET)
} else if (hasNodeSelected) {
const gapBetweenNodes = 48.0
return previousNodeDictatedPlacement(nodeSize, placementEnvironment.value, {
gap: gapBetweenNodes,
}).position
} else {
return mouseDictatedPlacement(nodeSize, placementEnvironment.value).position
}
}
// This is the current position of the component browser.
const componentBrowserPosition = ref<Vec2>(Vec2.Zero)
const graphEditorSourceNode = computed(() => {
if (graphStore.editedNodeInfo != null) return undefined
return nodeSelection.selected.values().next().value
@ -73,8 +99,7 @@ const graphBindingsHandler = graphBindings.handler({
openComponentBrowser() {
if (keyboardBusy()) return false
if (graphNavigator.sceneMousePos != null && !componentBrowserVisible.value) {
componentBrowserPosition.value = graphNavigator.sceneMousePos
interaction.setCurrent(new CreatingNode())
interaction.setCurrent(creatingNode)
}
},
newNode() {
@ -96,7 +121,6 @@ const graphBindingsHandler = graphBindings.handler({
},
deselectAll() {
nodeSelection.deselectAll()
console.log('deselectAll')
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
@ -161,9 +185,13 @@ const groupColors = computed(() => {
})
const editingNode: Interaction = {
init: () => {
componentBrowserPosition.value = targetComponentBrowserPosition()
},
cancel: () => (componentBrowserVisible.value = false),
}
interaction.setWhen(componentBrowserVisible, editingNode)
const nodeIsBeingEdited = computed(() => graphStore.editedNodeInfo != null)
interaction.setWhen(nodeIsBeingEdited, editingNode)
const placementEnvironment = computed(() => {
const mousePosition = graphNavigator.sceneMousePos ?? Vec2.Zero
@ -177,33 +205,15 @@ const placementEnvironment = computed(() => {
return environment
})
/// Interaction to create a new node. This will create a temporary node and open the component browser.
/// If the interaction is cancelled, the temporary node will be deleted, otherwise it will be kept.
class CreatingNode implements Interaction {
nodeId: ExprId
// Start a node creation interaction. This will create a new node and open the component browser.
// For more information about the flow of the interaction, see `CreatingNode`.
constructor() {
// We create a temporary node to show the component browser on. This node will be deleted if
// the interaction is cancelled. It can later on be used to have a preview of the node as it is
// being created.
const nodeHeight = 32
const targetPosition = mouseDictatedPlacement(
Vec2.FromArr([0, nodeHeight]),
placementEnvironment.value,
)
const nodeId = graphStore.createNode(targetPosition.position, '')
if (nodeId == null) {
throw new Error('CreatingNode: Failed to create node.')
}
this.nodeId = nodeId
// From here on we just edit the temporary node.
graphStore.editedNodeInfo = { id: nodeId, range: [0, 0] }
}
cancel() {
// Aborting node creation means we no longer need the temporary node.
graphStore.deleteNode(this.nodeId)
}
const creatingNode: Interaction = {
init: () => {
componentBrowserInputContent.value = ''
componentBrowserPosition.value = targetComponentBrowserPosition()
componentBrowserVisible.value = true
},
cancel: () => {
// Nothing to do here. We just don't create a node and the component browser will close itself.
},
}
async function handleFileDrop(event: DragEvent) {
@ -241,10 +251,22 @@ async function handleFileDrop(event: DragEvent) {
function onComponentBrowserCommit(content: string) {
if (content != null && graphStore.editedNodeInfo != null) {
/// We finish editing a node.
graphStore.setNodeContent(graphStore.editedNodeInfo.id, content)
} else if (content != null) {
/// We finish creating a new node.
const nodePosition = componentBrowserPosition.value
graphStore.createNode(nodePosition.sub(COMPONENT_BROWSER_TO_NODE_OFFSET), content)
}
componentBrowserVisible.value = false
graphStore.editedNodeInfo = undefined
interaction.setCurrent(undefined)
}
function onComponentBrowserCancel() {
componentBrowserVisible.value = false
graphStore.editedNodeInfo = undefined
interaction.setCurrent(undefined)
}
/**
@ -261,10 +283,7 @@ watch(
() => graphStore.editedNodeInfo,
(editedInfo) => {
if (editedInfo != null) {
const targetNode = graphStore.db.nodes.get(editedInfo.id)
const targetPos = targetNode?.position ?? Vec2.Zero
const offset = new Vec2(20, 35)
componentBrowserPosition.value = targetPos.add(offset)
componentBrowserPosition.value = targetComponentBrowserPosition()
componentBrowserInputContent.value = getNodeContent(editedInfo.id)
componentBrowserVisible.value = true
} else {
@ -382,7 +401,9 @@ async function readNodeFromClipboard() {
ref="componentBrowser"
:navigator="graphNavigator"
:position="componentBrowserPosition"
@finished="onComponentBrowserCommit"
@accepted="onComponentBrowserCommit"
@closed="onComponentBrowserCancel"
@canceled="onComponentBrowserCancel"
:initialContent="componentBrowserInputContent"
:initialCaretPosition="graphStore.editedNodeInfo?.range ?? [0, 0]"
:sourceNode="graphEditorSourceNode"

View File

@ -25,6 +25,7 @@ export class InteractionHandler {
if (interaction !== this.currentInteraction) {
this.currentInteraction?.cancel?.()
this.currentInteraction = interaction
interaction?.init?.()
}
}
@ -48,5 +49,6 @@ export class InteractionHandler {
export interface Interaction {
cancel?(): void
init?(): void
click?(event: MouseEvent): boolean | void
}