Fix interfering click handlers (#9127)

Fixes one failure in #8942 which caught a real issue: clicks at various panels were triggering many handlers at once, often unexpectedly - for example quick clicking at breadcrumbs (and automatic tests always click fast) we could also trigger getting out the current node.

Therefore, I added `stopPropagation` for all mouse events on "panel" level + where I think the event should be considered "handled" and no longer bother anyone. Also, to unify things, most actions are for `click` event.

Additionally I spotted and fixed some issues:
* When "clicking-off" Component Browser, it creates new node only if anything was actually typed in (no more dangling `operatorX.` nodes)
* Filtering now works for operators
* When, after opening CB with source node, user starts typing operator, we replace dot with space
* Fixed our shortcut handler, so it works properly with `click` event.
* Fixed problems with defocusing input in CB when clicking at links.

# Important Notes
I removed `PointerMain` binding for deselectAll, because it was triggered every time did the area selection.
This commit is contained in:
Adam Obuchowicz 2024-02-22 16:18:28 +01:00 committed by GitHub
parent d817df94a5
commit 7f5b2edbf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 324 additions and 141 deletions

View File

@ -27,18 +27,20 @@ test('Leaving entered nodes', async ({ page }) => {
await actions.goToGraph(page)
await enterToFunc2(page)
await locate.graphEditor(page).dblclick()
await page.mouse.dblclick(100, 100)
await expectInsideFunc1(page)
await locate.graphEditor(page).dblclick()
await page.mouse.dblclick(100, 100)
await expectInsideMain(page)
})
test('Using breadcrumbs to navigate', async ({ page }) => {
await actions.goToGraph(page)
await enterToFunc2(page)
await locate.graphEditor(page).dblclick()
await locate.graphEditor(page).dblclick()
await page.mouse.dblclick(100, 100)
await expectInsideFunc1(page)
await page.mouse.dblclick(100, 100)
await expectInsideMain(page)
// Breadcrumbs still have all the crumbs, but the last two are dimmed.
await expect(locate.navBreadcrumb(page)).toHaveText(['main', 'func1', 'func2'])
await expect(locate.navBreadcrumb(page, (f) => f.class('inactive'))).toHaveText([
@ -61,9 +63,19 @@ test('Collapsing nodes', async ({ page }) => {
const initialNodesCount = await locate.graphNode(page).count()
await mockCollapsedFunctionInfo(page, 'final', 'func1')
await locate.graphNodeByBinding(page, 'ten').click({ modifiers: ['Shift'] })
await locate.graphNodeByBinding(page, 'sum').click({ modifiers: ['Shift'] })
await locate.graphNodeByBinding(page, 'prod').click({ modifiers: ['Shift'] })
// Widgets may "steal" clicks, so we always click at icon.
await locate
.graphNodeByBinding(page, 'ten')
.locator('.icon')
.click({ modifiers: ['Shift'] })
await locate
.graphNodeByBinding(page, 'sum')
.locator('.icon')
.click({ modifiers: ['Shift'] })
await locate
.graphNodeByBinding(page, 'prod')
.locator('.icon')
.click({ modifiers: ['Shift'] })
await page.keyboard.press(COLLAPSE_SHORTCUT)
await expect(locate.graphNode(page)).toHaveCount(initialNodesCount - 2)
@ -77,7 +89,10 @@ test('Collapsing nodes', async ({ page }) => {
await customExpect.toExist(locate.graphNodeByBinding(page, 'sum'))
await customExpect.toExist(locate.graphNodeByBinding(page, 'prod'))
locate.graphNodeByBinding(page, 'ten').click({ modifiers: ['Shift'] })
locate
.graphNodeByBinding(page, 'ten')
.locator('.icon')
.click({ modifiers: ['Shift'] })
// Wait till node is selected.
await expect(locate.graphNodeByBinding(page, 'ten').and(page.locator('.selected'))).toHaveCount(1)
await page.keyboard.press(COLLAPSE_SHORTCUT)
@ -118,6 +133,8 @@ async function expectInsideFunc2(page: Page) {
async function enterToFunc2(page: Page) {
await mockCollapsedFunctionInfo(page, 'final', 'func1')
await locate.graphNodeByBinding(page, 'final').dblclick()
await expectInsideFunc1(page)
await mockCollapsedFunctionInfo(page, 'f2', 'func2')
await locate.graphNodeByBinding(page, 'f2').dblclick()
await expectInsideFunc2(page)
}

View File

@ -28,7 +28,7 @@ export const graphBindings = defineKeybinds('graph-editor', {
deleteSelected: ['OsDelete'],
zoomToSelected: ['Mod+Shift+A'],
selectAll: ['Mod+A'],
deselectAll: ['Escape', 'PointerMain'],
deselectAll: ['Escape'],
copyNode: ['Mod+C'],
pasteNode: ['Mod+V'],
collapse: ['Mod+G'],

View File

@ -18,7 +18,12 @@ const emit = defineEmits<{
</script>
<template>
<div :class="`${props.isFullMenuVisible ? 'CircularMenu full' : 'CircularMenu partial'}`">
<div
:class="`${props.isFullMenuVisible ? 'CircularMenu full' : 'CircularMenu partial'}`"
@pointerdown.stop
@pointerup.stop
@click.stop
>
<ToggleIcon
icon="eye"
class="icon-container button slot5"
@ -26,7 +31,7 @@ const emit = defineEmits<{
:modelValue="props.isVisualizationVisible"
@update:modelValue="emit('update:isVisualizationVisible', $event)"
/>
<SvgIcon name="edit" class="icon-container button slot6" @pointerdown="emit('startEditing')" />
<SvgIcon name="edit" class="icon-container button slot6" @click.stop="emit('startEditing')" />
<ToggleIcon
:icon="props.isOutputContextEnabledGlobally ? 'no_auto_replay' : 'auto_replay'"
class="icon-container button slot7"

View File

@ -333,6 +333,8 @@ const editorStyle = computed(() => {
@keydown.delete.stop
@wheel.stop.passive
@pointerdown.stop
@pointerup.stop
@click.stop
@contextmenu.stop
>
<div class="resize-handle" v-on="resize.events" @dblclick="resetSize">

View File

@ -43,7 +43,6 @@ const props = defineProps<{
const emit = defineEmits<{
accepted: [searcherExpression: string, requiredImports: RequiredImport[]]
closed: [searcherExpression: string, requiredImports: RequiredImport[]]
canceled: []
}>()
@ -107,8 +106,10 @@ function readInputFieldSelection() {
inputField.value.selectionStart != null &&
inputField.value.selectionEnd != null
) {
input.selection.value.start = inputField.value.selectionStart
input.selection.value.end = inputField.value.selectionEnd
input.selection.value = {
start: inputField.value.selectionStart,
end: inputField.value.selectionEnd,
}
}
}
// HTMLInputElement's same event is not supported in chrome yet. We just react for any
@ -146,6 +147,17 @@ function handleDefocus(e: FocusEvent) {
}
}
/** Prevent default on an event if input is not its target.
*
* The mouse events emitted on other elements may make input selection disappear, what we want to
* avoid.
*/
function preventNonInputDefault(e: Event) {
if (inputField.value != null && e.target !== inputField.value) {
e.preventDefault()
}
}
useEvent(
window,
'pointerdown',
@ -153,7 +165,11 @@ useEvent(
if (event.button !== 0) return
if (!(event.target instanceof Element)) return
if (!cbRoot.value?.contains(event.target)) {
emit('closed', input.code.value, input.importsToAdd())
if (input.anyChange.value) {
emit('accepted', input.code.value, input.importsToAdd())
} else {
emit('canceled')
}
}
},
{ capture: true },
@ -363,7 +379,9 @@ const handler = componentBrowserBindings.handler({
tabindex="-1"
@focusout="handleDefocus"
@keydown="handler"
@pointerdown.stop
@pointerdown.stop="preventNonInputDefault"
@pointerup.stop="preventNonInputDefault"
@click.stop="preventNonInputDefault"
@keydown.enter.stop
@keydown.backspace.stop
@keydown.delete.stop

View File

@ -181,8 +181,16 @@ test.each([
expect(filtering.filter(entry)).toBeNull()
})
test.each(['bar', 'barfoo', 'fo', 'bar_fo_bar'])("%s is not matched by pattern 'foo'", (name) => {
const pattern = 'foo'
test.each`
name | pattern
${'bar'} | ${'foo'}
${'barfoo'} | ${'foo'}
${'fo'} | ${'foo'}
${'bar_fo_bar'} | ${'foo'}
${'bar'} | ${'+'}
${'*'} | ${'+'}
${'<='} | ${'='}
`('$name is not matched by pattern $pattern', ({ name, pattern }) => {
const entry = makeModuleMethod(`local.Project.${name}`)
const filtering = new Filtering({ pattern })
expect(filtering.filter(entry)).toBeNull()
@ -281,6 +289,43 @@ test('Matching pattern with underscores', () => {
}
})
test('Matching operators', () => {
const pattern = '+'
const filtering = new Filtering({
pattern,
selfArg: { type: 'known', typename: 'local.Project.Type' },
})
const matchedSorted = [
{ name: '+' }, // exact match
{ name: '+=' }, // prefix match
{ name: 'add', aliases: ['+'] }, // alias exact match
{ name: 'increase', aliases: ['+='] }, // alias match
]
const matchResults = Array.from(matchedSorted, ({ name, aliases }) => {
const entry = { ...makeMethod(`local.Project.Type.${name}`), aliases: aliases ?? [] }
return filtering.filter(entry)
})
expect(matchResults[0]).not.toBeNull()
expect(
matchedText(matchedSorted[0]!.name, matchResults[0]!),
`matchedText('${matchedSorted[0]!.name}')`,
).toEqual(pattern)
for (let i = 1; i < matchResults.length; i++) {
expect(
matchResults[i],
`\`matchResults\` for ${JSON.stringify(matchedSorted[i]!)}`,
).not.toBeNull()
expect(
matchResults[i]!.score,
`score('${matchedSorted[i]!.name}') > score('${matchedSorted[i - 1]!.name}')`,
).toBeGreaterThan(matchResults[i - 1]!.score)
expect(
matchedText(matchedSorted[i]!.name, matchResults[i]!),
`matchedText('${matchedSorted[i]!.name}')`,
).toEqual(pattern)
}
})
test('Unstable filtering', () => {
const stableEntry = makeStaticMethod('local.Project.Type.stable')
const unstableEntry = {

View File

@ -1,5 +1,5 @@
import { useComponentBrowserInput } from '@/components/ComponentBrowser/input'
import { GraphDb } from '@/stores/graph/graphDatabase'
import { asNodeId, GraphDb } from '@/stores/graph/graphDatabase'
import type { RequiredImport } from '@/stores/graph/imports'
import { ComputedValueRegistry } from '@/stores/project/computedValueRegistry'
import { SuggestionDb } from '@/stores/suggestionDatabase'
@ -20,9 +20,28 @@ import { tryIdentifier, tryQualifiedName } from '@/util/qualifiedName'
import { initializeFFI } from 'shared/ast/ffi'
import type { ExternalId, Uuid } from 'shared/yjsModel'
import { expect, test } from 'vitest'
import { nextTick } from 'vue'
await initializeFFI()
const operator1Id = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as AstId
const operator1ExternalId = operator1Id as Uuid as ExternalId
const operator2Id = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as AstId
function mockGraphDb() {
const computedValueRegistryMock = ComputedValueRegistry.Mock()
computedValueRegistryMock.db.set(operator1ExternalId, {
typename: 'Standard.Base.Number',
methodCall: undefined,
payload: { type: 'Value' },
profilingInfo: [],
})
const db = GraphDb.Mock(computedValueRegistryMock)
db.mockNode('operator1', operator1Id, 'Data.read')
db.mockNode('operator2', operator2Id)
return db
}
test.each([
['', 0, { type: 'insert', position: 0 }, {}],
[
@ -101,21 +120,7 @@ test.each([
selfArg?: { type: string; typename?: string }
},
) => {
const operator1Id = '3d0e9b96-3ca0-4c35-a820-7d3a1649de55' as AstId
const operator1ExternalId = operator1Id as Uuid as ExternalId
const operator2Id = '5eb16101-dd2b-4034-a6e2-476e8bfa1f2b' as AstId
const computedValueRegistryMock = ComputedValueRegistry.Mock()
computedValueRegistryMock.db.set(operator1ExternalId, {
typename: 'Standard.Base.Number',
methodCall: undefined,
payload: { type: 'Value' },
profilingInfo: [],
})
const mockGraphDb = GraphDb.Mock(computedValueRegistryMock)
mockGraphDb.mockNode('operator1', operator1Id)
mockGraphDb.mockNode('operator2', operator2Id)
const input = useComponentBrowserInput(mockGraphDb, new SuggestionDb())
const input = useComponentBrowserInput(mockGraphDb(), new SuggestionDb())
input.code.value = code
input.selection.value = { start: cursorPos, end: cursorPos }
const context = input.context.value
@ -359,3 +364,57 @@ test.each([
expect(input.importsToAdd()).toEqual(expectedImports)
},
)
test.each`
typed | finalCodeWithSourceNode
${'foo'} | ${'operator1.foo'}
${' '} | ${'operator1. '}
${'+'} | ${'operator1 +'}
${'>='} | ${'operator1 >='}
`('Initialize input for new node and type $typed', async ({ typed, finalCodeWithSourceNode }) => {
const mockDb = mockGraphDb()
const sourceNode = operator1Id
const sourcePort = mockDb.getNodeFirstOutputPort(asNodeId(sourceNode))
const input = useComponentBrowserInput(mockDb, new SuggestionDb())
// Without source node
input.reset({ type: 'newNode' })
expect(input.code.value).toBe('')
expect(input.selection.value).toEqual({ start: 0, end: 0 })
expect(input.anyChange.value).toBeFalsy()
input.code.value = typed
input.selection.value.start = input.selection.value.end = typed.length
await nextTick()
expect(input.code.value).toBe(typed)
expect(input.selection.value).toEqual({ start: typed.length, end: typed.length })
expect(input.anyChange.value).toBeTruthy()
// With source node
input.reset({ type: 'newNode', sourcePort })
expect(input.code.value).toBe('operator1.')
expect(input.selection.value).toEqual({ start: 10, end: 10 })
expect(input.anyChange.value).toBeFalsy()
input.code.value = `operator1.${typed}`
input.selection.value.start = input.selection.value.end = typed.length + 10
await nextTick()
expect(input.code.value).toBe(finalCodeWithSourceNode)
expect(input.selection.value).toEqual({
start: finalCodeWithSourceNode.length,
end: finalCodeWithSourceNode.length,
})
expect(input.anyChange.value).toBeTruthy()
})
test('Initialize input for edited node', async () => {
const input = useComponentBrowserInput(mockGraphDb(), new SuggestionDb())
input.reset({ type: 'editNode', node: asNodeId(operator1Id), cursorPos: 4 })
expect(input.code.value).toBe('Data.read')
expect(input.selection.value).toEqual({ start: 4, end: 4 })
expect(input.anyChange.value).toBeFalsy()
// Typing anything should not affected existing code (in contrary to new node creation)
input.code.value = `Data.+.read`
input.selection.value.start = input.selection.value.end = 5
await nextTick()
expect(input.code.value).toEqual('Data.+.read')
expect(input.selection.value).toEqual({ start: 5, end: 5 })
expect(input.anyChange.value).toBeTruthy()
})

View File

@ -6,6 +6,7 @@ import {
import type { Opt } from '@/util/data/opt'
import { Range } from '@/util/data/range'
import { qnIsTopElement, qnParent, type QualifiedName } from '@/util/qualifiedName'
import escapeStringRegexp from '@/util/regexp'
export type SelfArg =
| {
@ -58,7 +59,7 @@ class FilteringWithPattern {
// - The unmatched rest of the word, up to, but excluding, the next underscore
// - The unmatched words before the next matched word, including any underscores
this.wordMatchRegex = new RegExp(
'(^|.*?_)(' + pattern.replace(/_/g, ')([^_]*)(.*?)(_') + ')([^_]*)(.*)',
'(^|.*?_)(' + escapeStringRegexp(pattern).replace(/_/g, ')([^_]*)(.*?)(_') + ')([^_]*)(.*)',
'i',
)
if (pattern.length > 1 && !/_/.test(pattern)) {
@ -302,10 +303,11 @@ export class Filtering {
let prefix = ''
let suffix = ''
for (const [, text, separator] of this.fullPattern.matchAll(/(.+?)([._]|$)/g)) {
const escaped = escapeStringRegexp(text ?? '')
const segment =
separator === '_'
? `()(${text})([^_.]*)(_)`
: `([^.]*_)?(${text})([^.]*)(${separator === '.' ? '\\.' : ''})`
? `()(${escaped})([^_.]*)(_)`
: `([^.]*_)?(${escaped})([^.]*)(${separator === '.' ? '\\.' : ''})`
prefix = '(?:' + prefix
suffix += segment + ')?'
}

View File

@ -10,7 +10,7 @@ import {
type Typename,
} from '@/stores/suggestionDatabase/entry'
import { RawAst, RawAstExtended, astContainingChar } from '@/util/ast'
import type { AstId } from '@/util/ast/abstract.ts'
import { isOperator, type AstId } from '@/util/ast/abstract.ts'
import { AliasAnalyzer } from '@/util/ast/aliasAnalysis'
import { GeneralOprApp, type OperatorChain } from '@/util/ast/opr'
import { MappedSet } from '@/util/containers'
@ -23,7 +23,7 @@ import {
} from '@/util/qualifiedName'
import { equalFlat } from 'lib0/array'
import { sourceRangeKey, type SourceRange } from 'shared/yjsModel'
import { computed, ref, type ComputedRef } from 'vue'
import { computed, nextTick, ref, watch, type ComputedRef } from 'vue'
/** Information how the component browser is used, needed for proper input initializing. */
export type Usage =
@ -70,10 +70,42 @@ export function useComponentBrowserInput(
suggestionDb: SuggestionDb = useSuggestionDbStore().entries,
) {
const code = ref('')
const cbUsage = ref<Usage>()
const anyChange = ref(false)
const selection = ref({ start: 0, end: 0 })
const ast = computed(() => RawAstExtended.parse(code.value))
const imports = ref<RequiredImport[]>([])
// Code Model to being edited externally (by user).
//
// Some user actions (like typing operator right after input) may handled differently than
// internal changes (like applying suggestion).
const codeModel = computed({
get: () => code.value,
set: (newValue) => {
if (newValue != code.value) {
// When user, right after opening CB with source node types operator, we should
// re-initialize input with it instead of dot at the end.
const startedTyping = !anyChange.value && newValue.startsWith(code.value)
const typed = newValue.substring(code.value.length)
code.value = newValue
anyChange.value = true
if (
startedTyping &&
cbUsage.value?.type === 'newNode' &&
cbUsage.value?.sourcePort &&
isOperator(typed)
) {
const sourcePort = cbUsage.value.sourcePort
// We do any code updates in next tick, as in the current we may yet expect selection
// changes we would like to override.
nextTick(() => setSourceNode(sourcePort, ` ${typed}`))
}
}
},
})
const context: ComputedRef<EditingContext> = computed(() => {
const cursorPosition = selection.value.start
if (cursorPosition === 0) return { type: 'insert', position: 0 }
@ -414,10 +446,7 @@ export function useComponentBrowserInput(
switch (usage.type) {
case 'newNode':
if (usage.sourcePort) {
const sourceNodeName = graphDb.getOutputPortIdentifier(usage.sourcePort)
code.value = sourceNodeName ? sourceNodeName + '.' : ''
const caretPosition = code.value.length
selection.value = { start: caretPosition, end: caretPosition }
setSourceNode(usage.sourcePort)
} else {
code.value = ''
selection.value = { start: 0, end: 0 }
@ -429,11 +458,21 @@ export function useComponentBrowserInput(
break
}
imports.value = []
cbUsage.value = usage
anyChange.value = false
}
function setSourceNode(sourcePort: AstId, operator: string = '.') {
const sourceNodeName = graphDb.getOutputPortIdentifier(sourcePort)
code.value = sourceNodeName ? `${sourceNodeName}${operator}` : ''
selection.value = { start: code.value.length, end: code.value.length }
}
return {
/** The current input's text (code). */
code,
code: codeModel,
/** A flag indicating that input was changed after last reset. */
anyChange,
/** The current selection (or cursor position if start is equal to end). */
selection,
/** The editing context deduced from code and selection */

View File

@ -9,7 +9,7 @@ const emit = defineEmits<{ click: [] }>()
<template>
<div class="Breadcrumb">
<SvgIcon v-if="props.icon" :name="props.icon || ''" />
<span @pointerdown="emit('click')" v-text="props.text"></span>
<span @click.stop="emit('click')" v-text="props.text"></span>
</div>
</template>

View File

@ -33,13 +33,13 @@ function shrinkFactor(index: number): number {
name="arrow_left"
draggable="false"
:class="['icon', 'button', 'arrow', { inactive: !props.canGoBackward }]"
@pointerdown="emit('backward')"
@click.stop="emit('backward')"
/>
<SvgIcon
name="arrow_right"
draggable="false"
:class="['icon', 'button', 'arrow', { inactive: !props.canGoForward }]"
@pointerdown="emit('forward')"
@click.stop="emit('forward')"
/>
</div>
<TransitionGroup name="breadcrumbs">

View File

@ -48,10 +48,7 @@ const annotations = computed<Array<string | undefined>>(() => {
<template>
<ul v-if="props.items.items.length > 0">
<li v-for="(item, index) in props.items.items" :key="index" :class="props.items.kind">
<a
:class="['link', props.items.kind]"
@pointerdown.stop.prevent="emit('linkClicked', item.id)"
>
<a :class="['link', props.items.kind]" @click.stop="emit('linkClicked', item.id)">
<span class="entryName">{{ qnSplit(item.name)[1] }}</span>
<span class="arguments">{{ ' ' + argumentsList(item.arguments) }}</span>
</a>

View File

@ -25,7 +25,7 @@ useEvent(document, 'pointerdown', onDocumentClick)
<template>
<div ref="executionModeSelectorNode" class="ExecutionModeSelector">
<div class="execution-mode-button">
<div class="execution-mode button" @pointerdown.stop="isDropdownOpen = !isDropdownOpen">
<div class="execution-mode button" @click.stop="isDropdownOpen = !isDropdownOpen">
<span v-text="props.modelValue"></span>
</div>
<div class="divider"></div>
@ -33,7 +33,7 @@ useEvent(document, 'pointerdown', onDocumentClick)
name="workflow_play"
class="play button"
draggable="false"
@pointerdown="
@click.stop="
() => {
isDropdownOpen = false
emit('execute')
@ -47,7 +47,7 @@ useEvent(document, 'pointerdown', onDocumentClick)
<span
v-if="modelValue !== otherMode"
class="button"
@pointerdown="emit('update:modelValue', otherMode), (isDropdownOpen = false)"
@click.stop="emit('update:modelValue', otherMode), (isDropdownOpen = false)"
v-text="otherMode"
></span>
</template>

View File

@ -317,7 +317,7 @@ const graphBindingsHandler = graphBindings.handler({
},
})
const handleClick = useDoubleClick(
const { handleClick } = useDoubleClick(
(e: MouseEvent) => {
graphBindingsHandler(e)
},
@ -325,7 +325,7 @@ const handleClick = useDoubleClick(
if (keyboardBusy()) return false
stackNavigator.exitNode()
},
).handleClick
)
const codeEditorArea = ref<HTMLElement>()
const showCodeEditor = ref(false)
const codeEditorHandler = codeEditorBindings.handler({
@ -654,7 +654,6 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
:nodePosition="componentBrowserNodePosition"
:usage="componentBrowserUsage"
@accepted="onComponentBrowserCommit"
@closed="onComponentBrowserCommit"
@canceled="onComponentBrowserCancel"
/>
<TopBar
@ -673,7 +672,11 @@ function handleEdgeDrop(source: AstId, position: Vec2) {
@zoomIn="graphNavigator.scale *= 1.1"
@zoomOut="graphNavigator.scale *= 0.9"
/>
<PlusButton @pointerdown="interaction.setCurrent(creatingNodeFromButton)" />
<PlusButton
@click.stop="interaction.setCurrent(creatingNodeFromButton)"
@pointerdown.stop
@pointerup.stop
/>
<Transition>
<Suspense ref="codeEditorArea">
<CodeEditor v-if="showCodeEditor" />

View File

@ -150,6 +150,9 @@ const startEpochMs = ref(0)
let startEvent: PointerEvent | null = null
let startPos = Vec2.Zero
// TODO[ao]: Now, the dragPointer.events are preventing `click` events on widgets if they don't
// stop pointerup and pointerdown. Now we ensure that any widget handling click does that, but
// instead `usePointer` should be smarter.
const dragPointer = usePointer((pos, event, type) => {
if (type !== 'start') {
const fullOffset = pos.absolute.sub(startPos)
@ -170,6 +173,7 @@ const dragPointer = usePointer((pos, event, type) => {
pos.absolute.distanceSquared(startPos) <= MAXIMUM_CLICK_DISTANCE_SQ
) {
nodeSelection?.handleSelectionOf(startEvent, new Set([nodeId.value]))
handleNodeClick(event)
menuVisible.value = MenuState.Partial
}
startEvent = null
@ -413,7 +417,14 @@ function openFullMenu() {
@update:id="emit('update:visualizationId', $event)"
@update:visible="emit('update:visualizationVisible', $event)"
/>
<div ref="contentNode" class="node" @pointerdown="handleNodeClick" v-on="dragPointer.events">
<div
ref="contentNode"
class="node"
v-on="dragPointer.events"
@click.stop
@pointerdown.stop
@pointerup.stop
>
<NodeWidgetTree
:ast="displayedExpression"
:nodeId="nodeId"

View File

@ -83,7 +83,7 @@ provideWidgetTree(
v-if="!props.connectedSelfArgumentId"
class="icon grab-handle"
:name="props.icon"
@pointerdown.right.stop="emit('openFullMenu')"
@click.right.stop.prevent="emit('openFullMenu')"
/>
<NodeWidget :input="rootPort" @update="handleWidgetUpdates" />
</div>

View File

@ -90,10 +90,13 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script>
<template>
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
<CheckboxWidget
v-model="value"
class="WidgetCheckbox"
contenteditable="false"
@beforeinput.stop
@pointerdown.stop
@pointerup.stop
/>
</template>

View File

@ -52,7 +52,14 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script>
<template>
<NumericInputWidget v-model="value" class="WidgetNumber r-24" :limits="limits" />
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
<NumericInputWidget
v-model="value"
class="WidgetNumber r-24"
:limits="limits"
@pointerdown.stop
@pointerup.stop
/>
</template>
<style scoped>

View File

@ -171,7 +171,8 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script>
<template>
<div class="WidgetSelection" @pointerdown.stop="toggleDropdownWidget">
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
<div class="WidgetSelection" @pointerdown.stop @pointerup.stop @click.stop="toggleDropdownWidget">
<NodeWidget ref="childWidgetRef" :input="innerWidgetInput" />
<SvgIcon name="arrow_right_head_only" class="arrow" />
<DropdownWidget
@ -180,7 +181,6 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
:color="'var(--node-color-primary)'"
:values="tagLabels"
:selectedValue="selectedLabel"
@pointerdown.stop
@click="onClick($event)"
/>
</div>

View File

@ -21,7 +21,7 @@ export const widgetDefinition = defineWidget(WidgetInput.isAst, {
</script>
<template>
<SvgIcon class="icon" :name="icon" @pointerdown.right.stop="tree.emitOpenFullMenu()" />
<SvgIcon class="icon" :name="icon" @click.right.stop.prevent="tree.emitOpenFullMenu()" />
</template>
<style scoped>

View File

@ -36,7 +36,8 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
</script>
<template>
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" />
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown -->
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" @pointerdown.stop />
</template>
<style scoped>

View File

@ -11,7 +11,7 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe
</script>
<template>
<div class="NavBar">
<div class="NavBar" @pointerdown.stop @pointerup.stop @click.stop>
<SvgIcon name="graph_editor" draggable="false" class="icon" />
<div class="breadcrumbs-controls">
<SvgIcon
@ -19,14 +19,14 @@ const emit = defineEmits<{ back: []; forward: []; breadcrumbClick: [index: numbe
draggable="false"
class="icon button"
:class="{ inactive: !props.allowNavigationLeft }"
@pointerdown="emit('back')"
@click.stop="emit('back')"
/>
<SvgIcon
name="arrow_right"
draggable="false"
class="icon button"
:class="{ inactive: !props.allowNavigationRight }"
@pointerdown="emit('forward')"
@click.stop="emit('forward')"
/>
</div>
<NavBreadcrumbs :breadcrumbs="props.breadcrumbs" @selected="emit('breadcrumbClick', $event)" />

View File

@ -1,11 +1,10 @@
<script setup lang="ts">
const props = defineProps<{ text: string; active: boolean }>()
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<div :class="['NavBreadcrumb', { inactive: !props.active }]">
<span @click="emit('click')" v-text="props.text"></span>
<span v-text="props.text"></span>
</div>
</template>

View File

@ -23,7 +23,7 @@ const emit = defineEmits<{ selected: [index: number] }>()
<NavBreadcrumb
:text="breadcrumb.label"
:active="breadcrumb.active"
@pointerdown="emit('selected', index)"
@click.stop="emit('selected', index)"
/>
</template>
</div>

View File

@ -6,7 +6,7 @@ const emit = defineEmits<{ execute: []; 'update:mode': [mode: string] }>()
</script>
<template>
<div class="ProjectTitle">
<div class="ProjectTitle" @pointerdown.stop @pointerup.stop @click.stop>
<span class="title" v-text="props.title"></span>
<ExecutionModeSelector
:modes="props.modes"

View File

@ -22,6 +22,6 @@ const emit = defineEmits<{
<SvgIcon
:name="props.icon"
:class="{ toggledOn: modelValue }"
@pointerdown.stop="emit('update:modelValue', !modelValue)"
@click.stop="emit('update:modelValue', !modelValue)"
/>
</template>

View File

@ -105,6 +105,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
'--color-visualization-bg': config.background,
'--node-height': `${config.nodeSize.y}px`,
}"
@pointerdown.stop
@pointerup.stop
@click.stop
>
<div class="resizer-right" v-on="resizeRight.stop.events"></div>
<div class="resizer-bottom" v-on="resizeBottom.stop.events"></div>
@ -132,22 +135,18 @@ const resizeBottomRight = usePointer((pos, _, type) => {
invisible: config.isCircularMenuVisible,
hidden: config.fullscreen,
}"
@pointerdown.stop
@pointerup.stop
@click.stop
>
<button
class="image-button active"
@pointerdown.stop="config.hide()"
@click="config.hide()"
>
<button class="image-button active" @click.stop="config.hide()">
<SvgIcon class="icon" name="eye" alt="Hide visualization" />
</button>
</div>
<div class="toolbar">
<button
class="image-button active"
@pointerdown.stop="(config.fullscreen = !config.fullscreen), blur($event)"
@click.prevent="
isTriggeredByKeyboard($event) && (config.fullscreen = !config.fullscreen)
"
@click.stop.prevent="(config.fullscreen = !config.fullscreen), blur($event)"
>
<SvgIcon
class="icon"
@ -158,9 +157,9 @@ const resizeBottomRight = usePointer((pos, _, type) => {
<div class="icon-container">
<button
class="image-button active"
@pointerdown.stop="!isSelectorVisible && (isSelectorVisible = !isSelectorVisible)"
@click.prevent="
isTriggeredByKeyboard($event) && (isSelectorVisible = !isSelectorVisible)
@click.stop.prevent="
(!isSelectorVisible || isTriggeredByKeyboard($event)) &&
(isSelectorVisible = !isSelectorVisible)
"
>
<SvgIcon

View File

@ -40,6 +40,9 @@ onMounted(() => setTimeout(() => rootNode.value?.querySelector('button')?.focus(
ref="rootNode"
class="VisualizationSelector"
@focusout="$event.relatedTarget == null && emit('hide')"
@pointerdown.stop
@pointerup.stop
@click.stop
>
<div class="background"></div>
<ul>
@ -47,7 +50,7 @@ onMounted(() => setTimeout(() => rootNode.value?.querySelector('button')?.focus(
v-for="type_ in props.types"
:key="visIdKey(type_)"
:class="{ selected: visIdentifierEquals(props.modelValue, type_) }"
@pointerdown.stop="emit('update:modelValue', type_)"
@click.stop="emit('update:modelValue', type_)"
>
<button>
<SvgIcon class="icon" :name="visualizationStore.icon(type_) ?? 'columns_increasing'" />

View File

@ -535,10 +535,10 @@ useEvent(document, 'keydown', bindings.handler({ zoomToSelected: () => zoomToSel
<VisualizationContainer :belowToolbar="true">
<template #toolbar>
<button class="image-button active">
<SvgIcon name="show_all" alt="Fit all" @pointerdown="zoomToSelected(false)" />
<SvgIcon name="show_all" alt="Fit all" @click.stop="zoomToSelected(false)" />
</button>
<button class="image-button" :class="{ active: brushExtent != null }">
<SvgIcon name="find" alt="Zoom to selected" @pointerdown="zoomToSelected" />
<SvgIcon name="find" alt="Zoom to selected" @click.stop="zoomToSelected" />
</button>
</template>
<div ref="containerNode" class="ScatterplotVisualization" @pointerdown.stop>

View File

@ -4,17 +4,7 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: boolean] }>()
</script>
<template>
<div
class="Checkbox r-24"
@pointerdown="
!$event.ctrlKey &&
!$event.shiftKey &&
!$event.altKey &&
!$event.metaKey &&
$event.stopImmediatePropagation()
"
@click="emit('update:modelValue', !props.modelValue)"
>
<div class="Checkbox r-24" @click.stop="emit('update:modelValue', !props.modelValue)">
<div :class="{ hidden: !props.modelValue }"></div>
</div>
</template>

View File

@ -47,15 +47,15 @@ const NEXT_SORT_DIRECTION: Record<SortDirection, SortDirection> = {
</script>
<template>
<div class="Dropdown">
<div class="Dropdown" @pointerdown.stop @pointerup.stop @click.stop>
<ul class="list" :style="{ background: color }" @wheel.stop>
<template v-for="[value, index] in sortedValuesAndIndices" :key="value">
<li v-if="value === selectedValue">
<div class="selected-item button" @pointerdown="emit('click', index)">
<div class="selected-item button" @click.stop="emit('click', index)">
<span v-text="value"></span>
</div>
</li>
<li v-else class="selectable-item button" @pointerdown="emit('click', index)">
<li v-else class="selectable-item button" @click.stop="emit('click', index)">
<span v-text="value"></span>
</li>
</template>

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useResizeObserver } from '@/composables/events'
import { escape, unescape } from '@/util/ast/abstract'
import { escape, unescape } from '@/util/ast/text'
import { blurIfNecessary } from '@/util/autoBlur'
import { getTextWidthByFont } from '@/util/measurement'
import { computed, ref, watch, type StyleValue } from 'vue'

View File

@ -404,14 +404,7 @@ watchPostEffect(() => {
<SvgIcon
class="add-item"
name="vector_add"
@pointerdown="
!$event.ctrlKey &&
!$event.shiftKey &&
!$event.altKey &&
!$event.metaKey &&
$event.stopImmediatePropagation()
"
@click="emit('update:modelValue', [...props.modelValue, props.default()])"
@click.stop="emit('update:modelValue', [...props.modelValue, props.default()])"
/>
<span class="token">]</span>
</div>

View File

@ -2,7 +2,8 @@ import { assert } from '@/util/assert'
import { Ast } from '@/util/ast'
import { initializeFFI } from 'shared/ast/ffi'
import { expect, test } from 'vitest'
import { MutableModule, escape, unescape, type Identifier } from '../abstract'
import { MutableModule, type Identifier } from '../abstract'
import { escape, unescape } from '../text'
import { findExpressions, testCase, tryFindExpressions } from './testCase'
await initializeFFI()

View File

@ -1,5 +1,4 @@
import { parseEnso } from '@/util/ast'
import { swapKeysAndValues } from '@/util/record'
import type { AstId, MutableAst, NodeKey, Owned, TokenId, TokenKey } from 'shared/ast'
import {
Ast,
@ -14,31 +13,6 @@ import {
} from 'shared/ast'
export * from 'shared/ast'
const mapping: Record<string, string> = {
'\b': '\\b',
'\f': '\\f',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
'\v': '\\v',
'"': '\\"',
"'": "\\'",
'`': '``',
}
const reverseMapping = swapKeysAndValues(mapping)
/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
* NOT USABLE to insert into raw strings. Does not include quotes. */
export function escape(string: string) {
return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!)
}
/** The reverse of `escape`: transform the string into human-readable form, not suitable for interpolation. */
export function unescape(string: string) {
return string.replace(/\\[0bfnrtv"']|``/g, (match) => reverseMapping[match]!)
}
export function deserialize(serialized: string): Owned {
const parsed: SerializedPrintedSource = JSON.parse(serialized)
const module = MutableModule.Transient()

View File

@ -1,3 +1,5 @@
import { swapKeysAndValues } from '@/util/record'
const mapping: Record<string, string> = {
'\b': '\\b',
'\f': '\\f',
@ -5,14 +7,20 @@ const mapping: Record<string, string> = {
'\r': '\\r',
'\t': '\\t',
'\v': '\\v',
'\\': '\\\\',
'"': '\\"',
"'": "\\'",
'`': '``',
}
const reverseMapping = swapKeysAndValues(mapping)
/** Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
* NOT USABLE to insert into raw strings. Does not include quotes. */
export function escape(string: string) {
return string.replace(/[\0\b\f\n\r\t\v\\"'`]/g, (match) => mapping[match]!)
return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!)
}
/** The reverse of `escape`: transform the string into human-readable form, not suitable for interpolation. */
export function unescape(string: string) {
return string.replace(/\\[0bfnrtv"']|``/g, (match) => reverseMapping[match]!)
}

View File

@ -0,0 +1,3 @@
export default function escapeStringRegexp(s: string) {
return s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
}

View File

@ -333,7 +333,11 @@ export function defineKeybinds<
const keybinds =
event instanceof KeyboardEvent
? keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags]
: mouseShortcuts[event.buttons as PointerButtonFlags]?.[eventModifierFlags]
: mouseShortcuts[
(event.type === 'click' // `click` events don't have 'buttons' field initialized.
? POINTER_BUTTON_FLAG.PointerMain
: event.buttons) as PointerButtonFlags
]?.[eventModifierFlags]
let handle = handlers[DefaultHandler]
if (keybinds != null) {
for (const bindingName in handlers) {