Drop-down filtering (#9399)

Fixes #9058 - the filtering so far is a bit aggressive, but I tune it up in next PR(s).

[Screencast from 2024-03-13 15-20-17.webm](https://github.com/enso-org/enso/assets/3919101/112ce65a-a8c6-4818-b8b8-9f493caf9c81)

Added new special `WidgetEditHandler,` allowing handling "multi-widget" interactions needed for drop down filtering.

# Important Notes
* Now when clicking on argument name, the edit is accepted (as normal "outside" click), and then the dropdown is opened again (due to handling click event). I didn't figure out how to handle this case properly, left something least confusing.
This commit is contained in:
Adam Obuchowicz 2024-03-15 16:15:43 +01:00 committed by GitHub
parent c4cb7b9305
commit aecabfe0de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 577 additions and 94 deletions

View File

@ -119,6 +119,71 @@ test('Selection widgets in Data.read node', async ({ page }) => {
await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 1') await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 1')
}) })
test('Selection widget with text widget as input', async ({ page }) => {
await actions.goToGraph(page)
await mockMethodCallInfo(page, 'data', {
methodPointer: {
module: 'Standard.Base.Data',
definedOnType: 'Standard.Base.Data',
name: 'read',
},
notAppliedArguments: [0, 1, 2],
})
const dropDown = new DropDownLocator(page)
const node = locate.graphNodeByBinding(page, 'data')
const argumentNames = node.locator('.WidgetArgumentName')
const pathArg = argumentNames.filter({ has: page.getByText('path') })
const pathArgInput = pathArg.locator('.WidgetText > input')
await pathArg.click()
await expect(page.locator('.dropdownContainer')).toBeVisible()
await dropDown.clickOption(page, 'File 2')
await expect(pathArgInput).toHaveValue('File 2')
// Editing text input shows and filters drop down
await pathArgInput.click()
await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2'])
await page.keyboard.insertText('File 1')
await dropDown.expectVisibleWithOptions(page, ['File 1'])
// Clearing input should show all text literal options
await pathArgInput.clear()
await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2'])
// Esc should cancel editing and close drop down
await page.keyboard.press('Escape')
await expect(pathArgInput).not.toBeFocused()
await expect(pathArgInput).toHaveValue('File 2')
await expect(dropDown.dropDown).not.toBeVisible()
// Choosing entry should finish editing
await pathArgInput.click()
await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2'])
await page.keyboard.insertText('File')
await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2'])
await dropDown.clickOption(page, 'File 1')
await expect(pathArgInput).not.toBeFocused()
await expect(pathArgInput).toHaveValue('File 1')
await expect(dropDown.dropDown).not.toBeVisible()
// Clicking-off and pressing enter should accept text as-is
await pathArgInput.click()
await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2'])
await page.keyboard.insertText('File')
await page.keyboard.press('Enter')
await expect(pathArgInput).not.toBeFocused()
await expect(pathArgInput).toHaveValue('File')
await expect(dropDown.dropDown).not.toBeVisible()
await pathArgInput.click()
await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2'])
await page.keyboard.insertText('Foo')
await expect(pathArgInput).toHaveValue('Foo')
await page.mouse.click(200, 200)
await expect(pathArgInput).not.toBeFocused()
await expect(pathArgInput).toHaveValue('Foo')
await expect(dropDown.dropDown).not.toBeVisible()
})
test('File Browser widget', async ({ page }) => { test('File Browser widget', async ({ page }) => {
await actions.goToGraph(page) await actions.goToGraph(page)
await mockMethodCallInfo(page, 'data', { await mockMethodCallInfo(page, 'data', {

View File

@ -67,7 +67,7 @@ const cbOpen: Interaction = {
emit('canceled') emit('canceled')
}, },
click: (e: PointerEvent) => { click: (e: PointerEvent) => {
if (targetIsOutside(e, cbRoot)) { if (targetIsOutside(e, cbRoot.value)) {
if (input.anyChange.value) { if (input.anyChange.value) {
acceptInput() acceptInput()
} else { } else {

View File

@ -2,12 +2,13 @@
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue' import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import SvgIcon from '@/components/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon.vue'
import DropdownWidget from '@/components/widgets/DropdownWidget.vue' import DropdownWidget from '@/components/widgets/DropdownWidget.vue'
import { injectInteractionHandler } from '@/providers/interactionHandler' import { unrefElement } from '@/composables/events'
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry' import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
import { import {
singleChoiceConfiguration, singleChoiceConfiguration,
type ArgumentWidgetConfiguration, type ArgumentWidgetConfiguration,
} from '@/providers/widgetRegistry/configuration' } from '@/providers/widgetRegistry/configuration'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { requiredImports, type RequiredImport } from '@/stores/graph/imports.ts' import { requiredImports, type RequiredImport } from '@/stores/graph/imports.ts'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
@ -21,75 +22,129 @@ import { ArgumentInfoKey } from '@/util/callTree'
import { arrayEquals } from '@/util/data/array' import { arrayEquals } from '@/util/data/array'
import type { Opt } from '@/util/data/opt' import type { Opt } from '@/util/data/opt'
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName' import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
import { computed, ref, watch } from 'vue' import { computed, ref, watch, type ComponentInstance } from 'vue'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const suggestions = useSuggestionDbStore() const suggestions = useSuggestionDbStore()
const graph = useGraphStore() const graph = useGraphStore()
const interaction = injectInteractionHandler()
const widgetRoot = ref<HTMLElement>() const widgetRoot = ref<HTMLElement>()
const dropdownElement = ref<ComponentInstance<typeof DropdownWidget>>()
interface Tag { const editedValue = ref<Ast.Ast | string | undefined>()
/** If not set, the label is same as expression */ const isHovered = ref(false)
label?: string
expression: string
requiredImports?: RequiredImport[]
parameters?: ArgumentWidgetConfiguration[]
}
type CustomTag = Tag & { onClick: () => void } class Tag {
private cachedExpressionAst: Ast.Ast | undefined
function tagFromExpression(expression: string, label?: Opt<string>): Tag { constructor(
const qn = tryQualifiedName(expression) readonly expression: string,
if (!qn.ok) return { expression, ...(label ? { label } : {}) } private explicitLabel?: Opt<string>,
const entry = suggestions.entries.getEntryByQualifiedName(qn.value) readonly requiredImports?: RequiredImport[],
if (entry) { public parameters?: ArgumentWidgetConfiguration[],
const tag = tagFromEntry(entry) ) {}
return label ? { ...tag, label: label } : tag
static FromExpression(expression: string, label?: Opt<string>): Tag {
const qn = tryQualifiedName(expression)
if (!qn.ok) return new Tag(expression, label)
const entry = suggestions.entries.getEntryByQualifiedName(qn.value)
if (entry) return Tag.FromEntry(entry, label)
return new Tag(qn.value, label ?? qnLastSegment(qn.value))
} }
return {
label: label ?? qnLastSegment(qn.value),
expression: qn.value,
}
}
function tagFromEntry(entry: SuggestionEntry): Tag { static FromEntry(entry: SuggestionEntry, label?: Opt<string>): Tag {
return { const expression =
label: entry.name,
expression:
entry.selfType != null ? `_.${entry.name}` entry.selfType != null ? `_.${entry.name}`
: entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}` : entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}`
: entry.name, : entry.name
requiredImports: requiredImports(suggestions.entries, entry), return new Tag(expression, label ?? entry.name, requiredImports(suggestions.entries, entry))
}
get label() {
return this.explicitLabel ?? this.expression
}
get expressionAst() {
if (this.cachedExpressionAst == null) {
this.cachedExpressionAst = Ast.parse(this.expression)
}
return this.cachedExpressionAst
}
isFilteredIn(): boolean {
// Here is important distinction between empty string meaning the pattern is an empty string
// literal "", and undefined meaning that there it's not a string literal.
if (editedTextLiteralValuePattern.value != null) {
return (
this.expressionAst instanceof Ast.TextLiteral &&
this.expressionAst.rawTextContent.startsWith(editedTextLiteralValuePattern.value)
)
} else if (editedValuePattern.value) {
return this.expression.startsWith(editedValuePattern.value)
} else {
return true
}
} }
} }
function tagFromCustomItem(item: CustomDropdownItem): CustomTag { class CustomTag {
const expression = item.label constructor(
return { expression, onClick: item.onClick } readonly label: string,
readonly onClick: () => void,
) {}
static FromItem(item: CustomDropdownItem): CustomTag {
return new CustomTag(item.label, item.onClick)
}
isFilteredIn(): boolean {
// User writing something in inner inputs wants to create an expression, so custom
// tags are hidden in that case.
return !(editedTextLiteralValuePattern.value || editedValuePattern.value)
}
} }
const editedValuePattern = computed(() =>
editedValue.value instanceof Ast.Ast ? editedValue.value.code() : editedValue.value,
)
const editedTextLiteralValuePattern = computed(() => {
const editedAst =
typeof editedValue.value === 'string' ? Ast.parse(editedValue.value) : editedValue.value
return editedAst instanceof Ast.TextLiteral ? editedAst.rawTextContent : undefined
})
const staticTags = computed<Tag[]>(() => { const staticTags = computed<Tag[]>(() => {
const tags = props.input[ArgumentInfoKey]?.info?.tagValues const tags = props.input[ArgumentInfoKey]?.info?.tagValues
if (tags == null) return [] if (tags == null) return []
return tags.map((t) => tagFromExpression(t)) return tags.map((t) => Tag.FromExpression(t))
}) })
const dynamicTags = computed<Tag[]>(() => { const dynamicTags = computed<Tag[]>(() => {
const config = props.input.dynamicConfig const config = props.input.dynamicConfig
if (config?.kind !== 'Single_Choice') return [] if (config?.kind !== 'Single_Choice') return []
return config.values.map((value) => ({
...tagFromExpression(value.value, value.label), return config.values.map((value) => {
parameters: value.parameters, const tag = Tag.FromExpression(value.value, value.label)
})) tag.parameters = value.parameters
return tag
})
}) })
const customTags = computed(() => props.input[CustomDropdownItemsKey]?.map(tagFromCustomItem) ?? []) const customTags = computed(
() => props.input[CustomDropdownItemsKey]?.map(CustomTag.FromItem) ?? [],
)
const tags = computed(() => { const tags = computed(() => {
const standardTags = dynamicTags.value.length > 0 ? dynamicTags.value : staticTags.value const standardTags = dynamicTags.value.length > 0 ? dynamicTags.value : staticTags.value
return [...customTags.value, ...standardTags] return [...customTags.value, ...standardTags]
}) })
const tagLabels = computed(() => tags.value.map((tag) => tag.label ?? tag.expression)) const filteredTags = computed(() => {
console.log(editedValuePattern.value)
console.log(editedTextLiteralValuePattern.value)
return Array.from(tags.value, (tag, index) => ({
tag,
index,
})).filter(({ tag }) => tag.isFilteredIn())
})
const filteredTagLabels = computed(() => filteredTags.value.map(({ tag }) => tag.label))
const removeSurroundingParens = (expr?: string) => expr?.trim().replaceAll(/(^[(])|([)]$)/g, '') const removeSurroundingParens = (expr?: string) => expr?.trim().replaceAll(/(^[(])|([)]$)/g, '')
@ -108,7 +163,11 @@ const selectedTag = computed(() => {
// We need to find the tag that matches the (beginning of) current expression. // We need to find the tag that matches the (beginning of) current expression.
// To prevent partial prefix matches, we arrange tags in reverse lexicographical order. // To prevent partial prefix matches, we arrange tags in reverse lexicographical order.
const sortedTags = tags.value const sortedTags = tags.value
.map((tag, index) => [removeSurroundingParens(tag.expression), index] as [string, number]) .filter((tag) => tag instanceof Tag)
.map(
(tag, index) =>
[removeSurroundingParens((tag as Tag).expression), index] as [string, number],
)
.sort(([a], [b]) => .sort(([a], [b]) =>
a < b ? 1 a < b ? 1
: a > b ? -1 : a > b ? -1
@ -122,39 +181,65 @@ const selectedTag = computed(() => {
const selectedLabel = computed(() => { const selectedLabel = computed(() => {
return selectedTag.value?.label return selectedTag.value?.label
}) })
const innerWidgetInput = computed(() => { const innerWidgetInput = computed<WidgetInput>(() => {
if (props.input.dynamicConfig == null) return props.input const dynamicConfig =
const config = props.input.dynamicConfig props.input.dynamicConfig?.kind === 'Single_Choice' ?
if (config.kind !== 'Single_Choice') return props.input singleChoiceConfiguration(props.input.dynamicConfig)
return { ...props.input, dynamicConfig: singleChoiceConfiguration(config) } : props.input.dynamicConfig
return {
...props.input,
editHandler: dropDownInteraction,
dynamicConfig,
}
}) })
const showDropdownWidget = ref(false) const dropdownVisible = ref(false)
interaction.setWhen(showDropdownWidget, { const dropDownInteraction = WidgetEditHandler.New(props.input, {
cancel: () => { cancel: () => {
showDropdownWidget.value = false dropdownVisible.value = false
}, },
click: (e: PointerEvent) => { click: (e, _, childHandler) => {
if (targetIsOutside(e, widgetRoot)) showDropdownWidget.value = false if (targetIsOutside(e, unrefElement(dropdownElement))) {
if (childHandler) return childHandler()
else dropdownVisible.value = false
}
return false return false
}, },
start: () => {
dropdownVisible.value = true
editedValue.value = undefined
},
edit: (_, value) => {
editedValue.value = value
},
end: () => {
dropdownVisible.value = false
},
}) })
function toggleDropdownWidget() { function toggleDropdownWidget() {
showDropdownWidget.value = !showDropdownWidget.value if (!dropdownVisible.value) dropDownInteraction.start()
else dropDownInteraction.cancel()
} }
function onClick(index: number, keepOpen: boolean) { function onClick(indexOfFiltered: number, keepOpen: boolean) {
if (index < customTags.value.length) { const clicked = filteredTags.value[indexOfFiltered]
customTags.value[index]!.onClick() if (clicked?.tag instanceof CustomTag) clicked.tag.onClick()
} else { else selectedIndex.value = clicked?.index
selectedIndex.value = index if (!keepOpen) {
// We cancel interaction instead of ending it to restore the old value in the inner widget;
// if we clicked already selected entry, there would be no AST change, thus the inner
// widget's content woud not be updated.
dropDownInteraction.cancel()
} }
showDropdownWidget.value = keepOpen
} }
// When the selected index changes, we update the expression content. // When the selected index changes, we update the expression content.
watch(selectedIndex, (_index) => { watch(selectedIndex, (_index) => {
let edit: Ast.MutableModule | undefined let edit: Ast.MutableModule | undefined
if (selectedTag.value instanceof CustomTag) {
console.warn('Selecting custom drop down item does nothing!')
return
}
// Unless import conflict resolution is needed, we use the selected expression as is. // Unless import conflict resolution is needed, we use the selected expression as is.
let value = selectedTag.value?.expression let value = selectedTag.value?.expression
if (selectedTag.value?.requiredImports) { if (selectedTag.value?.requiredImports) {
@ -167,8 +252,6 @@ watch(selectedIndex, (_index) => {
} }
props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } }) props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } })
}) })
const isHovered = ref(false)
</script> </script>
<script lang="ts"> <script lang="ts">
@ -225,10 +308,11 @@ declare module '@/providers/widgetRegistry' {
<NodeWidget ref="childWidgetRef" :input="innerWidgetInput" /> <NodeWidget ref="childWidgetRef" :input="innerWidgetInput" />
<SvgIcon v-if="isHovered" name="arrow_right_head_only" class="arrow" /> <SvgIcon v-if="isHovered" name="arrow_right_head_only" class="arrow" />
<DropdownWidget <DropdownWidget
v-if="showDropdownWidget" v-if="dropdownVisible"
ref="dropdownElement"
class="dropdownContainer" class="dropdownContainer"
:color="'var(--node-color-primary)'" :color="'var(--node-color-primary)'"
:values="tagLabels" :values="filteredTagLabels"
:selectedValue="selectedLabel" :selectedValue="selectedLabel"
@click="onClick" @click="onClick"
/> />

View File

@ -1,14 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue' import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue' import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue'
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry' import { unrefElement } from '@/composables/events'
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
import { useGraphStore } from '@/stores/graph' import { useGraphStore } from '@/stores/graph'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { MutableModule } from '@/util/ast/abstract' import { MutableModule } from '@/util/ast/abstract'
import { computed } from 'vue' import { targetIsOutside } from '@/util/autoBlur'
import { computed, ref, watch, type ComponentInstance } from 'vue'
const props = defineProps(widgetProps(widgetDefinition)) const props = defineProps(widgetProps(widgetDefinition))
const graph = useGraphStore() const graph = useGraphStore()
const input = ref<ComponentInstance<typeof AutoSizedInput>>()
const widgetRoot = ref<HTMLElement>()
const editing = WidgetEditHandler.New(props.input, {
cancel() {
editedContents.value = textContents.value
input.value?.blur()
},
click(event) {
if (targetIsOutside(event, unrefElement(input))) accepted()
return false
},
end() {
input.value?.blur()
},
})
function accepted() {
editing.end()
if (props.input.value instanceof Ast.TextLiteral) {
const edit = graph.startEdit()
edit.getVersion(props.input.value).setRawTextContent(editedContents.value)
props.onUpdate({ edit })
} else {
props.onUpdate({
portUpdate: {
value: makeNewLiteral(editedContents.value),
origin: props.input.portId,
},
})
}
}
const inputTextLiteral = computed((): Ast.TextLiteral | undefined => { const inputTextLiteral = computed((): Ast.TextLiteral | undefined => {
if (props.input.value instanceof Ast.TextLiteral) return props.input.value if (props.input.value instanceof Ast.TextLiteral) return props.input.value
@ -21,29 +56,23 @@ function makeNewLiteral(value: string) {
return Ast.TextLiteral.new(value, MutableModule.Transient()) return Ast.TextLiteral.new(value, MutableModule.Transient())
} }
function makeLiteralFromUserInput(value: string): Ast.Owned<Ast.MutableTextLiteral> {
if (props.input.value instanceof Ast.TextLiteral) {
const literal = MutableModule.Transient().copy(props.input.value)
literal.setRawTextContent(value)
return literal
} else {
return makeNewLiteral(value)
}
}
const emptyTextLiteral = makeNewLiteral('') const emptyTextLiteral = makeNewLiteral('')
const shownLiteral = computed(() => inputTextLiteral.value ?? emptyTextLiteral) const shownLiteral = computed(() => inputTextLiteral.value ?? emptyTextLiteral)
const closeToken = computed(() => shownLiteral.value.close ?? shownLiteral.value.open) const closeToken = computed(() => shownLiteral.value.close ?? shownLiteral.value.open)
const textContents = computed({ const textContents = computed(() => shownLiteral.value.rawTextContent)
get() { const editedContents = ref(textContents.value)
return shownLiteral.value.rawTextContent watch(textContents, (value) => (editedContents.value = value))
},
set(value) {
if (props.input.value instanceof Ast.TextLiteral) {
const edit = graph.startEdit()
edit.getVersion(props.input.value).setRawTextContent(value)
props.onUpdate({ edit })
} else {
props.onUpdate({
portUpdate: {
value: makeNewLiteral(value).code(),
origin: props.input.portId,
},
})
}
},
})
</script> </script>
<script lang="ts"> <script lang="ts">
@ -60,9 +89,19 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script> </script>
<template> <template>
<label class="WidgetText r-24" @pointerdown.stop> <label ref="widgetRoot" class="WidgetText r-24" @pointerdown.stop>
<NodeWidget v-if="shownLiteral.open" :input="WidgetInput.FromAst(shownLiteral.open)" /> <NodeWidget v-if="shownLiteral.open" :input="WidgetInput.FromAst(shownLiteral.open)" />
<AutoSizedInput v-model.lazy="textContents" /> <AutoSizedInput
ref="input"
v-model="editedContents"
autoSelect
@pointerdown.stop
@pointerup.stop
@click.stop
@keydown.enter.stop="accepted"
@focusin="editing.start()"
@input="editing.edit(makeLiteralFromUserInput($event ?? ''))"
/>
<NodeWidget v-if="closeToken" :input="WidgetInput.FromAst(closeToken)" /> <NodeWidget v-if="closeToken" :input="WidgetInput.FromAst(closeToken)" />
</label> </label>
</template> </template>

View File

@ -5,10 +5,17 @@ import { computed, ref, watch, type StyleValue } from 'vue'
const [model, modifiers] = defineModel<string>() const [model, modifiers] = defineModel<string>()
const props = defineProps<{ autoSelect?: boolean }>() const props = defineProps<{ autoSelect?: boolean }>()
const emit = defineEmits<{
input: [value: string | undefined]
change: [value: string | undefined]
}>()
const innerModel = modifiers.lazy ? ref(model.value) : model const innerModel = modifiers.lazy ? ref(model.value) : model
if (modifiers.lazy) watch(model, (newVal) => (innerModel.value = newVal)) if (modifiers.lazy) watch(model, (newVal) => (innerModel.value = newVal))
const onChange = modifiers.lazy ? () => (model.value = innerModel.value) : undefined function onChange() {
if (modifiers.lazy) model.value = innerModel.value
emit('change', innerModel.value)
}
const inputNode = ref<HTMLInputElement>() const inputNode = ref<HTMLInputElement>()
useAutoBlur(inputNode) useAutoBlur(inputNode)
@ -40,6 +47,11 @@ defineExpose({
getTextWidth, getTextWidth,
select: () => inputNode.value?.select(), select: () => inputNode.value?.select(),
focus: () => inputNode.value?.focus(), focus: () => inputNode.value?.focus(),
blur: () => inputNode.value?.blur(),
cancel: () => {
innerModel.value = model.value
inputNode.value?.blur()
},
}) })
</script> </script>
@ -52,6 +64,7 @@ defineExpose({
@keydown.backspace.stop @keydown.backspace.stop
@keydown.delete.stop @keydown.delete.stop
@keydown.enter.stop="onEnterDown" @keydown.enter.stop="onEnterDown"
@input="emit('input', innerModel)"
@change="onChange" @change="onChange"
@focus="onFocus" @focus="onFocus"
/> />

View File

@ -11,6 +11,10 @@ const { provideFn, injectFn } = createContextStore(
export class InteractionHandler { export class InteractionHandler {
private currentInteraction: Interaction | undefined = undefined private currentInteraction: Interaction | undefined = undefined
isActive(interaction: Interaction | undefined): interaction is Interaction {
return interaction != null && interaction === this.currentInteraction
}
/** Automatically activate specified interaction any time a specified condition becomes true. */ /** Automatically activate specified interaction any time a specified condition becomes true. */
setWhen(active: WatchSource<boolean>, interaction: Interaction) { setWhen(active: WatchSource<boolean>, interaction: Interaction) {
watch(active, (active) => { watch(active, (active) => {
@ -23,7 +27,7 @@ export class InteractionHandler {
} }
setCurrent(interaction: Interaction | undefined) { setCurrent(interaction: Interaction | undefined) {
if (interaction !== this.currentInteraction) { if (!this.isActive(interaction)) {
this.currentInteraction?.cancel?.() this.currentInteraction?.cancel?.()
this.currentInteraction = interaction this.currentInteraction = interaction
} }
@ -31,12 +35,12 @@ export class InteractionHandler {
/** Unset the current interaction, if it is the specified instance. */ /** Unset the current interaction, if it is the specified instance. */
end(interaction: Interaction) { end(interaction: Interaction) {
if (this.currentInteraction === interaction) this.currentInteraction = undefined if (this.isActive(interaction)) this.currentInteraction = undefined
} }
/** Cancel the current interaction, if it is the specified instance. */ /** Cancel the current interaction, if it is the specified instance. */
cancel(interaction: Interaction) { cancel(interaction: Interaction) {
if (this.currentInteraction === interaction) this.setCurrent(undefined) if (this.isActive(interaction)) this.setCurrent(undefined)
} }
handleCancel(): boolean { handleCancel(): boolean {

View File

@ -6,6 +6,7 @@ import type { Typename } from '@/stores/suggestionDatabase/entry'
import { Ast } from '@/util/ast' import { Ast } from '@/util/ast'
import { MutableModule } from '@/util/ast/abstract.ts' import { MutableModule } from '@/util/ast/abstract.ts'
import { computed, shallowReactive, type Component, type PropType } from 'vue' import { computed, shallowReactive, type Component, type PropType } from 'vue'
import type { WidgetEditHandler } from './widgetRegistry/editHandler'
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>> export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
@ -104,6 +105,7 @@ export interface WidgetInput {
dynamicConfig?: WidgetConfiguration | undefined dynamicConfig?: WidgetConfiguration | undefined
/** Force the widget to be a connectible port. */ /** Force the widget to be a connectible port. */
forcePort?: boolean forcePort?: boolean
editHandler?: WidgetEditHandler
} }
/** /**

View File

@ -0,0 +1,145 @@
import type { GraphNavigator } from '@/providers/graphNavigator'
import { InteractionHandler } from '@/providers/interactionHandler'
import type { PortId } from '@/providers/portInfo'
import { assert } from 'shared/util/assert'
import { expect, test, vi, type Mock } from 'vitest'
import { WidgetEditHandler } from '../editHandler'
// If widget's name is a prefix of another widget's name, then it is its ancestor.
// The ancestor with longest name is a direct parent.
function editHandlerTree(
widgets: string[],
interactionHandler: InteractionHandler,
createInteraction: (name: string) => Record<string, Mock>,
): Map<string, { handler: WidgetEditHandler; interaction: Record<string, Mock> }> {
const handlers = new Map()
for (const id of widgets) {
let parent: string | undefined
for (const [otherId] of handlers) {
if (id.startsWith(otherId) && otherId.length > (parent?.length ?? -1)) parent = otherId
}
const interaction = createInteraction(id)
const handler = new WidgetEditHandler(
id as PortId,
interaction,
parent ? handlers.get(parent)?.handler : undefined,
interactionHandler,
)
handlers.set(id, { handler, interaction })
}
return handlers
}
test.each`
widgets | edited | expectedPropagation
${['A']} | ${'A'} | ${['A']}
${['A', 'A1', 'B']} | ${'A1'} | ${['A', 'A1']}
${['A', 'A1', 'A2']} | ${'A2'} | ${['A', 'A2']}
${['A', 'A1', 'A11']} | ${'A1'} | ${['A', 'A1']}
${['A', 'A1', 'A11']} | ${'A11'} | ${['A', 'A1', 'A11']}
${['A', 'A1', 'A2', 'A21']} | ${'A21'} | ${['A', 'A2', 'A21']}
`(
'Edit interaction propagation starting from $edited in $widgets tree',
({ widgets, edited, expectedPropagation }) => {
const interactionHandler = new InteractionHandler()
const handlers = editHandlerTree(widgets, interactionHandler, () => ({
start: vi.fn(),
edit: vi.fn(),
end: vi.fn(),
cancel: vi.fn(),
}))
const expectedPropagationSet = new Set(expectedPropagation)
const checkCallbackCall = (callback: string, ...args: any[]) => {
for (const [id, { interaction }] of handlers) {
if (expectedPropagationSet.has(id)) {
expect(interaction[callback]).toHaveBeenCalledWith(...args)
} else {
expect(interaction[callback]).not.toHaveBeenCalled()
}
interaction[callback]?.mockClear()
}
}
const editedHandler = handlers.get(edited)
assert(editedHandler != null)
editedHandler.handler.start()
checkCallbackCall('start', edited)
for (const [id, { handler }] of handlers) {
expect(handler.isActive()).toBe(expectedPropagationSet.has(id))
}
editedHandler.handler.edit('13')
checkCallbackCall('edit', edited, '13')
for (const ended of expectedPropagation) {
const endedHandler = handlers.get(ended)?.handler
editedHandler.handler.start()
expect(editedHandler.handler.isActive()).toBeTruthy()
endedHandler?.end()
checkCallbackCall('end', ended)
expect(editedHandler.handler.isActive()).toBeFalsy()
editedHandler.handler.start()
expect(editedHandler.handler.isActive()).toBeTruthy()
endedHandler?.cancel()
checkCallbackCall('cancel')
expect(editedHandler.handler.isActive()).toBeFalsy()
}
editedHandler.handler.start()
expect(editedHandler.handler.isActive()).toBeTruthy()
interactionHandler.setCurrent(undefined)
checkCallbackCall('cancel')
expect(editedHandler.handler.isActive()).toBeFalsy()
},
)
test.each`
name | widgets | edited | propagatingHandlers | nonPropagatingHandlers | expectedHandlerCalls
${'Propagating'} | ${['A', 'A1']} | ${'A1'} | ${['A', 'A1']} | ${[]} | ${['A', 'A1']}
${'Parent edited'} | ${['A', 'A1']} | ${'A'} | ${['A', 'A1']} | ${[]} | ${['A']}
${'Not propagating'} | ${['A', 'A1']} | ${'A1'} | ${['A1']} | ${['A']} | ${['A']}
${'Child only'} | ${['A', 'A1']} | ${'A1'} | ${['A1']} | ${[]} | ${['A1']}
${'Skipping handler without click'} | ${['A', 'A1', 'A12']} | ${'A12'} | ${['A', 'A12']} | ${[]} | ${['A', 'A12']}
${'Stopping propagation'} | ${['A', 'A1', 'A12']} | ${'A12'} | ${['A', 'A12']} | ${['A1']} | ${['A', 'A1']}
`(
'Handling clicks in WidgetEditHandlers case $name',
({ widgets, edited, propagatingHandlers, nonPropagatingHandlers, expectedHandlerCalls }) => {
const event = new MouseEvent('click') as PointerEvent
const navigator = {} as GraphNavigator
const interactionHandler = new InteractionHandler()
const propagatingHandlersSet = new Set(propagatingHandlers)
const nonPropagatingHandlersSet = new Set(nonPropagatingHandlers)
const expectedHandlerCallsSet = new Set(expectedHandlerCalls)
const handlers = editHandlerTree(widgets, interactionHandler, (id) =>
propagatingHandlersSet.has(id) ?
{
click: vi.fn((e, nav, childHandler) => {
expect(e).toBe(event)
expect(nav).toBe(navigator)
childHandler?.()
}),
}
: nonPropagatingHandlersSet.has(id) ?
{
click: vi.fn((e, nav) => {
expect(e).toBe(event)
expect(nav).toBe(navigator)
}),
}
: {},
)
handlers.get(edited)?.handler.start()
interactionHandler.handleClick(event, navigator)
for (const [id, { interaction }] of handlers) {
if (expectedHandlerCallsSet.has(id))
expect(interaction.click, `${id} click handler`).toHaveBeenCalled()
else if (interaction.click)
expect(interaction.click, `${id} click handler`).not.toHaveBeenCalled()
}
},
)

View File

@ -0,0 +1,133 @@
import type { PortId } from '@/providers//portInfo'
import type { GraphNavigator } from '@/providers/graphNavigator'
import {
injectInteractionHandler,
type Interaction,
type InteractionHandler,
} from '@/providers/interactionHandler'
import type { WidgetInput } from '@/providers/widgetRegistry'
import type { Ast } from '@/util/ast'
/** An extend {@link Interaction} used in {@link WidgetEditHandler} */
export interface WidgetEditInteraction extends Interaction {
/** Click handler from {@link Interaction}, but receives child's click handler. See
* {@link WidgetEditHandler} for details */
click?(
event: PointerEvent,
navigator: GraphNavigator,
childHandler?: () => boolean | void,
): boolean | void
start?(origin: PortId): void
edit?(origin: PortId, value: Ast.Owned | string): void
end?(origin: PortId): void
}
/**
* Widget edit handler.
*
* This handler takes an extended interaction and allows cooperation between parent/child
* interactions. A usage example is WidgetSelection, which wants to open when the child is edited
* and filters entries by edited temporary value.
*
* Widget's edit state should be manipulated by `start`, `end` and `cancel` methods; they will set
* proper interaction in the global {@link InteractionHandler} and call the additional callbacks in
* {@link WidgetEditInteraction} passed during construction.
*
* The parent widget may pass its edit handler to one or more children's {@link WidgetInput} to
* bound their interactions; when this child is edited, the parent is also considered edited,
* along with any further ancestors. In particular:
* - Starting, ending and cancelling (including automatic canceling by the global interaction
* handler) of child edit will also call proper callbacks in parent.
* - Cancelling or ending parent edit will cancel/end the child's interaction.
* - `isActive` method of both edit handlers will return true.
*
* This `edited` state is propagated only upwards: if only parent is edited, its children are not
* considered edited. If child starts being edited while parent is still edited, the parent interaction
* will be considered cancelled and then immediately started again. Similarly, when a parent's handler
* is bound to two children, and one of them starts editing while the other is edited, the parent
* will receive `cancel` feedback from the latter and then `start` from the former.
*
* **The `click` handler is a special case:** it will be called only on top-most parent, but its
* handler may decide to delegate it further by calling child's handler passed as an additional
* argument
*/
export class WidgetEditHandler {
private interaction: WidgetEditInteraction
/** This, or one's child interaction which is currently active */
private activeInteraction: WidgetEditInteraction | undefined
constructor(
private portId: PortId,
innerInteraction: WidgetEditInteraction,
private parent?: WidgetEditHandler,
private interactionHandler: InteractionHandler = injectInteractionHandler(),
) {
this.interaction = {
cancel: () => {
this.activeInteraction = undefined
innerInteraction.cancel?.()
parent?.interaction.cancel?.()
},
click: (event, navigator, childHandler) => {
const innerInteractionClick = innerInteraction.click
const thisHandler =
innerInteractionClick ?
() => innerInteractionClick(event, navigator, childHandler)
: childHandler
if (parent && parent.interaction.click)
return parent.interaction.click(event, navigator, thisHandler)
else return thisHandler ? thisHandler() : false
},
start: (portId) => {
innerInteraction.start?.(portId)
parent?.interaction.start?.(portId)
},
edit: (portId, value) => {
innerInteraction.edit?.(portId, value)
parent?.interaction.edit?.(portId, value)
},
end: (portId) => {
this.activeInteraction = undefined
innerInteraction.end?.(portId)
parent?.interaction.end?.(portId)
},
}
}
static New(input: WidgetInput, myInteraction: WidgetEditInteraction) {
return new WidgetEditHandler(input.portId, myInteraction, input.editHandler)
}
cancel() {
if (this.activeInteraction) {
this.interactionHandler.cancel(this.activeInteraction)
}
}
start() {
this.interactionHandler.setCurrent(this.interaction)
for (
let handler: WidgetEditHandler | undefined = this;
handler != null;
handler = handler.parent
) {
handler.activeInteraction = this.interaction
}
this.interaction.start?.(this.portId)
}
edit(value: Ast.Owned | string) {
this.interaction.edit?.(this.portId, value)
}
end() {
if (this.activeInteraction) {
this.interactionHandler.end(this.activeInteraction)
this.activeInteraction.end?.(this.portId)
}
}
isActive() {
return this.activeInteraction ? this.interactionHandler.isActive(this.activeInteraction) : false
}
}

View File

@ -1,4 +1,5 @@
import { useEvent } from '@/composables/events' import { useEvent } from '@/composables/events'
import type { Opt } from 'shared/util/data/opt'
import { watchEffect, type Ref } from 'vue' import { watchEffect, type Ref } from 'vue'
/** Automatically `blur` the currently active element on any mouse click outside of `root`. /** Automatically `blur` the currently active element on any mouse click outside of `root`.
@ -41,9 +42,6 @@ export function registerAutoBlurHandler() {
} }
/** Returns true if the target of the event is in the DOM subtree of the given `area` element. */ /** Returns true if the target of the event is in the DOM subtree of the given `area` element. */
export function targetIsOutside( export function targetIsOutside(e: Event, area: Opt<Element>): boolean {
e: Event, return !!area && e.target instanceof Element && !area.contains(e.target)
area: Ref<HTMLElement | SVGElement | MathMLElement | undefined>,
): boolean {
return !!area.value && e.target instanceof Element && !area.value.contains(e.target)
} }