mirror of
https://github.com/enso-org/enso.git
synced 2024-12-25 03:43:41 +03:00
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:
parent
d817df94a5
commit
7f5b2edbf5
@ -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)
|
||||
}
|
||||
|
@ -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'],
|
||||
|
@ -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"
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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 = {
|
||||
|
@ -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()
|
||||
})
|
||||
|
@ -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 + ')?'
|
||||
}
|
||||
|
@ -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 */
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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">
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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" />
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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)" />
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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'" />
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -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()
|
||||
|
@ -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()
|
||||
|
@ -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]!)
|
||||
}
|
||||
|
3
app/gui2/src/util/regexp.ts
Normal file
3
app/gui2/src/util/regexp.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default function escapeStringRegexp(s: string) {
|
||||
return s.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&')
|
||||
}
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user