mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 08:11:30 +03:00
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:
parent
c4cb7b9305
commit
aecabfe0de
@ -119,6 +119,71 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
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 }) => {
|
||||
await actions.goToGraph(page)
|
||||
await mockMethodCallInfo(page, 'data', {
|
||||
|
@ -67,7 +67,7 @@ const cbOpen: Interaction = {
|
||||
emit('canceled')
|
||||
},
|
||||
click: (e: PointerEvent) => {
|
||||
if (targetIsOutside(e, cbRoot)) {
|
||||
if (targetIsOutside(e, cbRoot.value)) {
|
||||
if (input.anyChange.value) {
|
||||
acceptInput()
|
||||
} else {
|
||||
|
@ -2,12 +2,13 @@
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.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 {
|
||||
singleChoiceConfiguration,
|
||||
type ArgumentWidgetConfiguration,
|
||||
} from '@/providers/widgetRegistry/configuration'
|
||||
import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { requiredImports, type RequiredImport } from '@/stores/graph/imports.ts'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
@ -21,75 +22,129 @@ import { ArgumentInfoKey } from '@/util/callTree'
|
||||
import { arrayEquals } from '@/util/data/array'
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
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 suggestions = useSuggestionDbStore()
|
||||
const graph = useGraphStore()
|
||||
const interaction = injectInteractionHandler()
|
||||
const widgetRoot = ref<HTMLElement>()
|
||||
const dropdownElement = ref<ComponentInstance<typeof DropdownWidget>>()
|
||||
|
||||
interface Tag {
|
||||
/** If not set, the label is same as expression */
|
||||
label?: string
|
||||
expression: string
|
||||
requiredImports?: RequiredImport[]
|
||||
parameters?: ArgumentWidgetConfiguration[]
|
||||
}
|
||||
const editedValue = ref<Ast.Ast | string | undefined>()
|
||||
const isHovered = ref(false)
|
||||
|
||||
type CustomTag = Tag & { onClick: () => void }
|
||||
class Tag {
|
||||
private cachedExpressionAst: Ast.Ast | undefined
|
||||
|
||||
function tagFromExpression(expression: string, label?: Opt<string>): Tag {
|
||||
const qn = tryQualifiedName(expression)
|
||||
if (!qn.ok) return { expression, ...(label ? { label } : {}) }
|
||||
const entry = suggestions.entries.getEntryByQualifiedName(qn.value)
|
||||
if (entry) {
|
||||
const tag = tagFromEntry(entry)
|
||||
return label ? { ...tag, label: label } : tag
|
||||
constructor(
|
||||
readonly expression: string,
|
||||
private explicitLabel?: Opt<string>,
|
||||
readonly requiredImports?: RequiredImport[],
|
||||
public parameters?: ArgumentWidgetConfiguration[],
|
||||
) {}
|
||||
|
||||
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 {
|
||||
return {
|
||||
label: entry.name,
|
||||
expression:
|
||||
static FromEntry(entry: SuggestionEntry, label?: Opt<string>): Tag {
|
||||
const expression =
|
||||
entry.selfType != null ? `_.${entry.name}`
|
||||
: entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}`
|
||||
: entry.name,
|
||||
requiredImports: requiredImports(suggestions.entries, entry),
|
||||
: entry.name
|
||||
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 {
|
||||
const expression = item.label
|
||||
return { expression, onClick: item.onClick }
|
||||
class CustomTag {
|
||||
constructor(
|
||||
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 tags = props.input[ArgumentInfoKey]?.info?.tagValues
|
||||
if (tags == null) return []
|
||||
return tags.map((t) => tagFromExpression(t))
|
||||
return tags.map((t) => Tag.FromExpression(t))
|
||||
})
|
||||
|
||||
const dynamicTags = computed<Tag[]>(() => {
|
||||
const config = props.input.dynamicConfig
|
||||
if (config?.kind !== 'Single_Choice') return []
|
||||
return config.values.map((value) => ({
|
||||
...tagFromExpression(value.value, value.label),
|
||||
parameters: value.parameters,
|
||||
}))
|
||||
|
||||
return config.values.map((value) => {
|
||||
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 standardTags = dynamicTags.value.length > 0 ? dynamicTags.value : staticTags.value
|
||||
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, '')
|
||||
|
||||
@ -108,7 +163,11 @@ const selectedTag = computed(() => {
|
||||
// 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.
|
||||
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]) =>
|
||||
a < b ? 1
|
||||
: a > b ? -1
|
||||
@ -122,39 +181,65 @@ const selectedTag = computed(() => {
|
||||
const selectedLabel = computed(() => {
|
||||
return selectedTag.value?.label
|
||||
})
|
||||
const innerWidgetInput = computed(() => {
|
||||
if (props.input.dynamicConfig == null) return props.input
|
||||
const config = props.input.dynamicConfig
|
||||
if (config.kind !== 'Single_Choice') return props.input
|
||||
return { ...props.input, dynamicConfig: singleChoiceConfiguration(config) }
|
||||
const innerWidgetInput = computed<WidgetInput>(() => {
|
||||
const dynamicConfig =
|
||||
props.input.dynamicConfig?.kind === 'Single_Choice' ?
|
||||
singleChoiceConfiguration(props.input.dynamicConfig)
|
||||
: props.input.dynamicConfig
|
||||
return {
|
||||
...props.input,
|
||||
editHandler: dropDownInteraction,
|
||||
dynamicConfig,
|
||||
}
|
||||
})
|
||||
const showDropdownWidget = ref(false)
|
||||
interaction.setWhen(showDropdownWidget, {
|
||||
const dropdownVisible = ref(false)
|
||||
const dropDownInteraction = WidgetEditHandler.New(props.input, {
|
||||
cancel: () => {
|
||||
showDropdownWidget.value = false
|
||||
dropdownVisible.value = false
|
||||
},
|
||||
click: (e: PointerEvent) => {
|
||||
if (targetIsOutside(e, widgetRoot)) showDropdownWidget.value = false
|
||||
click: (e, _, childHandler) => {
|
||||
if (targetIsOutside(e, unrefElement(dropdownElement))) {
|
||||
if (childHandler) return childHandler()
|
||||
else dropdownVisible.value = false
|
||||
}
|
||||
return false
|
||||
},
|
||||
start: () => {
|
||||
dropdownVisible.value = true
|
||||
editedValue.value = undefined
|
||||
},
|
||||
edit: (_, value) => {
|
||||
editedValue.value = value
|
||||
},
|
||||
end: () => {
|
||||
dropdownVisible.value = false
|
||||
},
|
||||
})
|
||||
|
||||
function toggleDropdownWidget() {
|
||||
showDropdownWidget.value = !showDropdownWidget.value
|
||||
if (!dropdownVisible.value) dropDownInteraction.start()
|
||||
else dropDownInteraction.cancel()
|
||||
}
|
||||
|
||||
function onClick(index: number, keepOpen: boolean) {
|
||||
if (index < customTags.value.length) {
|
||||
customTags.value[index]!.onClick()
|
||||
} else {
|
||||
selectedIndex.value = index
|
||||
function onClick(indexOfFiltered: number, keepOpen: boolean) {
|
||||
const clicked = filteredTags.value[indexOfFiltered]
|
||||
if (clicked?.tag instanceof CustomTag) clicked.tag.onClick()
|
||||
else selectedIndex.value = clicked?.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.
|
||||
watch(selectedIndex, (_index) => {
|
||||
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.
|
||||
let value = selectedTag.value?.expression
|
||||
if (selectedTag.value?.requiredImports) {
|
||||
@ -167,8 +252,6 @@ watch(selectedIndex, (_index) => {
|
||||
}
|
||||
props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } })
|
||||
})
|
||||
|
||||
const isHovered = ref(false)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@ -225,10 +308,11 @@ declare module '@/providers/widgetRegistry' {
|
||||
<NodeWidget ref="childWidgetRef" :input="innerWidgetInput" />
|
||||
<SvgIcon v-if="isHovered" name="arrow_right_head_only" class="arrow" />
|
||||
<DropdownWidget
|
||||
v-if="showDropdownWidget"
|
||||
v-if="dropdownVisible"
|
||||
ref="dropdownElement"
|
||||
class="dropdownContainer"
|
||||
:color="'var(--node-color-primary)'"
|
||||
:values="tagLabels"
|
||||
:values="filteredTagLabels"
|
||||
:selectedValue="selectedLabel"
|
||||
@click="onClick"
|
||||
/>
|
||||
|
@ -1,14 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.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 { Ast } from '@/util/ast'
|
||||
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 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 => {
|
||||
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())
|
||||
}
|
||||
|
||||
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 shownLiteral = computed(() => inputTextLiteral.value ?? emptyTextLiteral)
|
||||
const closeToken = computed(() => shownLiteral.value.close ?? shownLiteral.value.open)
|
||||
|
||||
const textContents = computed({
|
||||
get() {
|
||||
return shownLiteral.value.rawTextContent
|
||||
},
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
const textContents = computed(() => shownLiteral.value.rawTextContent)
|
||||
const editedContents = ref(textContents.value)
|
||||
watch(textContents, (value) => (editedContents.value = value))
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@ -60,9 +89,19 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
</script>
|
||||
|
||||
<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)" />
|
||||
<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)" />
|
||||
</label>
|
||||
</template>
|
||||
|
@ -5,10 +5,17 @@ import { computed, ref, watch, type StyleValue } from 'vue'
|
||||
|
||||
const [model, modifiers] = defineModel<string>()
|
||||
const props = defineProps<{ autoSelect?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
input: [value: string | undefined]
|
||||
change: [value: string | undefined]
|
||||
}>()
|
||||
|
||||
const innerModel = modifiers.lazy ? ref(model.value) : model
|
||||
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>()
|
||||
useAutoBlur(inputNode)
|
||||
@ -40,6 +47,11 @@ defineExpose({
|
||||
getTextWidth,
|
||||
select: () => inputNode.value?.select(),
|
||||
focus: () => inputNode.value?.focus(),
|
||||
blur: () => inputNode.value?.blur(),
|
||||
cancel: () => {
|
||||
innerModel.value = model.value
|
||||
inputNode.value?.blur()
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -52,6 +64,7 @@ defineExpose({
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
@keydown.enter.stop="onEnterDown"
|
||||
@input="emit('input', innerModel)"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
|
@ -11,6 +11,10 @@ const { provideFn, injectFn } = createContextStore(
|
||||
export class InteractionHandler {
|
||||
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. */
|
||||
setWhen(active: WatchSource<boolean>, interaction: Interaction) {
|
||||
watch(active, (active) => {
|
||||
@ -23,7 +27,7 @@ export class InteractionHandler {
|
||||
}
|
||||
|
||||
setCurrent(interaction: Interaction | undefined) {
|
||||
if (interaction !== this.currentInteraction) {
|
||||
if (!this.isActive(interaction)) {
|
||||
this.currentInteraction?.cancel?.()
|
||||
this.currentInteraction = interaction
|
||||
}
|
||||
@ -31,12 +35,12 @@ export class InteractionHandler {
|
||||
|
||||
/** Unset the current interaction, if it is the specified instance. */
|
||||
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(interaction: Interaction) {
|
||||
if (this.currentInteraction === interaction) this.setCurrent(undefined)
|
||||
if (this.isActive(interaction)) this.setCurrent(undefined)
|
||||
}
|
||||
|
||||
handleCancel(): boolean {
|
||||
|
@ -6,6 +6,7 @@ import type { Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MutableModule } from '@/util/ast/abstract.ts'
|
||||
import { computed, shallowReactive, type Component, type PropType } from 'vue'
|
||||
import type { WidgetEditHandler } from './widgetRegistry/editHandler'
|
||||
|
||||
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
|
||||
|
||||
@ -104,6 +105,7 @@ export interface WidgetInput {
|
||||
dynamicConfig?: WidgetConfiguration | undefined
|
||||
/** Force the widget to be a connectible port. */
|
||||
forcePort?: boolean
|
||||
editHandler?: WidgetEditHandler
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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()
|
||||
}
|
||||
},
|
||||
)
|
133
app/gui2/src/providers/widgetRegistry/editHandler.ts
Normal file
133
app/gui2/src/providers/widgetRegistry/editHandler.ts
Normal 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
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { useEvent } from '@/composables/events'
|
||||
import type { Opt } from 'shared/util/data/opt'
|
||||
import { watchEffect, type Ref } from 'vue'
|
||||
|
||||
/** 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. */
|
||||
export function targetIsOutside(
|
||||
e: Event,
|
||||
area: Ref<HTMLElement | SVGElement | MathMLElement | undefined>,
|
||||
): boolean {
|
||||
return !!area.value && e.target instanceof Element && !area.value.contains(e.target)
|
||||
export function targetIsOutside(e: Event, area: Opt<Element>): boolean {
|
||||
return !!area && e.target instanceof Element && !area.contains(e.target)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user