mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 12:42:16 +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')
|
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', {
|
||||||
|
@ -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 {
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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>
|
||||||
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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 { 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)
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user