mirror of
https://github.com/enso-org/enso.git
synced 2024-12-24 08:42:05 +03:00
Vue dependency update, better selection performance, visible quotes in text inputs (#9204)
- Improved performance by batching simulatenous node edits, including metadata updates when dragging many selected nodes together. - Updated Vue to new version, allowing us to use `defineModel`. - Fixed #9161 - Unified all handling of auto-blur by making `useAutoBlur` cheap to register - all logic goes through a single window event handler. - Combined all `ResizeObserver`s into one. - Fixed the behaviour of repeated toast messages. Now only the latest compilation status is visible at any given time, and the errors disappear once compilation passes. - Actually fixed broken interaction of node and visualization widths. There no longer is a style feedback loop and the visible node backdrop width no longer jumps or randomly fails to update.
This commit is contained in:
parent
d4b2390fc1
commit
b7a8909818
@ -1 +1 @@
|
||||
18.14.1
|
||||
20.11.1
|
||||
|
@ -233,8 +233,8 @@ class TableVisualization extends Visualization {
|
||||
parsedData.data && parsedData.data.length > 0
|
||||
? parsedData.data[0].length
|
||||
: parsedData.indices && parsedData.indices.length > 0
|
||||
? parsedData.indices[0].length
|
||||
: 0
|
||||
? parsedData.indices[0].length
|
||||
: 0
|
||||
rowData = Array.apply(null, Array(rows)).map((_, i) => {
|
||||
const row = {}
|
||||
const shift = parsedData.indices ? parsedData.indices.length : 0
|
||||
|
@ -6,5 +6,6 @@
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"organizeImportsSkipDestructiveCodeActions": true
|
||||
"organizeImportsSkipDestructiveCodeActions": true,
|
||||
"experimentalTernaries": true
|
||||
}
|
||||
|
@ -28,8 +28,8 @@ onMounted(() => {
|
||||
</MockProjectStoreWrapper>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:is(.viewport) {
|
||||
<style>
|
||||
:deep(.viewport) {
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-code);
|
||||
font-size: 11.5px;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
import * as customExpect from './customExpect'
|
||||
import { type Page } from '@playwright/test'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
import { graphNodeByBinding } from './locate'
|
||||
|
||||
@ -12,14 +12,25 @@ export async function goToGraph(page: Page) {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.App')).toBeVisible()
|
||||
// Wait until nodes are loaded.
|
||||
await customExpect.toExist(locate.graphNode(page))
|
||||
await expect(locate.graphNode(page)).toExist()
|
||||
// Wait for position initialization
|
||||
await expectNodePositionsInitialized(page, 64)
|
||||
}
|
||||
|
||||
export async function expectNodePositionsInitialized(page: Page, yPos: number) {
|
||||
// TODO: The yPos should not need to be a variable. Instead, first automatically positioned nodes
|
||||
// should always have constant known position. This is a bug caused by incorrect layout after
|
||||
// entering a function. To be fixed with #9255
|
||||
await expect(locate.graphNode(page).first()).toHaveCSS(
|
||||
'transform',
|
||||
'matrix(1, 0, 0, 1, -16, -16)',
|
||||
`matrix(1, 0, 0, 1, -16, ${yPos})`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function exitFunction(page: Page, x = 300, y = 300) {
|
||||
await page.mouse.dblclick(x, y, { delay: 10 })
|
||||
}
|
||||
|
||||
// =================
|
||||
// === Drag Node ===
|
||||
// =================
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import os from 'os'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import { mockCollapsedFunctionInfo } from './expressionUpdates'
|
||||
import * as locate from './locate'
|
||||
|
||||
@ -27,19 +27,19 @@ test('Leaving entered nodes', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
await enterToFunc2(page)
|
||||
|
||||
await page.mouse.dblclick(100, 100)
|
||||
await actions.exitFunction(page)
|
||||
await expectInsideFunc1(page)
|
||||
|
||||
await page.mouse.dblclick(100, 100)
|
||||
await actions.exitFunction(page)
|
||||
await expectInsideMain(page)
|
||||
})
|
||||
|
||||
test('Using breadcrumbs to navigate', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
await enterToFunc2(page)
|
||||
await page.mouse.dblclick(100, 100)
|
||||
await actions.exitFunction(page)
|
||||
await expectInsideFunc1(page)
|
||||
await page.mouse.dblclick(100, 100)
|
||||
await actions.exitFunction(page)
|
||||
await expectInsideMain(page)
|
||||
// Breadcrumbs still have all the crumbs, but the last two are dimmed.
|
||||
await expect(locate.navBreadcrumb(page)).toHaveText(['Mock Project', 'func1', 'func2'])
|
||||
@ -85,9 +85,9 @@ test('Collapsing nodes', async ({ page }) => {
|
||||
|
||||
await collapsedNode.dblclick()
|
||||
await expect(locate.graphNode(page)).toHaveCount(4)
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'ten'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'sum'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'prod'))
|
||||
await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'sum')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'prod')).toExist()
|
||||
|
||||
await locate
|
||||
.graphNodeByBinding(page, 'ten')
|
||||
@ -103,31 +103,34 @@ test('Collapsing nodes', async ({ page }) => {
|
||||
await mockCollapsedFunctionInfo(page, 'ten', 'collapsed1')
|
||||
await secondCollapsedNode.dblclick()
|
||||
await expect(locate.graphNode(page)).toHaveCount(2)
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'ten'))
|
||||
await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
|
||||
})
|
||||
|
||||
async function expectInsideMain(page: Page) {
|
||||
await actions.expectNodePositionsInitialized(page, 64)
|
||||
await expect(locate.graphNode(page)).toHaveCount(10)
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'five'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'ten'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'sum'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'prod'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'final'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'list'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'data'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'aggregated'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'filtered'))
|
||||
await expect(locate.graphNodeByBinding(page, 'five')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'ten')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'sum')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'prod')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'final')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'list')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'data')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'aggregated')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'filtered')).toExist()
|
||||
}
|
||||
|
||||
async function expectInsideFunc1(page: Page) {
|
||||
await actions.expectNodePositionsInitialized(page, 192)
|
||||
await expect(locate.graphNode(page)).toHaveCount(3)
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'f2'))
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'result'))
|
||||
await expect(locate.graphNodeByBinding(page, 'f2')).toExist()
|
||||
await expect(locate.graphNodeByBinding(page, 'result')).toExist()
|
||||
}
|
||||
|
||||
async function expectInsideFunc2(page: Page) {
|
||||
await actions.expectNodePositionsInitialized(page, 128)
|
||||
await expect(locate.graphNode(page)).toHaveCount(2)
|
||||
await customExpect.toExist(locate.graphNodeByBinding(page, 'r'))
|
||||
await expect(locate.graphNodeByBinding(page, 'r')).toExist()
|
||||
}
|
||||
|
||||
async function enterToFunc2(page: Page) {
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import assert from 'assert'
|
||||
import os from 'os'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
|
||||
const CONTROL_KEY = os.platform() === 'darwin' ? 'Meta' : 'Control'
|
||||
@ -18,8 +18,8 @@ test('Different ways of opening Component Browser', async ({ page }) => {
|
||||
const nodeCount = await locate.graphNode(page).count()
|
||||
|
||||
async function expectAndCancelBrowser(expectedInput: string) {
|
||||
await customExpect.toExist(locate.componentBrowser(page))
|
||||
await customExpect.toExist(locate.componentBrowserEntry(page))
|
||||
await expect(locate.componentBrowser(page)).toExist()
|
||||
await expect(locate.componentBrowserEntry(page)).toExist()
|
||||
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(expectedInput)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(locate.componentBrowser(page)).not.toBeVisible()
|
||||
@ -79,7 +79,7 @@ test('Accepting suggestion', async ({ page }) => {
|
||||
'.',
|
||||
'read_text',
|
||||
])
|
||||
await customExpect.toBeSelected(locate.graphNode(page).last())
|
||||
await expect(locate.graphNode(page).last()).toBeSelected()
|
||||
|
||||
// Clicking at highlighted entry
|
||||
nodeCount = await locate.graphNode(page).count()
|
||||
@ -93,7 +93,7 @@ test('Accepting suggestion', async ({ page }) => {
|
||||
'.',
|
||||
'read',
|
||||
])
|
||||
await customExpect.toBeSelected(locate.graphNode(page).last())
|
||||
await expect(locate.graphNode(page).last()).toBeSelected()
|
||||
|
||||
// Accepting with Enter
|
||||
nodeCount = await locate.graphNode(page).count()
|
||||
@ -107,7 +107,7 @@ test('Accepting suggestion', async ({ page }) => {
|
||||
'.',
|
||||
'read',
|
||||
])
|
||||
await customExpect.toBeSelected(locate.graphNode(page).last())
|
||||
await expect(locate.graphNode(page).last()).toBeSelected()
|
||||
})
|
||||
|
||||
test('Accepting any written input', async ({ page }) => {
|
||||
@ -127,14 +127,14 @@ test('Filling input with suggestions', async ({ page }) => {
|
||||
|
||||
// Entering module
|
||||
await locate.componentBrowserEntryByLabel(page, 'Standard.Base.Data').click()
|
||||
await customExpect.toExist(locate.componentBrowser(page))
|
||||
await expect(locate.componentBrowser(page)).toExist()
|
||||
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(
|
||||
'Standard.Base.Data.',
|
||||
)
|
||||
|
||||
// Applying suggestion
|
||||
await page.keyboard.press('Tab')
|
||||
await customExpect.toExist(locate.componentBrowser(page))
|
||||
await expect(locate.componentBrowser(page)).toExist()
|
||||
await expect(locate.componentBrowserInput(page).locator('input')).toHaveValue(
|
||||
'Standard.Base.Data.read ',
|
||||
)
|
||||
@ -167,8 +167,8 @@ test('Editing existing nodes', async ({ page }) => {
|
||||
await expect(input).toHaveValue(`Data.read ${ADDED_PATH}`)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(locate.componentBrowser(page)).not.toBeVisible()
|
||||
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read'])
|
||||
await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH)
|
||||
await expect(node.locator('.WidgetToken')).toHaveText(['Data', '.', 'read', '"', '"'])
|
||||
await expect(node.locator('.WidgetText input')).toHaveValue(ADDED_PATH.replaceAll('"', ''))
|
||||
|
||||
// Edit again, using "edit" button
|
||||
await locate.graphNodeIcon(node).click()
|
||||
@ -187,12 +187,12 @@ test('Visualization preview: type-based visualization selection', async ({ page
|
||||
await actions.goToGraph(page)
|
||||
const nodeCount = await locate.graphNode(page).count()
|
||||
await locate.addNewNodeButton(page).click()
|
||||
await customExpect.toExist(locate.componentBrowser(page))
|
||||
await customExpect.toExist(locate.componentBrowserEntry(page))
|
||||
await expect(locate.componentBrowser(page)).toExist()
|
||||
await expect(locate.componentBrowserEntry(page)).toExist()
|
||||
const input = locate.componentBrowserInput(page).locator('input')
|
||||
await input.fill('4')
|
||||
await expect(input).toHaveValue('4')
|
||||
await customExpect.toExist(locate.jsonVisualization(page))
|
||||
await expect(locate.jsonVisualization(page)).toExist()
|
||||
await input.fill('Table.ne')
|
||||
await expect(input).toHaveValue('Table.ne')
|
||||
// The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON
|
||||
@ -207,12 +207,12 @@ test('Visualization preview: user visualization selection', async ({ page }) =>
|
||||
await actions.goToGraph(page)
|
||||
const nodeCount = await locate.graphNode(page).count()
|
||||
await locate.addNewNodeButton(page).click()
|
||||
await customExpect.toExist(locate.componentBrowser(page))
|
||||
await customExpect.toExist(locate.componentBrowserEntry(page))
|
||||
await expect(locate.componentBrowser(page)).toExist()
|
||||
await expect(locate.componentBrowserEntry(page)).toExist()
|
||||
const input = locate.componentBrowserInput(page).locator('input')
|
||||
await input.fill('4')
|
||||
await expect(input).toHaveValue('4')
|
||||
await customExpect.toExist(locate.jsonVisualization(page))
|
||||
await expect(locate.jsonVisualization(page)).toExist()
|
||||
await locate.showVisualizationSelectorButton(page).click()
|
||||
await page.getByRole('button', { name: 'Table' }).click()
|
||||
// The table visualization is not currently working with `executeExpression` (#9194), but we can test that the JSON
|
||||
|
@ -1,19 +1,52 @@
|
||||
import { expect, type Locator } from 'playwright/test'
|
||||
import { expect as baseExpect, type Locator } from 'playwright/test'
|
||||
|
||||
/** Ensures that at least one of the elements that the Locator points to,
|
||||
* is an attached and visible DOM node. */
|
||||
export function toExist(locator: Locator) {
|
||||
// Counter-intuitive, but correct:
|
||||
// https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible
|
||||
return expect(locator.first()).toBeVisible()
|
||||
}
|
||||
export const expect = baseExpect.extend({
|
||||
/** Ensures that at least one of the elements that the Locator points to,
|
||||
* is an attached and visible DOM node. */
|
||||
async toExist(locator: Locator) {
|
||||
// Counter-intuitive, but correct:
|
||||
// https://playwright.dev/docs/api/class-locatorassertions#locator-assertions-to-be-visible
|
||||
const assertionName = 'toExist'
|
||||
let pass: boolean
|
||||
try {
|
||||
await expect(locator.first()).toBeVisible()
|
||||
pass = true
|
||||
} catch (e: any) {
|
||||
console.log(e)
|
||||
pass = false
|
||||
}
|
||||
|
||||
export function toBeSelected(locator: Locator) {
|
||||
return expect(locator).toHaveClass(/(?<=^| )selected(?=$| )/)
|
||||
}
|
||||
const message = () =>
|
||||
this.utils.matcherHint(assertionName, locator, '', {
|
||||
isNot: this.isNot,
|
||||
})
|
||||
|
||||
export module not {
|
||||
export function toBeSelected(locator: Locator) {
|
||||
return expect(locator).not.toHaveClass(/(?<=^| )selected(?=$| )/)
|
||||
}
|
||||
}
|
||||
return {
|
||||
message,
|
||||
pass,
|
||||
name: assertionName,
|
||||
}
|
||||
},
|
||||
|
||||
async toBeSelected(locator: Locator) {
|
||||
const assertionName = 'toBeSelected'
|
||||
let pass: boolean
|
||||
try {
|
||||
await baseExpect(locator).toHaveClass(/(?<=^| )selected(?=$| )/, { timeout: 50 })
|
||||
pass = true
|
||||
} catch (e: any) {
|
||||
pass = false
|
||||
}
|
||||
|
||||
const message = () =>
|
||||
this.utils.matcherHint(assertionName, locator, '', {
|
||||
isNot: this.isNot,
|
||||
})
|
||||
|
||||
return {
|
||||
message,
|
||||
pass,
|
||||
name: assertionName,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { test } from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
import { graphNodeByBinding } from './locate'
|
||||
|
||||
@ -17,11 +17,11 @@ test('Load Fullscreen Visualisation', async ({ page }) => {
|
||||
const fullscreenButton = locate.enterFullscreenButton(aggregatedNode)
|
||||
await fullscreenButton.click()
|
||||
const vis = locate.jsonVisualization(page)
|
||||
await customExpect.toExist(vis)
|
||||
await customExpect.toExist(locate.exitFullscreenButton(page))
|
||||
await expect(vis).toExist()
|
||||
await expect(locate.exitFullscreenButton(page)).toExist()
|
||||
const visBoundingBox = await vis.boundingBox()
|
||||
expect(visBoundingBox!.height).toBe(808)
|
||||
expect(visBoundingBox!.width).toBe(1920)
|
||||
expect(visBoundingBox?.height).toBeGreaterThan(600)
|
||||
expect(visBoundingBox?.width).toBe(1920)
|
||||
const jsonContent = await vis.textContent().then((text) => JSON.parse(text!))
|
||||
expect(jsonContent).toEqual({
|
||||
axis: {
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { test } from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
|
||||
test('node can open and load visualization', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node = locate.graphNode(page).last()
|
||||
await node.click({ position: { x: 8, y: 8 } })
|
||||
await customExpect.toExist(locate.circularMenu(page))
|
||||
await expect(locate.circularMenu(page)).toExist()
|
||||
await locate.toggleVisualizationButton(page).click()
|
||||
await customExpect.toExist(locate.anyVisualization(page))
|
||||
await expect(locate.anyVisualization(page)).toExist()
|
||||
await locate.showVisualizationSelectorButton(page).click()
|
||||
await page.getByText('JSON').click()
|
||||
await customExpect.toExist(locate.jsonVisualization(page))
|
||||
await expect(locate.jsonVisualization(page)).toExist()
|
||||
// The default JSON viz data contains an object.
|
||||
await expect(locate.jsonVisualization(page)).toContainText('{')
|
||||
})
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { test } from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
|
||||
test('graph can open and render nodes', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
await customExpect.toExist(locate.graphEditor(page))
|
||||
await customExpect.toExist(locate.graphNode(page))
|
||||
await expect(locate.graphEditor(page)).toExist()
|
||||
await expect(locate.graphNode(page)).toExist()
|
||||
|
||||
// check simple node's content (without input widgets)
|
||||
const sumNode = locate.graphNodeByBinding(page, 'sum')
|
||||
|
@ -132,10 +132,9 @@ export function graphNodeIcon(node: Node) {
|
||||
|
||||
// === Data locators ===
|
||||
|
||||
type SanitizeClassName<T extends string> = T extends `${infer A}.${infer B}`
|
||||
? SanitizeClassName<`${A}${B}`>
|
||||
: T extends `${infer A} ${infer B}`
|
||||
? SanitizeClassName<`${A}${B}`>
|
||||
type SanitizeClassName<T extends string> =
|
||||
T extends `${infer A}.${infer B}` ? SanitizeClassName<`${A}${B}`>
|
||||
: T extends `${infer A} ${infer B}` ? SanitizeClassName<`${A}${B}`>
|
||||
: T
|
||||
|
||||
function componentLocator<T extends string>(className: SanitizeClassName<T>) {
|
||||
|
@ -3,7 +3,7 @@ import { createPinia } from 'pinia'
|
||||
import { initializeFFI } from 'shared/ast/ffi'
|
||||
import { createApp, ref } from 'vue'
|
||||
import { mockDataHandler, mockLSHandler } from '../mock/engine'
|
||||
import '../src/assets/base.css'
|
||||
import '../src/assets/main.css'
|
||||
import { provideGuiConfig } from '../src/providers/guiConfig'
|
||||
import { provideVisualizationConfig } from '../src/providers/visualizationConfig'
|
||||
import { Vec2 } from '../src/util/data/vec2'
|
||||
|
@ -1,44 +1,44 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { test } from '@playwright/test'
|
||||
import assert from 'assert'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import * as locate from './locate'
|
||||
|
||||
test('Selecting nodes by click', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node1 = locate.graphNodeByBinding(page, 'five')
|
||||
const node2 = locate.graphNodeByBinding(page, 'ten')
|
||||
await customExpect.not.toBeSelected(node1)
|
||||
await customExpect.not.toBeSelected(node2)
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
|
||||
await locate.graphNodeIcon(node1).click()
|
||||
await customExpect.toBeSelected(node1)
|
||||
await customExpect.not.toBeSelected(node2)
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
|
||||
await locate.graphNodeIcon(node2).click()
|
||||
await customExpect.not.toBeSelected(node1)
|
||||
await customExpect.toBeSelected(node2)
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
|
||||
await page.waitForTimeout(600) // Avoid double clicks
|
||||
await page.waitForTimeout(300) // Avoid double clicks
|
||||
await locate.graphNodeIcon(node1).click({ modifiers: ['Shift'] })
|
||||
await customExpect.toBeSelected(node1)
|
||||
await customExpect.toBeSelected(node2)
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
|
||||
await locate.graphNodeIcon(node2).click()
|
||||
await customExpect.not.toBeSelected(node1)
|
||||
await customExpect.toBeSelected(node2)
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
|
||||
await page.mouse.click(200, 200)
|
||||
await customExpect.not.toBeSelected(node1)
|
||||
await customExpect.not.toBeSelected(node2)
|
||||
await page.mouse.click(600, 200)
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
})
|
||||
|
||||
test('Selecting nodes by area drag', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node1 = locate.graphNodeByBinding(page, 'five')
|
||||
const node2 = locate.graphNodeByBinding(page, 'ten')
|
||||
await customExpect.not.toBeSelected(node1)
|
||||
await customExpect.not.toBeSelected(node2)
|
||||
await expect(node1).not.toBeSelected()
|
||||
await expect(node2).not.toBeSelected()
|
||||
|
||||
const node1BBox = await node1.locator('.selection').boundingBox()
|
||||
const node2BBox = await node2.boundingBox()
|
||||
@ -49,9 +49,9 @@ test('Selecting nodes by area drag', async ({ page }) => {
|
||||
await page.mouse.move(node1BBox.x - 49, node1BBox.y - 49)
|
||||
await expect(page.locator('.SelectionBrush')).toBeVisible()
|
||||
await page.mouse.move(node2BBox.x + node2BBox.width, node2BBox.y + node2BBox.height)
|
||||
await customExpect.toBeSelected(node1)
|
||||
await customExpect.toBeSelected(node2)
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
await page.mouse.up()
|
||||
await customExpect.toBeSelected(node1)
|
||||
await customExpect.toBeSelected(node2)
|
||||
await expect(node1).toBeSelected()
|
||||
await expect(node2).toBeSelected()
|
||||
})
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import { test, type Page } from '@playwright/test'
|
||||
import * as actions from './actions'
|
||||
import * as customExpect from './customExpect'
|
||||
import { expect } from './customExpect'
|
||||
import { mockExpressionUpdate } from './expressionUpdates'
|
||||
import * as locate from './locate'
|
||||
import { graphNodeByBinding } from './locate'
|
||||
@ -26,7 +26,7 @@ test('Load Table Visualisation', async ({ page }) => {
|
||||
await page.keyboard.press('Space')
|
||||
await page.waitForTimeout(1000)
|
||||
const tableVisualization = locate.tableVisualization(page)
|
||||
await customExpect.toExist(tableVisualization)
|
||||
await expect(tableVisualization).toExist()
|
||||
await expect(tableVisualization).toContainText('10 rows.')
|
||||
await expect(tableVisualization).toContainText('0,0')
|
||||
await expect(tableVisualization).toContainText('1,0')
|
||||
|
@ -1,5 +1,6 @@
|
||||
import test, { expect, type Locator, type Page } from 'playwright/test'
|
||||
import test, { type Locator, type Page } from 'playwright/test'
|
||||
import * as actions from './actions'
|
||||
import { expect } from './customExpect'
|
||||
import { mockMethodCallInfo } from './expressionUpdates'
|
||||
import * as locate from './locate'
|
||||
|
||||
@ -32,7 +33,7 @@ test('Widget in plain AST', async ({ page }) => {
|
||||
const numberNode = locate.graphNodeByBinding(page, 'five')
|
||||
const numberWidget = numberNode.locator('.WidgetNumber')
|
||||
await expect(numberWidget).toBeVisible()
|
||||
await expect(numberWidget.locator('.value')).toHaveValue('5')
|
||||
await expect(numberWidget.locator('input')).toHaveValue('5')
|
||||
|
||||
const listNode = locate.graphNodeByBinding(page, 'list')
|
||||
const listWidget = listNode.locator('.WidgetVector')
|
||||
@ -41,7 +42,7 @@ test('Widget in plain AST', async ({ page }) => {
|
||||
const textNode = locate.graphNodeByBinding(page, 'text')
|
||||
const textWidget = textNode.locator('.WidgetText')
|
||||
await expect(textWidget).toBeVisible()
|
||||
await expect(textWidget.locator('.value')).toHaveValue("'test'")
|
||||
await expect(textWidget.locator('input')).toHaveValue('test')
|
||||
})
|
||||
|
||||
test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
@ -71,7 +72,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
await onProblemsArg.click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error'])
|
||||
await dropDown.clickOption(page, 'Report_Error')
|
||||
await expect(onProblemsArg.locator('.WidgetToken')).toHaveText([
|
||||
await expect(onProblemsArg.locator('.WidgetToken')).toContainText([
|
||||
'Problem_Behavior',
|
||||
'.',
|
||||
'Report_Error',
|
||||
@ -89,7 +90,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
await page.getByText('Report_Error').click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error'])
|
||||
await dropDown.clickOption(page, 'Report_Warning')
|
||||
await expect(onProblemsArg.locator('.WidgetToken')).toHaveText([
|
||||
await expect(onProblemsArg.locator('.WidgetToken')).toContainText([
|
||||
'Problem_Behavior',
|
||||
'.',
|
||||
'Report_Warning',
|
||||
@ -101,7 +102,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
await expect(page.locator('.dropdownContainer')).toBeVisible()
|
||||
await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"'])
|
||||
await dropDown.clickOption(page, '"File 2"')
|
||||
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 2"')
|
||||
await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 2')
|
||||
|
||||
// Change value on `path` (dynamic config)
|
||||
await mockMethodCallInfo(page, 'data', {
|
||||
@ -115,7 +116,7 @@ test('Selection widgets in Data.read node', async ({ page }) => {
|
||||
await page.getByText('path').click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['"File 1"', '"File 2"'])
|
||||
await dropDown.clickOption(page, '"File 1"')
|
||||
await expect(pathArg.locator('.EnsoTextInputWidget > input')).toHaveValue('"File 1"')
|
||||
await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 1')
|
||||
})
|
||||
|
||||
test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
@ -142,7 +143,11 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
// Add first aggregate
|
||||
const columnsArg = argumentNames.filter({ has: page.getByText('columns') })
|
||||
await columnsArg.locator('.add-item').click()
|
||||
await expect(columnsArg.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By'])
|
||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Group_By',
|
||||
])
|
||||
await mockMethodCallInfo(
|
||||
page,
|
||||
{
|
||||
@ -164,7 +169,7 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
await firstItem.click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['Group_By', 'Count', 'Count_Distinct'])
|
||||
await dropDown.clickOption(page, 'Count_Distinct')
|
||||
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
|
||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Count_Distinct',
|
||||
@ -190,16 +195,16 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
await columnArg.click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"'])
|
||||
await dropDown.clickOption(page, '"column 1"')
|
||||
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
|
||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Count_Distinct',
|
||||
])
|
||||
await expect(columnsArg.locator('.EnsoTextInputWidget > input').first()).toHaveValue('"column 1"')
|
||||
await expect(columnsArg.locator('.WidgetText > input').first()).toHaveValue('column 1')
|
||||
|
||||
// Add another aggregate
|
||||
await columnsArg.locator('.add-item').click()
|
||||
await expect(columnsArg.locator('.WidgetToken')).toHaveText([
|
||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Count_Distinct',
|
||||
@ -229,8 +234,12 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
await secondColumnArg.click()
|
||||
await dropDown.expectVisibleWithOptions(page, ['"column 1"', '"column 2"'])
|
||||
await dropDown.clickOption(page, '"column 2"')
|
||||
await expect(secondItem.locator('.WidgetToken')).toHaveText(['Aggregate_Column', '.', 'Group_By'])
|
||||
await expect(secondItem.locator('.EnsoTextInputWidget > input').first()).toHaveValue('"column 2"')
|
||||
await expect(secondItem.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
'Group_By',
|
||||
])
|
||||
await expect(secondItem.locator('.WidgetText > input').first()).toHaveValue('column 2')
|
||||
|
||||
// Switch aggregates
|
||||
//TODO[ao] I have no idea how to emulate drag. Simple dragTo does not work (some element seem to capture event).
|
||||
@ -244,7 +253,7 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => {
|
||||
// await columnsArg.locator('.item > .handle').nth(0).hover({ force: true })
|
||||
// await columnsArg.locator('.item > .handle').nth(0).hover()
|
||||
// await page.mouse.up()
|
||||
// await expect(columnsArg.locator('.WidgetToken')).toHaveText([
|
||||
// await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
// 'Aggregate_Column',
|
||||
// '.',
|
||||
// 'Group_By',
|
||||
|
@ -15,6 +15,7 @@ const conf = [
|
||||
'dist',
|
||||
'shared/ast/generated',
|
||||
'templates',
|
||||
'.histoire',
|
||||
'playwright-report',
|
||||
],
|
||||
},
|
||||
|
@ -43,13 +43,14 @@ export default defineConfig({
|
||||
order(a, b) {
|
||||
const aIndex = order.indexOf(a)
|
||||
const bIndex = order.indexOf(b)
|
||||
return aIndex != null
|
||||
? bIndex != null
|
||||
? aIndex - bIndex
|
||||
return (
|
||||
aIndex != null ?
|
||||
bIndex != null ?
|
||||
aIndex - bIndex
|
||||
: -1
|
||||
: bIndex != null
|
||||
? 1
|
||||
: bIndex != null ? 1
|
||||
: a.localeCompare(b)
|
||||
)
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
|
@ -323,15 +323,15 @@ function createId(id: Uuid) {
|
||||
function sendVizData(id: Uuid, config: VisualizationConfiguration) {
|
||||
const vizDataHandler =
|
||||
mockVizData[
|
||||
typeof config.expression === 'string'
|
||||
? `${config.visualizationModule}.${config.expression}`
|
||||
: `${config.expression.definedOnType}.${config.expression.name}`
|
||||
typeof config.expression === 'string' ?
|
||||
`${config.visualizationModule}.${config.expression}`
|
||||
: `${config.expression.definedOnType}.${config.expression.name}`
|
||||
]
|
||||
if (!vizDataHandler || !sendData) return
|
||||
const vizData =
|
||||
vizDataHandler instanceof Uint8Array
|
||||
? vizDataHandler
|
||||
: vizDataHandler(config.positionalArgumentsExpressions ?? [])
|
||||
vizDataHandler instanceof Uint8Array ? vizDataHandler : (
|
||||
vizDataHandler(config.positionalArgumentsExpressions ?? [])
|
||||
)
|
||||
const builder = new Builder()
|
||||
const exprId = visualizationExprIds.get(id)
|
||||
const visualizationContextOffset = VisualizationContext.createVisualizationContext(
|
||||
|
@ -62,13 +62,13 @@
|
||||
"lib0": "^0.2.85",
|
||||
"magic-string": "^0.30.3",
|
||||
"murmurhash": "^2.0.1",
|
||||
"pinia": "^2.1.6",
|
||||
"pinia": "^2.1.7",
|
||||
"postcss-inline-svg": "^6.0.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"rimraf": "^5.0.5",
|
||||
"semver": "^7.5.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue": "^3.4.19",
|
||||
"ws": "^8.13.0",
|
||||
"y-codemirror.next": "^0.3.2",
|
||||
"y-protocols": "^1.0.5",
|
||||
@ -79,9 +79,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@danmarshall/deckgl-typings": "^4.9.28",
|
||||
"@eslint/eslintrc": "^2.1.2",
|
||||
"@eslint/js": "^8.49.0",
|
||||
"@histoire/plugin-vue": "^0.17.1",
|
||||
"@eslint/eslintrc": "^3.0.2",
|
||||
"@eslint/js": "^8.57.0",
|
||||
"@histoire/plugin-vue": "^0.17.12",
|
||||
"@open-rpc/server-js": "^1.9.4",
|
||||
"@playwright/test": "^1.40.0",
|
||||
"@rushstack/eslint-patch": "^1.3.2",
|
||||
@ -92,45 +92,45 @@
|
||||
"@types/hash-sum": "^1.0.0",
|
||||
"@types/jsdom": "^21.1.1",
|
||||
"@types/mapbox-gl": "^2.7.13",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/node": "^20.11.21",
|
||||
"@types/shuffle-seed": "^1.1.0",
|
||||
"@types/unbzip2-stream": "^1.4.3",
|
||||
"@types/wicg-file-system-access": "^2023.10.2",
|
||||
"@types/ws": "^8.5.5",
|
||||
"@vitejs/plugin-react": "^4.0.4",
|
||||
"@vitejs/plugin-vue": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitest/coverage-v8": "^1.3.1",
|
||||
"@volar/vue-typescript": "^1.6.5",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-prettier": "^9.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.4.1",
|
||||
"@vue/tsconfig": "^0.4.0",
|
||||
"@vue/test-utils": "^2.4.4",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"change-case": "^4.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"css.escape": "^1.5.1",
|
||||
"d3": "^7.4.0",
|
||||
"esbuild": "^0.19.3",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-vue": "^9.16.1",
|
||||
"eslint-plugin-vue": "^9.22.0",
|
||||
"floating-vue": "^2.0.0-beta.24",
|
||||
"hash-wasm": "^4.10.0",
|
||||
"histoire": "^0.17.2",
|
||||
"jsdom": "^22.1.0",
|
||||
"playwright": "^1.39.0",
|
||||
"postcss-nesting": "^12.0.1",
|
||||
"prettier": "^3.0.0",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
"shuffle-seed": "^1.1.6",
|
||||
"sql-formatter": "^13.0.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"tar": "^6.2.0",
|
||||
"tsx": "^3.12.6",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "~5.2.2",
|
||||
"unbzip2-stream": "^1.4.3",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-inspect": "^0.7.38",
|
||||
"vitest": "^0.34.2",
|
||||
"vitest": "^1.3.1",
|
||||
"vue-react-wrapper": "^0.3.1",
|
||||
"vue-tsc": "^1.8.8"
|
||||
"vue-tsc": "^1.8.27"
|
||||
}
|
||||
}
|
||||
|
@ -49,31 +49,31 @@ export default defineConfig({
|
||||
headless: !DEBUG,
|
||||
trace: 'retain-on-failure',
|
||||
viewport: { width: 1920, height: 1600 },
|
||||
...(DEBUG
|
||||
? {}
|
||||
: {
|
||||
launchOptions: {
|
||||
ignoreDefaultArgs: ['--headless'],
|
||||
args: [
|
||||
// Much closer to headful Chromium than classic headless.
|
||||
'--headless=new',
|
||||
// Required for `backdrop-filter: blur` to work.
|
||||
'--use-angle=swiftshader',
|
||||
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by
|
||||
// the software (CPU) compositor. This SHOULD be fixed eventually, but this flag
|
||||
// MUST stay as CI does not have a GPU.
|
||||
'--disable-gpu',
|
||||
// Fully disable GPU process.
|
||||
'--disable-software-rasterizer',
|
||||
// Disable text subpixel antialiasing.
|
||||
'--font-render-hinting=none',
|
||||
'--disable-skia-runtime-opts',
|
||||
'--disable-system-font-check',
|
||||
'--disable-font-subpixel-positioning',
|
||||
'--disable-lcd-text',
|
||||
],
|
||||
},
|
||||
}),
|
||||
...(DEBUG ?
|
||||
{}
|
||||
: {
|
||||
launchOptions: {
|
||||
ignoreDefaultArgs: ['--headless'],
|
||||
args: [
|
||||
// Much closer to headful Chromium than classic headless.
|
||||
'--headless=new',
|
||||
// Required for `backdrop-filter: blur` to work.
|
||||
'--use-angle=swiftshader',
|
||||
// FIXME: `--disable-gpu` disables `backdrop-filter: blur`, which is not handled by
|
||||
// the software (CPU) compositor. This SHOULD be fixed eventually, but this flag
|
||||
// MUST stay as CI does not have a GPU.
|
||||
'--disable-gpu',
|
||||
// Fully disable GPU process.
|
||||
'--disable-software-rasterizer',
|
||||
// Disable text subpixel antialiasing.
|
||||
'--font-render-hinting=none',
|
||||
'--disable-skia-runtime-opts',
|
||||
'--disable-system-font-check',
|
||||
'--disable-font-subpixel-positioning',
|
||||
'--disable-lcd-text',
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
// projects: [
|
||||
// {
|
||||
@ -112,9 +112,9 @@ export default defineConfig({
|
||||
E2E: 'true',
|
||||
},
|
||||
command:
|
||||
process.env.CI || process.env.PROD
|
||||
? `npx vite build && npx vite preview --port ${PORT} --strictPort`
|
||||
: `npx vite dev --port ${PORT}`,
|
||||
process.env.CI || process.env.PROD ?
|
||||
`npx vite build && npx vite preview --port ${PORT} --strictPort`
|
||||
: `npx vite dev --port ${PORT}`,
|
||||
// Build from scratch apparently can take a while on CI machines.
|
||||
timeout: 120 * 1000,
|
||||
port: PORT,
|
||||
|
@ -40,9 +40,9 @@ function get(options, callback) {
|
||||
const location = response.headers.location
|
||||
if (location) {
|
||||
get(
|
||||
typeof options === 'string' || options instanceof URL
|
||||
? location
|
||||
: { ...options, ...new URL(location) },
|
||||
typeof options === 'string' || options instanceof URL ?
|
||||
location
|
||||
: { ...options, ...new URL(location) },
|
||||
callback,
|
||||
)
|
||||
} else {
|
||||
@ -53,11 +53,13 @@ function get(options, callback) {
|
||||
|
||||
/** @param {unknown} error */
|
||||
function errorCode(error) {
|
||||
return typeof error === 'object' &&
|
||||
error != null &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
? error.code
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error != null &&
|
||||
'code' in error &&
|
||||
typeof error.code === 'string'
|
||||
) ?
|
||||
error.code
|
||||
: undefined
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ import { App, Ast, Group, MutableAst, OprApp, Wildcard } from './tree'
|
||||
|
||||
export * from './mutableModule'
|
||||
export * from './parse'
|
||||
export * from './text'
|
||||
export * from './token'
|
||||
export * from './tree'
|
||||
|
||||
|
@ -204,8 +204,9 @@ class Abstractor {
|
||||
}
|
||||
case RawAst.Tree.Type.OprApp: {
|
||||
const lhs = tree.lhs ? this.abstractTree(tree.lhs) : undefined
|
||||
const opr = tree.opr.ok
|
||||
? [this.abstractToken(tree.opr.value)]
|
||||
const opr =
|
||||
tree.opr.ok ?
|
||||
[this.abstractToken(tree.opr.value)]
|
||||
: Array.from(tree.opr.error.payload.operators, this.abstractToken.bind(this))
|
||||
const rhs = tree.rhs ? this.abstractTree(tree.rhs) : undefined
|
||||
const soleOpr = tryGetSoleValue(opr)
|
||||
@ -831,9 +832,9 @@ function calculateCorrespondence(
|
||||
for (const partAfter of partsAfter) {
|
||||
const astBefore = partAfterToAstBefore.get(sourceRangeKey(partAfter))!
|
||||
if (astBefore.typeName() === astAfter.typeName()) {
|
||||
;(rangeLength(newSpans.get(astAfter.id)!) === rangeLength(partAfter)
|
||||
? toSync
|
||||
: candidates
|
||||
;(rangeLength(newSpans.get(astAfter.id)!) === rangeLength(partAfter) ?
|
||||
toSync
|
||||
: candidates
|
||||
).set(astBefore.id, astAfter)
|
||||
break
|
||||
}
|
||||
@ -932,9 +933,9 @@ function syncTree(
|
||||
const editAst = edit.getVersion(ast)
|
||||
if (syncFieldsFrom) {
|
||||
const originalAssignmentExpression =
|
||||
ast instanceof Assignment
|
||||
? metadataSource.get(ast.fields.get('expression').node)
|
||||
: undefined
|
||||
ast instanceof Assignment ?
|
||||
metadataSource.get(ast.fields.get('expression').node)
|
||||
: undefined
|
||||
syncFields(edit.getVersion(ast), syncFieldsFrom, childReplacerFor(ast.id))
|
||||
if (editAst instanceof MutableAssignment && originalAssignmentExpression) {
|
||||
if (editAst.expression.externalId !== originalAssignmentExpression.externalId)
|
||||
|
@ -56,8 +56,9 @@ export class SourceDocument {
|
||||
}
|
||||
}
|
||||
if (printed.code !== this.text_) {
|
||||
const textEdits = update.updateRoots.has(root.id)
|
||||
? [{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }]
|
||||
const textEdits =
|
||||
update.updateRoots.has(root.id) ?
|
||||
[{ range: [0, this.text_.length] satisfies SourceRange, insert: printed.code }]
|
||||
: subtreeTextEdits
|
||||
this.text_ = printed.code
|
||||
this.notifyObservers(textEdits, update.origin)
|
||||
|
79
app/gui2/shared/ast/text.ts
Normal file
79
app/gui2/shared/ast/text.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/***
|
||||
* String escaping and interpolation handling. Code in this module must be kept aligned with lexer's
|
||||
* understanding of string literals. The relevant lexer code can be found in
|
||||
* `lib/rust/parser/src/lexer.rs`, search for `fn text_escape`.
|
||||
*/
|
||||
|
||||
import { assertUnreachable } from '../util/assert'
|
||||
|
||||
const escapeSequences = [
|
||||
['0', '\0'],
|
||||
['a', '\x07'],
|
||||
['b', '\x08'],
|
||||
['f', '\x0C'],
|
||||
['n', '\x0A'],
|
||||
['r', '\x0D'],
|
||||
['t', '\x09'],
|
||||
['v', '\x0B'],
|
||||
['e', '\x1B'],
|
||||
['\\', '\\'],
|
||||
['"', '"'],
|
||||
["'", "'"],
|
||||
['`', '`'],
|
||||
] as const
|
||||
|
||||
function escapeAsCharCodes(str: string): string {
|
||||
let out = ''
|
||||
for (let i = 0; i < str.length; i += 1) out += `\\u{${str?.charCodeAt(i).toString(16)}}`
|
||||
return out
|
||||
}
|
||||
|
||||
const escapeRegex = new RegExp(
|
||||
`${escapeSequences.map(([_, raw]) => escapeAsCharCodes(raw)).join('|')}`,
|
||||
'gu',
|
||||
)
|
||||
|
||||
const unescapeRegex = new RegExp(
|
||||
'\\\\(?:' +
|
||||
`${escapeSequences.map(([escape]) => escapeAsCharCodes(escape)).join('|')}` +
|
||||
'|x[0-9a-fA-F]{0,2}' +
|
||||
'|u\\{[0-9a-fA-F]{0,4}\\}?' + // Lexer allows trailing } to be missing.
|
||||
'|u[0-9a-fA-F]{0,4}' +
|
||||
'|U[0-9a-fA-F]{0,8}' +
|
||||
')',
|
||||
'gu',
|
||||
)
|
||||
|
||||
const escapeMapping = Object.fromEntries(
|
||||
escapeSequences.map(([escape, raw]) => [raw, `\\${escape}`]),
|
||||
)
|
||||
const unescapeMapping = Object.fromEntries(
|
||||
escapeSequences.map(([escape, raw]) => [`\\${escape}`, raw]),
|
||||
)
|
||||
|
||||
/**
|
||||
* Escape a string so it can be safely spliced into an interpolated (`''`) Enso string.
|
||||
* Note: Escape sequences are NOT interpreted in raw (`""`) string literals.
|
||||
* */
|
||||
export function escapeTextLiteral(rawString: string) {
|
||||
return rawString.replace(escapeRegex, (match) => escapeMapping[match] ?? assertUnreachable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret all escaped characters from an interpolated (`''`) Enso string.
|
||||
* Note: Escape sequences are NOT interpreted in raw (`""`) string literals.
|
||||
*/
|
||||
export function unescapeTextLiteral(escapedString: string) {
|
||||
return escapedString.replace(unescapeRegex, (match) => {
|
||||
let cut = 2
|
||||
switch (match[1]) {
|
||||
case 'u':
|
||||
if (match[2] === '{') cut = 3 // fallthrough
|
||||
case 'U':
|
||||
case 'x':
|
||||
return String.fromCharCode(parseInt(match.substring(cut), 16))
|
||||
default:
|
||||
return unescapeMapping[match] ?? assertUnreachable()
|
||||
}
|
||||
})
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import type { DeepReadonly } from 'vue'
|
||||
import type { AstId, Owned } from '.'
|
||||
import type { AstId, NodeChild, Owned } from '.'
|
||||
import { Ast, newExternalId } from '.'
|
||||
import { assert } from '../util/assert'
|
||||
import type { ExternalId } from '../yjsModel'
|
||||
@ -11,6 +11,10 @@ export function isToken(t: unknown): t is Token {
|
||||
return t instanceof Token
|
||||
}
|
||||
|
||||
export function isTokenChild(child: NodeChild<unknown>): child is NodeChild<Token> {
|
||||
return isToken(child.node)
|
||||
}
|
||||
|
||||
declare const brandTokenId: unique symbol
|
||||
export type TokenId = ExternalId & { [brandTokenId]: never }
|
||||
|
||||
|
@ -16,8 +16,10 @@ import {
|
||||
ROOT_ID,
|
||||
Token,
|
||||
asOwned,
|
||||
escapeTextLiteral,
|
||||
isIdentifier,
|
||||
isToken,
|
||||
isTokenChild,
|
||||
isTokenId,
|
||||
newExternalId,
|
||||
parentId,
|
||||
@ -1194,24 +1196,6 @@ export interface MutableImport extends Import, MutableAst {
|
||||
}
|
||||
applyMixins(MutableImport, [MutableAst])
|
||||
|
||||
const mapping: Record<string, string> = {
|
||||
'\b': '\\b',
|
||||
'\f': '\\f',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
'\v': '\\v',
|
||||
'"': '\\"',
|
||||
"'": "\\'",
|
||||
'`': '``',
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function escape(string: string) {
|
||||
return string.replace(/[\0\b\f\n\r\t\v"'`]/g, (match) => mapping[match]!)
|
||||
}
|
||||
|
||||
interface TreeRefs {
|
||||
token: any
|
||||
ast: any
|
||||
@ -1247,6 +1231,16 @@ function rawToConcrete(module: Module): RefMap<RawRefs, ConcreteRefs> {
|
||||
else return { ...child, node: module.get(child.node) }
|
||||
}
|
||||
}
|
||||
|
||||
function concreteToOwned(module: MutableModule): RefMap<ConcreteRefs, OwnedRefs> {
|
||||
return (child: FieldData<ConcreteRefs>) => {
|
||||
if (typeof child !== 'object') return
|
||||
if (!('node' in child)) return
|
||||
if (isTokenChild(child)) return child
|
||||
else return { ...child, node: module.copy(child.node) }
|
||||
}
|
||||
}
|
||||
|
||||
export interface TextToken<T extends TreeRefs = RawRefs> {
|
||||
type: 'token'
|
||||
readonly token: T['token']
|
||||
@ -1335,19 +1329,22 @@ export class TextLiteral extends Ast {
|
||||
return asOwned(new MutableTextLiteral(module, fields))
|
||||
}
|
||||
|
||||
static new(rawText: string, module: MutableModule): Owned<MutableTextLiteral> {
|
||||
const escaped = escape(rawText)
|
||||
static new(rawText: string, module?: MutableModule): Owned<MutableTextLiteral> {
|
||||
const escaped = escapeTextLiteral(rawText)
|
||||
const parsed = parse(`'${escaped}'`, module)
|
||||
if (!(parsed instanceof MutableTextLiteral)) {
|
||||
console.error(`Failed to escape string for interpolated text`, rawText, escaped, parsed)
|
||||
const safeText = rawText.replaceAll(/[^-+A-Za-z0-9_. ]/, '')
|
||||
const safeText = rawText.replaceAll(/[^-+A-Za-z0-9_. ]/g, '')
|
||||
return this.new(safeText, module)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/** Return the value of the string, interpreted except for any interpolated expressions. */
|
||||
contentUninterpolated(): string {
|
||||
/**
|
||||
* Return the literal value of the string with all escape sequences applied, but without
|
||||
* evaluating any interpolated expressions.
|
||||
*/
|
||||
get rawTextContent(): string {
|
||||
return uninterpolatedText(this.fields.get('elements'), this.module)
|
||||
}
|
||||
|
||||
@ -1358,10 +1355,63 @@ export class TextLiteral extends Ast {
|
||||
for (const e of elements) yield* fieldConcreteChildren(e)
|
||||
if (close) yield close
|
||||
}
|
||||
|
||||
boundaryTokenCode(): string | undefined {
|
||||
return (this.open || this.close)?.code()
|
||||
}
|
||||
|
||||
isInterpolated(): boolean {
|
||||
const token = this.boundaryTokenCode()
|
||||
return token === "'" || token === "'''"
|
||||
}
|
||||
|
||||
get open(): Token | undefined {
|
||||
return this.module.getToken(this.fields.get('open')?.node)
|
||||
}
|
||||
|
||||
get close(): Token | undefined {
|
||||
return this.module.getToken(this.fields.get('close')?.node)
|
||||
}
|
||||
|
||||
get elements(): TextElement<ConcreteRefs>[] {
|
||||
return this.fields.get('elements').map((e) => mapRefs(e, rawToConcrete(this.module)))
|
||||
}
|
||||
}
|
||||
export class MutableTextLiteral extends TextLiteral implements MutableAst {
|
||||
declare readonly module: MutableModule
|
||||
declare readonly fields: FixedMap<AstFields & TextLiteralFields>
|
||||
|
||||
setBoundaries(code: string) {
|
||||
this.fields.set('open', unspaced(Token.new(code)))
|
||||
this.fields.set('close', unspaced(Token.new(code)))
|
||||
}
|
||||
|
||||
setElements(elements: TextElement<OwnedRefs>[]) {
|
||||
this.fields.set(
|
||||
'elements',
|
||||
elements.map((e) => mapRefs(e, ownedToRaw(this.module, this.id))),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set literal value of the string. The code representation of assigned text will be automatically
|
||||
* transformed to use escape sequences when necessary.
|
||||
*/
|
||||
setRawTextContent(rawText: string) {
|
||||
let boundary = this.boundaryTokenCode()
|
||||
const isInterpolated = this.isInterpolated()
|
||||
const mustBecomeInterpolated = !isInterpolated && (!boundary || rawText.includes(boundary))
|
||||
if (mustBecomeInterpolated) {
|
||||
boundary = "'"
|
||||
this.setBoundaries(boundary)
|
||||
}
|
||||
const literalContents =
|
||||
isInterpolated || mustBecomeInterpolated ? escapeTextLiteral(rawText) : rawText
|
||||
const parsed = parse(`${boundary}${literalContents}${boundary}`)
|
||||
assert(parsed instanceof TextLiteral)
|
||||
const elements = parsed.elements.map((e) => mapRefs(e, concreteToOwned(this.module)))
|
||||
this.setElements(elements)
|
||||
}
|
||||
}
|
||||
export interface MutableTextLiteral extends TextLiteral, MutableAst {}
|
||||
applyMixins(MutableTextLiteral, [MutableAst])
|
||||
@ -1953,8 +2003,9 @@ function lineFromRaw(raw: RawBlockLine, module: Module): BlockLine {
|
||||
const expression = raw.expression ? module.get(raw.expression.node) : undefined
|
||||
return {
|
||||
newline: { ...raw.newline, node: module.getToken(raw.newline.node) },
|
||||
expression: expression
|
||||
? {
|
||||
expression:
|
||||
expression ?
|
||||
{
|
||||
whitespace: raw.expression?.whitespace,
|
||||
node: expression,
|
||||
}
|
||||
@ -1966,8 +2017,9 @@ function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockL
|
||||
const expression = raw.expression ? module.get(raw.expression.node).takeIfParented() : undefined
|
||||
return {
|
||||
newline: { ...raw.newline, node: module.getToken(raw.newline.node) },
|
||||
expression: expression
|
||||
? {
|
||||
expression:
|
||||
expression ?
|
||||
{
|
||||
whitespace: raw.expression?.whitespace,
|
||||
node: expression,
|
||||
}
|
||||
@ -1978,8 +2030,9 @@ function ownedLineFromRaw(raw: RawBlockLine, module: MutableModule): OwnedBlockL
|
||||
function lineToRaw(line: OwnedBlockLine, module: MutableModule, block: AstId): RawBlockLine {
|
||||
return {
|
||||
newline: line.newline ?? unspaced(Token.new('\n', RawAst.Token.Type.Newline)),
|
||||
expression: line.expression
|
||||
? {
|
||||
expression:
|
||||
line.expression ?
|
||||
{
|
||||
whitespace: line.expression?.whitespace,
|
||||
node: claimChild(module, line.expression.node, block),
|
||||
}
|
||||
@ -2084,40 +2137,24 @@ export class MutableWildcard extends Wildcard implements MutableAst {
|
||||
export interface MutableWildcard extends Wildcard, MutableAst {}
|
||||
applyMixins(MutableWildcard, [MutableAst])
|
||||
|
||||
export type Mutable<T extends Ast = Ast> = T extends App
|
||||
? MutableApp
|
||||
: T extends Assignment
|
||||
? MutableAssignment
|
||||
: T extends BodyBlock
|
||||
? MutableBodyBlock
|
||||
: T extends Documented
|
||||
? MutableDocumented
|
||||
: T extends Function
|
||||
? MutableFunction
|
||||
: T extends Generic
|
||||
? MutableGeneric
|
||||
: T extends Group
|
||||
? MutableGroup
|
||||
: T extends Ident
|
||||
? MutableIdent
|
||||
: T extends Import
|
||||
? MutableImport
|
||||
: T extends Invalid
|
||||
? MutableInvalid
|
||||
: T extends NegationApp
|
||||
? MutableNegationApp
|
||||
: T extends NumericLiteral
|
||||
? MutableNumericLiteral
|
||||
: T extends OprApp
|
||||
? MutableOprApp
|
||||
: T extends PropertyAccess
|
||||
? MutablePropertyAccess
|
||||
: T extends TextLiteral
|
||||
? MutableTextLiteral
|
||||
: T extends UnaryOprApp
|
||||
? MutableUnaryOprApp
|
||||
: T extends Wildcard
|
||||
? MutableWildcard
|
||||
export type Mutable<T extends Ast = Ast> =
|
||||
T extends App ? MutableApp
|
||||
: T extends Assignment ? MutableAssignment
|
||||
: T extends BodyBlock ? MutableBodyBlock
|
||||
: T extends Documented ? MutableDocumented
|
||||
: T extends Function ? MutableFunction
|
||||
: T extends Generic ? MutableGeneric
|
||||
: T extends Group ? MutableGroup
|
||||
: T extends Ident ? MutableIdent
|
||||
: T extends Import ? MutableImport
|
||||
: T extends Invalid ? MutableInvalid
|
||||
: T extends NegationApp ? MutableNegationApp
|
||||
: T extends NumericLiteral ? MutableNumericLiteral
|
||||
: T extends OprApp ? MutableOprApp
|
||||
: T extends PropertyAccess ? MutablePropertyAccess
|
||||
: T extends TextLiteral ? MutableTextLiteral
|
||||
: T extends UnaryOprApp ? MutableUnaryOprApp
|
||||
: T extends Wildcard ? MutableWildcard
|
||||
: MutableAst
|
||||
|
||||
export function materializeMutable(module: MutableModule, fields: FixedMap<AstFields>): MutableAst {
|
||||
@ -2296,15 +2333,27 @@ function concreteChild(
|
||||
}
|
||||
|
||||
type StrictIdentLike = Identifier | IdentifierToken
|
||||
function toIdentStrict(ident: StrictIdentLike): IdentifierToken {
|
||||
return isToken(ident) ? ident : (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken)
|
||||
function toIdentStrict(ident: StrictIdentLike): IdentifierToken
|
||||
function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined
|
||||
function toIdentStrict(ident: StrictIdentLike | undefined): IdentifierToken | undefined {
|
||||
return (
|
||||
ident ?
|
||||
isToken(ident) ? ident
|
||||
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierToken)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
type IdentLike = IdentifierOrOperatorIdentifier | IdentifierOrOperatorIdentifierToken
|
||||
function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken {
|
||||
return isToken(ident)
|
||||
? ident
|
||||
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken)
|
||||
function toIdent(ident: IdentLike): IdentifierOrOperatorIdentifierToken
|
||||
function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined
|
||||
function toIdent(ident: IdentLike | undefined): IdentifierOrOperatorIdentifierToken | undefined {
|
||||
return (
|
||||
ident ?
|
||||
isToken(ident) ? ident
|
||||
: (Token.new(ident, RawAst.Token.Type.Ident) as IdentifierOrOperatorIdentifierToken)
|
||||
: undefined
|
||||
)
|
||||
}
|
||||
|
||||
function makeEquals(): Token {
|
||||
|
@ -668,10 +668,9 @@ export class ByteBuffer {
|
||||
offset<T extends OffsetConstraint>(bbPos: number, vtableOffset: number): Offset<T> {
|
||||
const vtable = bbPos - this.view.getInt32(bbPos, true)
|
||||
return (
|
||||
vtableOffset < this.view.getInt16(vtable, true)
|
||||
? this.view.getInt16(vtable + vtableOffset, true)
|
||||
: 0
|
||||
) as Offset<T>
|
||||
vtableOffset < this.view.getInt16(vtable, true) ?
|
||||
this.view.getInt16(vtable + vtableOffset, true)
|
||||
: 0) as Offset<T>
|
||||
}
|
||||
|
||||
union(t: Table, offset: number): Table {
|
||||
@ -1311,8 +1310,8 @@ export class VisualizationUpdate implements Table {
|
||||
|
||||
visualizationContext(obj?: VisualizationContext): VisualizationContext | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? (obj ?? new VisualizationContext()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
return offset ?
|
||||
(obj ?? new VisualizationContext()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
: null
|
||||
}
|
||||
|
||||
@ -1328,8 +1327,8 @@ export class VisualizationUpdate implements Table {
|
||||
|
||||
dataArray(): Uint8Array | null {
|
||||
const offset = this.bb.offset(this.bbPos, 6)
|
||||
return offset
|
||||
? new Uint8Array(
|
||||
return offset ?
|
||||
new Uint8Array(
|
||||
this.bb.view.buffer,
|
||||
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
|
||||
this.bb.vectorLength(this.bbPos + offset),
|
||||
@ -1414,8 +1413,8 @@ export class Path implements Table {
|
||||
|
||||
rawSegments(index: number): ArrayBuffer {
|
||||
const offset = this.bb.offset(this.bbPos, 6)
|
||||
return offset
|
||||
? this.bb.rawMessage(this.bb.vector(this.bbPos + offset) + index * 4)
|
||||
return offset ?
|
||||
this.bb.rawMessage(this.bb.vector(this.bbPos + offset) + index * 4)
|
||||
: new Uint8Array()
|
||||
}
|
||||
|
||||
@ -1517,8 +1516,8 @@ export class WriteFileCommand implements Table {
|
||||
|
||||
contentsArray(): Uint8Array | null {
|
||||
const offset = this.bb.offset(this.bbPos, 6)
|
||||
return offset
|
||||
? new Uint8Array(
|
||||
return offset ?
|
||||
new Uint8Array(
|
||||
this.bb.view.buffer,
|
||||
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
|
||||
this.bb.vectorLength(this.bbPos + offset),
|
||||
@ -1662,8 +1661,8 @@ export class FileContentsReply implements Table {
|
||||
|
||||
contentsArray(): Uint8Array | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? new Uint8Array(
|
||||
return offset ?
|
||||
new Uint8Array(
|
||||
this.bb.view.buffer,
|
||||
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
|
||||
this.bb.vectorLength(this.bbPos + offset),
|
||||
@ -1761,8 +1760,8 @@ export class WriteBytesCommand implements Table {
|
||||
|
||||
bytesArray(): Uint8Array | null {
|
||||
const offset = this.bb.offset(this.bbPos, 10)
|
||||
return offset
|
||||
? new Uint8Array(
|
||||
return offset ?
|
||||
new Uint8Array(
|
||||
this.bb.view.buffer,
|
||||
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
|
||||
this.bb.vectorLength(this.bbPos + offset),
|
||||
@ -1855,8 +1854,8 @@ export class WriteBytesReply implements Table {
|
||||
|
||||
checksum(obj?: EnsoDigest): EnsoDigest | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
return offset ?
|
||||
(obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
: null
|
||||
}
|
||||
|
||||
@ -1913,8 +1912,8 @@ export class ReadBytesCommand implements Table {
|
||||
|
||||
segment(obj?: FileSegment): FileSegment | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
return offset ?
|
||||
(obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
: null
|
||||
}
|
||||
|
||||
@ -1962,8 +1961,8 @@ export class ReadBytesReply implements Table {
|
||||
|
||||
checksum(obj?: EnsoDigest): EnsoDigest | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
return offset ?
|
||||
(obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
: null
|
||||
}
|
||||
|
||||
@ -1979,8 +1978,8 @@ export class ReadBytesReply implements Table {
|
||||
|
||||
bytesArray(): Uint8Array | null {
|
||||
const offset = this.bb.offset(this.bbPos, 6)
|
||||
return offset
|
||||
? new Uint8Array(
|
||||
return offset ?
|
||||
new Uint8Array(
|
||||
this.bb.view.buffer,
|
||||
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
|
||||
this.bb.vectorLength(this.bbPos + offset),
|
||||
@ -2064,8 +2063,8 @@ export class ChecksumBytesCommand implements Table {
|
||||
|
||||
segment(obj?: FileSegment): FileSegment | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? (obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
return offset ?
|
||||
(obj ?? new FileSegment()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
: null
|
||||
}
|
||||
|
||||
@ -2122,8 +2121,8 @@ export class ChecksumBytesReply implements Table {
|
||||
|
||||
checksum(obj?: EnsoDigest): EnsoDigest | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? (obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
return offset ?
|
||||
(obj ?? new EnsoDigest()).init(this.bb.indirect(this.bbPos + offset), this.bb!)
|
||||
: null
|
||||
}
|
||||
|
||||
@ -2181,8 +2180,8 @@ export class EnsoDigest implements Table {
|
||||
|
||||
bytesArray(): Uint8Array | null {
|
||||
const offset = this.bb.offset(this.bbPos, 4)
|
||||
return offset
|
||||
? new Uint8Array(
|
||||
return offset ?
|
||||
new Uint8Array(
|
||||
this.bb.view.buffer,
|
||||
this.bb.view.byteOffset + this.bb.vector(this.bbPos + offset),
|
||||
this.bb.vectorLength(this.bbPos + offset),
|
||||
|
@ -24,9 +24,9 @@ export function assertLength<T>(iterable: Iterable<T>, length: number, message?:
|
||||
const convertedArray = Array.from(iterable)
|
||||
const messagePrefix = message ? message + ' ' : ''
|
||||
const elementRepresentation =
|
||||
convertedArray.length > 5
|
||||
? `${convertedArray.slice(0, 5).join(', ')},...`
|
||||
: convertedArray.join(', ')
|
||||
convertedArray.length > 5 ?
|
||||
`${convertedArray.slice(0, 5).join(', ')},...`
|
||||
: convertedArray.join(', ')
|
||||
assert(
|
||||
convertedArray.length === length,
|
||||
`${messagePrefix}Expected iterable of length ${length}, got length ${convertedArray.length}. Elements: [${elementRepresentation}]`,
|
||||
|
@ -145,8 +145,9 @@ export class WebsocketClient extends ObservableV2<WebsocketEvents> {
|
||||
this.lastMessageReceived = 0
|
||||
/** Whether to connect to other peers or not */
|
||||
this.shouldConnect = false
|
||||
this._checkInterval = this.sendPings
|
||||
? setInterval(() => {
|
||||
this._checkInterval =
|
||||
this.sendPings ?
|
||||
setInterval(() => {
|
||||
if (
|
||||
this.connected &&
|
||||
messageReconnectTimeout < time.getUnixTime() - this.lastMessageReceived
|
||||
|
@ -8,6 +8,7 @@ import ProjectView from '@/views/ProjectView.vue'
|
||||
import { isDevMode } from 'shared/util/detect'
|
||||
import { computed, onMounted, onUnmounted, toRaw } from 'vue'
|
||||
import { useProjectStore } from './stores/project'
|
||||
import { registerAutoBlurHandler } from './util/autoBlur'
|
||||
|
||||
const props = defineProps<{
|
||||
config: ApplicationConfig
|
||||
@ -20,6 +21,8 @@ const classSet = provideAppClassSet()
|
||||
|
||||
provideGuiConfig(computed((): ApplicationConfigValue => configValue(props.config)))
|
||||
|
||||
registerAutoBlurHandler()
|
||||
|
||||
// Initialize suggestion db immediately, so it will be ready when user needs it.
|
||||
onMounted(() => {
|
||||
const suggestionDb = useSuggestionDbStore()
|
||||
|
@ -15,6 +15,7 @@
|
||||
--color-frame-bg: rgb(255 255 255 / 0.3);
|
||||
--color-frame-selected-bg: rgb(255 255 255 / 0.7);
|
||||
--color-widget: rgb(255 255 255 / 0.12);
|
||||
--color-widget-focus: rgb(255 255 255 / 0.25);
|
||||
--color-widget-selected: rgb(255 255 255 / 0.58);
|
||||
--color-port-connected: rgb(255 255 255 / 0.15);
|
||||
|
||||
|
@ -60,9 +60,9 @@ const emit = defineEmits<{
|
||||
class="icon-container button slot7"
|
||||
:class="{ 'output-context-overridden': props.isOutputContextOverridden }"
|
||||
:alt="`${
|
||||
props.isOutputContextEnabledGlobally != props.isOutputContextOverridden
|
||||
? 'Disable'
|
||||
: 'Enable'
|
||||
props.isOutputContextEnabledGlobally != props.isOutputContextOverridden ?
|
||||
'Disable'
|
||||
: 'Enable'
|
||||
} output context`"
|
||||
:modelValue="props.isOutputContextOverridden"
|
||||
@update:modelValue="emit('update:isOutputContextOverridden', $event)"
|
||||
@ -78,6 +78,11 @@ const emit = defineEmits<{
|
||||
top: -36px;
|
||||
width: 114px;
|
||||
height: 114px;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
@ -86,6 +91,7 @@ const emit = defineEmits<{
|
||||
background: var(--color-app-bg);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
&.partial {
|
||||
|
@ -227,9 +227,8 @@ function updateListener() {
|
||||
commitPendingChanges()
|
||||
currentModule = newModule
|
||||
} else if (transaction.docChanged && currentModule) {
|
||||
pendingChanges = pendingChanges
|
||||
? pendingChanges.compose(transaction.changes)
|
||||
: transaction.changes
|
||||
pendingChanges =
|
||||
pendingChanges ? pendingChanges.compose(transaction.changes) : transaction.changes
|
||||
// Defer the update until after pending events have been processed, so that if changes are arriving faster than
|
||||
// we would be able to apply them individually we coalesce them to keep up.
|
||||
debouncer(commitPendingChanges)
|
||||
@ -282,8 +281,9 @@ function observeSourceChange(textEdits: SourceRangeEdit[], origin: Origin | unde
|
||||
// too quickly can result in incorrect ranges, but at idle it should correct itself when we receive new diagnostics.
|
||||
watch([viewInitialized, () => projectStore.diagnostics], ([ready, diagnostics]) => {
|
||||
if (!ready) return
|
||||
executionContextDiagnostics.value = graphStore.moduleSource.text
|
||||
? lsDiagnosticsToCMDiagnostics(graphStore.moduleSource.text, diagnostics)
|
||||
executionContextDiagnostics.value =
|
||||
graphStore.moduleSource.text ?
|
||||
lsDiagnosticsToCMDiagnostics(graphStore.moduleSource.text, diagnostics)
|
||||
: []
|
||||
})
|
||||
|
||||
@ -403,7 +403,7 @@ const editorStyle = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.CodeEditor :is(.cm-editor) {
|
||||
.CodeEditor :deep(.cm-editor) {
|
||||
position: relative;
|
||||
color: white;
|
||||
width: 100%;
|
||||
@ -420,11 +420,11 @@ const editorStyle = computed(() => {
|
||||
transition: outline 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
.CodeEditor :is(.cm-focused) {
|
||||
.CodeEditor :deep(.cm-focused) {
|
||||
outline: 1px solid rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.CodeEditor :is(.cm-tooltip-hover) {
|
||||
.CodeEditor :deep(.cm-tooltip-hover) {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.4);
|
||||
@ -438,7 +438,7 @@ const editorStyle = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.CodeEditor :is(.cm-gutters) {
|
||||
.CodeEditor :deep(.cm-gutters) {
|
||||
border-radius: 3px 0 0 3px;
|
||||
}
|
||||
</style>
|
||||
|
@ -69,7 +69,9 @@ export function lsDiagnosticsToCMDiagnostics(
|
||||
continue
|
||||
}
|
||||
const severity =
|
||||
diagnostic.kind === 'Error' ? 'error' : diagnostic.kind === 'Warning' ? 'warning' : 'info'
|
||||
diagnostic.kind === 'Error' ? 'error'
|
||||
: diagnostic.kind === 'Warning' ? 'warning'
|
||||
: 'info'
|
||||
results.push({ from, to, message: diagnostic.message, severity })
|
||||
}
|
||||
return results
|
||||
|
@ -54,14 +54,9 @@ export function labelOfEntry(
|
||||
matchedAlias: match.matchedAlias,
|
||||
matchedRanges: [
|
||||
...(match.memberOfRanges ?? match.definedInRanges ?? []).flatMap((range) =>
|
||||
range.end <= lastSegmentStart
|
||||
? []
|
||||
: [
|
||||
new Range(
|
||||
Math.max(0, range.start - lastSegmentStart),
|
||||
range.end - lastSegmentStart,
|
||||
),
|
||||
],
|
||||
range.end <= lastSegmentStart ?
|
||||
[]
|
||||
: [new Range(Math.max(0, range.start - lastSegmentStart), range.end - lastSegmentStart)],
|
||||
),
|
||||
...(match.nameRanges ?? []).map(
|
||||
(range) => new Range(range.start + nameOffset, range.end + nameOffset),
|
||||
@ -69,16 +64,15 @@ export function labelOfEntry(
|
||||
],
|
||||
}
|
||||
} else
|
||||
return match.nameRanges
|
||||
? { label: entry.name, matchedAlias: match.matchedAlias, matchedRanges: match.nameRanges }
|
||||
return match.nameRanges ?
|
||||
{ label: entry.name, matchedAlias: match.matchedAlias, matchedRanges: match.nameRanges }
|
||||
: { label: entry.name, matchedAlias: match.matchedAlias }
|
||||
}
|
||||
|
||||
function formatLabel(labelInfo: ComponentLabelInfo): ComponentLabel {
|
||||
return {
|
||||
label: labelInfo.matchedAlias
|
||||
? `${labelInfo.matchedAlias} (${labelInfo.label})`
|
||||
: labelInfo.label,
|
||||
label:
|
||||
labelInfo.matchedAlias ? `${labelInfo.matchedAlias} (${labelInfo.label})` : labelInfo.label,
|
||||
matchedRanges: labelInfo.matchedRanges,
|
||||
}
|
||||
}
|
||||
|
@ -73,7 +73,7 @@ class FilteringWithPattern {
|
||||
// - The unmatched part up to the next matched letter
|
||||
const regex = pattern
|
||||
.split('')
|
||||
.map((c) => `(${c})`)
|
||||
.map((c) => `(${escapeStringRegexp(c)})`)
|
||||
.join('([^_]*?[_ ])')
|
||||
this.initialsMatchRegex = new RegExp('(^|.*?_)' + regex + '(.*)', 'i')
|
||||
}
|
||||
@ -307,9 +307,9 @@ export class Filtering {
|
||||
for (const [, text, separator] of this.fullPattern.matchAll(/(.+?)([._]|$)/g)) {
|
||||
const escaped = escapeStringRegexp(text ?? '')
|
||||
const segment =
|
||||
separator === '_'
|
||||
? `()(${escaped})([^_.]*)(_)`
|
||||
: `([^.]*_)?(${escaped})([^.]*)(${separator === '.' ? '\\.' : ''})`
|
||||
separator === '_' ?
|
||||
`()(${escaped})([^_.]*)(_)`
|
||||
: `([^.]*_)?(${escaped})([^.]*)(${separator === '.' ? '\\.' : ''})`
|
||||
prefix = '(?:' + prefix
|
||||
suffix += segment + ')?'
|
||||
}
|
||||
|
@ -366,9 +366,9 @@ export function useComponentBrowserInput(
|
||||
const ctx = context.value
|
||||
const opr = ctx.type !== 'changeLiteral' && ctx.oprApp != null ? ctx.oprApp.lastOpr() : null
|
||||
const oprAppSpacing =
|
||||
ctx.type === 'insert' && opr != null && opr.inner.whitespaceLengthInCodeBuffer > 0
|
||||
? ' '.repeat(opr.inner.whitespaceLengthInCodeBuffer)
|
||||
: ''
|
||||
ctx.type === 'insert' && opr != null && opr.inner.whitespaceLengthInCodeBuffer > 0 ?
|
||||
' '.repeat(opr.inner.whitespaceLengthInCodeBuffer)
|
||||
: ''
|
||||
const extendingAccessOprChain = opr != null && opr.repr() === '.'
|
||||
// Modules are special case, as we want to encourage user to continue writing path.
|
||||
if (entry.kind === SuggestionKind.Module) {
|
||||
|
@ -55,46 +55,64 @@ const suggestionDb = useSuggestionDbStore()
|
||||
const interaction = provideInteractionHandler()
|
||||
|
||||
/// === UI Messages and Errors ===
|
||||
function toastOnce(id: string, ...[content, options]: Parameters<typeof toast>) {
|
||||
if (toast.isActive(id)) toast.update(id, { ...options, render: content })
|
||||
else toast(content, { ...options, toastId: id })
|
||||
}
|
||||
|
||||
enum ToastId {
|
||||
startup = 'startup',
|
||||
connectionLost = 'connectionLost',
|
||||
connectionError = 'connectionError',
|
||||
lspError = 'lspError',
|
||||
executionFailed = 'executionFailed',
|
||||
}
|
||||
|
||||
function initStartupToast() {
|
||||
let startupToast = toast.info('Initializing the project. This can take up to one minute.', {
|
||||
toastOnce(ToastId.startup, 'Initializing the project. This can take up to one minute.', {
|
||||
type: 'info',
|
||||
autoClose: false,
|
||||
})
|
||||
|
||||
const removeToast = () => toast.dismiss(startupToast)
|
||||
const removeToast = () => toast.dismiss(ToastId.startup)
|
||||
projectStore.firstExecution.then(removeToast)
|
||||
onScopeDispose(removeToast)
|
||||
}
|
||||
|
||||
function initConnectionLostToast() {
|
||||
let connectionLostToast = 'connectionLostToast'
|
||||
document.addEventListener(
|
||||
ProjectManagerEvents.loadingFailed,
|
||||
() => {
|
||||
toast.error('Lost connection to Language Server.', {
|
||||
toastOnce(ToastId.connectionLost, 'Lost connection to Language Server.', {
|
||||
type: 'error',
|
||||
autoClose: false,
|
||||
toastId: connectionLostToast,
|
||||
})
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
onUnmounted(() => {
|
||||
toast.dismiss(connectionLostToast)
|
||||
toast.dismiss(ToastId.connectionLost)
|
||||
})
|
||||
}
|
||||
|
||||
projectStore.lsRpcConnection.then(
|
||||
(ls) => {
|
||||
ls.client.onError((err) => {
|
||||
toast.error(`Language server error: ${err}`)
|
||||
toastOnce(ToastId.lspError, `Language server error: ${err}`, { type: 'error' })
|
||||
})
|
||||
},
|
||||
(err) => {
|
||||
toast.error(`Connection to language server failed: ${JSON.stringify(err)}`)
|
||||
toastOnce(
|
||||
ToastId.connectionError,
|
||||
`Connection to language server failed: ${JSON.stringify(err)}`,
|
||||
{ type: 'error' },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
projectStore.executionContext.on('executionComplete', () => toast.dismiss(ToastId.executionFailed))
|
||||
projectStore.executionContext.on('executionFailed', (err) => {
|
||||
toast.error(`Execution Failed: ${JSON.stringify(err)}`, {})
|
||||
toastOnce(ToastId.executionFailed, `Execution Failed: ${JSON.stringify(err)}`, { type: 'error' })
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
|
@ -55,9 +55,8 @@ const targetPos = computed<Vec2 | undefined>(() => {
|
||||
if (expr != null && targetNode.value != null && targetNodeRect.value != null) {
|
||||
const targetRectRelative = graph.getPortRelativeRect(expr)
|
||||
if (targetRectRelative == null) return
|
||||
const yAdjustment = targetIsSelfArgument.value
|
||||
? -(selfArgumentArrowHeight + selfArgumentArrowYOffset)
|
||||
: 0
|
||||
const yAdjustment =
|
||||
targetIsSelfArgument.value ? -(selfArgumentArrowHeight + selfArgumentArrowYOffset) : 0
|
||||
return targetNodeRect.value.pos.add(new Vec2(targetRectRelative.center().x, yAdjustment))
|
||||
} else if (navigator?.sceneMousePos != null) {
|
||||
return navigator.sceneMousePos
|
||||
@ -389,9 +388,9 @@ const activeStyle = computed(() => {
|
||||
const distances = mouseLocationOnEdge.value
|
||||
if (distances == null) return {}
|
||||
const offset =
|
||||
distances.sourceToMouse < distances.mouseToTarget
|
||||
? distances.mouseToTarget
|
||||
: -distances.sourceToMouse
|
||||
distances.sourceToMouse < distances.mouseToTarget ?
|
||||
distances.mouseToTarget
|
||||
: -distances.sourceToMouse
|
||||
return {
|
||||
...baseStyle.value,
|
||||
strokeDasharray: distances.sourceToTarget,
|
||||
|
@ -71,14 +71,13 @@ const outputPortsSet = computed(() => {
|
||||
return bindings
|
||||
})
|
||||
|
||||
const widthOverridePx = ref<number>()
|
||||
const nodeId = computed(() => asNodeId(props.node.rootSpan.id))
|
||||
const externalId = computed(() => props.node.rootSpan.externalId)
|
||||
const potentialSelfArgumentId = computed(() => props.node.primarySubject)
|
||||
const connectedSelfArgumentId = computed(() =>
|
||||
props.node.primarySubject && graph.isConnectedTarget(props.node.primarySubject)
|
||||
? props.node.primarySubject
|
||||
: undefined,
|
||||
props.node.primarySubject && graph.isConnectedTarget(props.node.primarySubject) ?
|
||||
props.node.primarySubject
|
||||
: undefined,
|
||||
)
|
||||
|
||||
onUnmounted(() => graph.unregisterNodeRect(nodeId.value))
|
||||
@ -86,7 +85,6 @@ onUnmounted(() => graph.unregisterNodeRect(nodeId.value))
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const contentNode = ref<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode)
|
||||
const baseNodeSize = computed(() => new Vec2(contentNode.value?.scrollWidth ?? 0, nodeSize.value.y))
|
||||
|
||||
const error = computed(() => {
|
||||
const externalId = graph.db.idToExternal(nodeId.value)
|
||||
@ -215,11 +213,11 @@ const isOutputContextOverridden = computed({
|
||||
const module = projectStore.module
|
||||
if (!module) return
|
||||
const edit = props.node.rootSpan.module.edit()
|
||||
const replacementText = shouldOverride
|
||||
? [Ast.TextLiteral.new(projectStore.executionMode, edit)]
|
||||
: undefined
|
||||
const replacements = projectStore.isOutputContextEnabled
|
||||
? {
|
||||
const replacementText =
|
||||
shouldOverride ? [Ast.TextLiteral.new(projectStore.executionMode, edit)] : undefined
|
||||
const replacements =
|
||||
projectStore.isOutputContextEnabled ?
|
||||
{
|
||||
enableOutputContext: undefined,
|
||||
disableOutputContext: replacementText,
|
||||
}
|
||||
@ -388,10 +386,7 @@ const documentation = computed<string | undefined>({
|
||||
class="GraphNode"
|
||||
:style="{
|
||||
transform,
|
||||
width:
|
||||
widthOverridePx != null && isVisualizationVisible
|
||||
? `${Math.max(widthOverridePx, contentNode?.scrollWidth ?? 0)}px`
|
||||
: undefined,
|
||||
minWidth: isVisualizationVisible ? `${visualizationWidth}px` : undefined,
|
||||
'--node-group-color': color,
|
||||
}"
|
||||
:class="{
|
||||
@ -424,7 +419,7 @@ const documentation = computed<string | undefined>({
|
||||
/>
|
||||
<GraphVisualization
|
||||
v-if="isVisualizationVisible"
|
||||
:nodeSize="baseNodeSize"
|
||||
:nodeSize="nodeSize"
|
||||
:scale="navigator?.scale ?? 1"
|
||||
:nodePosition="props.node.position"
|
||||
:isCircularMenuVisible="menuVisible"
|
||||
@ -434,10 +429,7 @@ const documentation = computed<string | undefined>({
|
||||
:typename="expressionInfo?.typename"
|
||||
:width="visualizationWidth"
|
||||
:isFocused="isOnlyOneSelected"
|
||||
@update:rect="
|
||||
emit('update:visualizationRect', $event),
|
||||
(widthOverridePx = $event && $event.size.x > baseNodeSize.x ? $event.size.x : undefined)
|
||||
"
|
||||
@update:rect="emit('update:visualizationRect', $event)"
|
||||
@update:id="emit('update:visualizationId', $event)"
|
||||
@update:visible="emit('update:visualizationVisible', $event)"
|
||||
@update:fullscreen="emit('update:visualizationFullscreen', $event)"
|
||||
@ -453,7 +445,7 @@ const documentation = computed<string | undefined>({
|
||||
</Suspense>
|
||||
<div
|
||||
ref="contentNode"
|
||||
class="node"
|
||||
class="content"
|
||||
v-on="dragPointer.events"
|
||||
@click.stop
|
||||
@pointerdown.stop
|
||||
@ -600,6 +592,7 @@ const documentation = computed<string | undefined>({
|
||||
position: absolute;
|
||||
border-radius: var(--node-border-radius);
|
||||
transition: box-shadow 0.2s ease-in-out;
|
||||
box-sizing: border-box;
|
||||
::selection {
|
||||
background-color: rgba(255, 255, 255, 20%);
|
||||
}
|
||||
@ -609,7 +602,7 @@ const documentation = computed<string | undefined>({
|
||||
display: none;
|
||||
}
|
||||
|
||||
.node {
|
||||
.content {
|
||||
font-family: var(--font-code);
|
||||
position: relative;
|
||||
top: 0;
|
||||
@ -617,7 +610,7 @@ const documentation = computed<string | undefined>({
|
||||
caret-shape: bar;
|
||||
height: var(--node-height);
|
||||
border-radius: var(--node-border-radius);
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
|
@ -29,7 +29,7 @@ const iconForType: Record<GraphNodeMessageType, Icon | undefined> = {
|
||||
|
||||
<template>
|
||||
<div class="GraphNodeMessage" :class="styleClassForType[props.type]">
|
||||
<SvgIcon class="icon" :name="icon" />
|
||||
<SvgIcon v-if="icon" class="icon" :name="icon" />
|
||||
<div v-text="props.message"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -20,7 +20,6 @@ import type { Result } from '@/util/data/result'
|
||||
import type { URLString } from '@/util/data/urlString'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { debouncedGetter } from '@/util/reactivity'
|
||||
import { computedAsync } from '@vueuse/core'
|
||||
import { isIdentifier } from 'shared/ast'
|
||||
import { visIdentifierEquals, type VisualizationIdentifier } from 'shared/yjsModel'
|
||||
@ -91,9 +90,9 @@ const defaultVisualizationForCurrentNodeSource = computed<VisualizationIdentifie
|
||||
return {
|
||||
name: raw.value.name,
|
||||
module:
|
||||
raw.value.library == null
|
||||
? { kind: 'Builtin' }
|
||||
: { kind: 'Library', name: raw.value.library.name },
|
||||
raw.value.library == null ?
|
||||
{ kind: 'Builtin' }
|
||||
: { kind: 'Library', name: raw.value.library.name },
|
||||
}
|
||||
},
|
||||
)
|
||||
@ -225,26 +224,23 @@ watchEffect(async () => {
|
||||
})
|
||||
|
||||
const isBelowToolbar = ref(false)
|
||||
let width = ref<Opt<number>>(props.width)
|
||||
let height = ref(150)
|
||||
// We want to debounce width changes, because they are saved to the metadata.
|
||||
const debouncedWidth = debouncedGetter(() => width.value, 300)
|
||||
watch(debouncedWidth, (value) => value != null && emit('update:width', value))
|
||||
let userSetHeight = ref(150)
|
||||
|
||||
watchEffect(() =>
|
||||
emit(
|
||||
'update:rect',
|
||||
const rect = computed(
|
||||
() =>
|
||||
new Rect(
|
||||
props.nodePosition,
|
||||
new Vec2(
|
||||
width.value ?? props.nodeSize.x,
|
||||
height.value + (isBelowToolbar.value ? TOP_WITH_TOOLBAR_PX : TOP_WITHOUT_TOOLBAR_PX),
|
||||
Math.max(props.width ?? 0, props.nodeSize.x),
|
||||
userSetHeight.value + (isBelowToolbar.value ? TOP_WITH_TOOLBAR_PX : TOP_WITHOUT_TOOLBAR_PX),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
onUnmounted(() => emit('update:rect', undefined))
|
||||
watchEffect(() => emit('update:rect', rect.value))
|
||||
onUnmounted(() => {
|
||||
emit('update:rect', undefined)
|
||||
})
|
||||
|
||||
const allTypes = computed(() => Array.from(visualizationStore.types(props.typename)))
|
||||
|
||||
@ -262,16 +258,16 @@ provideVisualizationConfig({
|
||||
return props.scale
|
||||
},
|
||||
get width() {
|
||||
return width.value ?? null
|
||||
return rect.value.width
|
||||
},
|
||||
set width(value) {
|
||||
width.value = value
|
||||
emit('update:width', value)
|
||||
},
|
||||
get height() {
|
||||
return height.value
|
||||
return userSetHeight.value
|
||||
},
|
||||
set height(value) {
|
||||
height.value = value
|
||||
userSetHeight.value = value
|
||||
},
|
||||
get isBelowToolbar() {
|
||||
return isBelowToolbar.value
|
||||
|
@ -1,9 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
injectWidgetRegistry,
|
||||
type WidgetInput,
|
||||
type WidgetUpdate,
|
||||
} from '@/providers/widgetRegistry'
|
||||
import type { WidgetModule } from '@/providers/widgetRegistry'
|
||||
import { injectWidgetRegistry, WidgetInput, type WidgetUpdate } from '@/providers/widgetRegistry'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import {
|
||||
injectWidgetUsageInfo,
|
||||
@ -12,7 +9,7 @@ import {
|
||||
} from '@/providers/widgetUsageInfo'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { computed, proxyRefs } from 'vue'
|
||||
import { computed, getCurrentInstance, proxyRefs, shallowRef, watchEffect, withCtx } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
input: WidgetInput
|
||||
@ -43,16 +40,17 @@ const sameInputParentWidgets = computed(() =>
|
||||
)
|
||||
const nesting = computed(() => (parentUsageInfo?.nesting ?? 0) + (props.nest === true ? 1 : 0))
|
||||
|
||||
const selectedWidget = computed(() => {
|
||||
return registry.select(
|
||||
const selectedWidget = shallowRef<WidgetModule<WidgetInput> | undefined>()
|
||||
const updateSelection = withCtx(() => {
|
||||
selectedWidget.value = registry.select(
|
||||
{
|
||||
input: props.input,
|
||||
nesting: nesting.value,
|
||||
},
|
||||
sameInputParentWidgets.value,
|
||||
)
|
||||
})
|
||||
|
||||
}, getCurrentInstance())
|
||||
watchEffect(() => updateSelection())
|
||||
const updateHandler = computed(() => {
|
||||
const nextHandler =
|
||||
parentUsageInfo?.updateHandler ?? (() => console.log('Missing update handler'))
|
||||
|
@ -48,7 +48,9 @@ function handleWidgetUpdates(update: WidgetUpdate) {
|
||||
const { value, origin } = update.portUpdate
|
||||
if (Ast.isAstId(origin)) {
|
||||
const ast =
|
||||
value instanceof Ast.Ast ? value : value == null ? Ast.Wildcard.new(edit) : undefined
|
||||
value instanceof Ast.Ast ? value
|
||||
: value == null ? Ast.Wildcard.new(edit)
|
||||
: undefined
|
||||
if (ast) {
|
||||
edit.replaceValue(origin as Ast.AstId, ast)
|
||||
} else if (typeof value === 'string') {
|
||||
@ -105,7 +107,7 @@ provideWidgetTree(
|
||||
}
|
||||
|
||||
&:has(.WidgetPort.newToConnect > .r-24:only-child) {
|
||||
margin-left: 4px;
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,17 +171,19 @@ export function useDragging() {
|
||||
}
|
||||
|
||||
updateNodesPosition() {
|
||||
for (const [id, dragged] of this.draggedNodes) {
|
||||
const node = graphStore.db.nodeIdToNode.get(id)
|
||||
if (node == null) continue
|
||||
// If node was moved in other way than current dragging, we want to stop dragging it.
|
||||
if (node.position.distanceSquared(dragged.currentPos) > 1.0) {
|
||||
this.draggedNodes.delete(id)
|
||||
} else {
|
||||
dragged.currentPos = dragged.initialPos.add(snappedOffset.value)
|
||||
graphStore.setNodePosition(id, dragged.currentPos)
|
||||
graphStore.batchEdits(() => {
|
||||
for (const [id, dragged] of this.draggedNodes) {
|
||||
const node = graphStore.db.nodeIdToNode.get(id)
|
||||
if (node == null) continue
|
||||
// If node was moved in other way than current dragging, we want to stop dragging it.
|
||||
if (node.position.distanceSquared(dragged.currentPos) > 1.0) {
|
||||
this.draggedNodes.delete(id)
|
||||
} else {
|
||||
dragged.currentPos = dragged.initialPos.add(snappedOffset.value)
|
||||
graphStore.setNodePosition(id, dragged.currentPos)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Awareness } from '@/stores/awareness'
|
||||
import * as astText from '@/util/ast/text'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { Keccak, sha3_224 as SHA3 } from '@noble/hashes/sha3'
|
||||
import type { Hash } from '@noble/hashes/utils'
|
||||
import { bytesToHex } from '@noble/hashes/utils'
|
||||
import { escapeTextLiteral } from 'shared/ast'
|
||||
import type { DataServer } from 'shared/dataServer'
|
||||
import type { LanguageServer } from 'shared/languageServer'
|
||||
import { ErrorCode, RemoteRpcError } from 'shared/languageServer'
|
||||
@ -17,10 +17,10 @@ const DATA_DIR_NAME = 'data'
|
||||
export function uploadedExpression(result: UploadResult) {
|
||||
switch (result.source) {
|
||||
case 'Project': {
|
||||
return `enso_project.data/'${astText.escape(result.name)}' . read`
|
||||
return `enso_project.data/'${escapeTextLiteral(result.name)}' . read`
|
||||
}
|
||||
case 'FileSystemRoot': {
|
||||
return `Data.read '${astText.escape(result.name)}'`
|
||||
return `Data.read '${escapeTextLiteral(result.name)}'`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -7,9 +7,7 @@ import { requiredImportsByFQN } from '@/stores/graph/imports'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { TokenId } from '@/util/ast/abstract'
|
||||
import { ArgumentInfoKey } from '@/util/callTree'
|
||||
import { asNot } from '@/util/data/types.ts'
|
||||
import { type Identifier, type QualifiedName } from '@/util/qualifiedName'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@ -51,7 +49,7 @@ const value = computed({
|
||||
edit,
|
||||
portUpdate: {
|
||||
value: value ? 'True' : 'False',
|
||||
origin: asNot<TokenId>(props.input.portId),
|
||||
origin: props.input.portId,
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -68,11 +66,9 @@ const argumentName = computed(() => {
|
||||
<script lang="ts">
|
||||
function isBoolNode(ast: Ast.Ast) {
|
||||
const candidate =
|
||||
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean'
|
||||
? ast.rhs
|
||||
: ast instanceof Ast.Ident
|
||||
? ast.token
|
||||
: undefined
|
||||
ast instanceof Ast.PropertyAccess && ast.lhs?.code() === 'Boolean' ? ast.rhs
|
||||
: ast instanceof Ast.Ident ? ast.token
|
||||
: undefined
|
||||
return candidate && ['True', 'False'].includes(candidate.code())
|
||||
}
|
||||
function setBoolNode(ast: Ast.Mutable, value: Identifier): { requiresImport: boolean } {
|
||||
@ -90,15 +86,15 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
priority: 500,
|
||||
score: (props) => {
|
||||
if (props.input.value instanceof Ast.Ast && isBoolNode(props.input.value)) return Score.Perfect
|
||||
return props.input.expectedType === 'Standard.Base.Data.Boolean.Boolean'
|
||||
? Score.Good
|
||||
return props.input.expectedType === 'Standard.Base.Data.Boolean.Boolean' ?
|
||||
Score.Good
|
||||
: Score.Mismatch
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CheckboxContainer" :class="{ primary }">
|
||||
<div class="CheckboxContainer r-24" :class="{ primary }">
|
||||
<span v-if="argumentName" class="name" v-text="argumentName" />
|
||||
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
|
||||
<CheckboxWidget
|
||||
|
@ -78,10 +78,12 @@ const application = computed(() => {
|
||||
widgetCfg: widgetConfiguration.value,
|
||||
subjectAsSelf: selfArgumentPreapplied.value,
|
||||
notAppliedArguments:
|
||||
noArgsCall != null &&
|
||||
(!subjectTypeMatchesMethod.value || noArgsCall.notAppliedArguments.length > 0)
|
||||
? noArgsCall.notAppliedArguments
|
||||
: undefined,
|
||||
(
|
||||
noArgsCall != null &&
|
||||
(!subjectTypeMatchesMethod.value || noArgsCall.notAppliedArguments.length > 0)
|
||||
) ?
|
||||
noArgsCall.notAppliedArguments
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
@ -107,9 +109,9 @@ const selfArgumentExternalId = computed<Opt<ExternalId>>(() => {
|
||||
const knownArguments = methodCallInfo.value?.suggestion?.arguments
|
||||
const hasSelfArgument = knownArguments?.[0]?.name === 'self'
|
||||
const selfArgument =
|
||||
hasSelfArgument && !selfArgumentPreapplied.value
|
||||
? analyzed.args.find((a) => a.argName === 'self' || a.argName == null)?.argument
|
||||
: getAccessOprSubject(analyzed.func) ?? analyzed.args[0]?.argument
|
||||
hasSelfArgument && !selfArgumentPreapplied.value ?
|
||||
analyzed.args.find((a) => a.argName === 'self' || a.argName == null)?.argument
|
||||
: getAccessOprSubject(analyzed.func) ?? analyzed.args[0]?.argument
|
||||
|
||||
return selfArgument?.externalId
|
||||
}
|
||||
@ -191,9 +193,9 @@ function handleArgUpdate(update: WidgetUpdate): boolean {
|
||||
newArg = Ast.parse(value, edit)
|
||||
}
|
||||
const name =
|
||||
argApp.argument.insertAsNamed && isIdentifier(argApp.argument.argInfo.name)
|
||||
? argApp.argument.argInfo.name
|
||||
: undefined
|
||||
argApp.argument.insertAsNamed && isIdentifier(argApp.argument.argInfo.name) ?
|
||||
argApp.argument.argInfo.name
|
||||
: undefined
|
||||
edit
|
||||
.getVersion(argApp.appTree)
|
||||
.updateValue((oldAppTree) => Ast.App.new(edit, oldAppTree, name, newArg))
|
||||
|
@ -2,8 +2,6 @@
|
||||
import NumericInputWidget from '@/components/widgets/NumericInputWidget.vue'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { TokenId } from '@/util/ast/abstract.ts'
|
||||
import { asNot } from '@/util/data/types.ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
@ -14,7 +12,7 @@ const value = computed({
|
||||
},
|
||||
set(value) {
|
||||
props.onUpdate({
|
||||
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
|
||||
portUpdate: { value: value.toString(), origin: props.input.portId },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
@ -10,16 +10,13 @@ import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import { PortViewInstance, useGraphStore } from '@/stores/graph'
|
||||
import { assert } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { TokenId } from '@/util/ast/abstract'
|
||||
import { ArgumentInfoKey } from '@/util/callTree'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { asNot } from '@/util/data/types.ts'
|
||||
import { cachedGetter } from '@/util/reactivity'
|
||||
import { uuidv4 } from 'lib0/random'
|
||||
import { isUuid } from 'shared/yjsModel'
|
||||
import {
|
||||
computed,
|
||||
markRaw,
|
||||
nextTick,
|
||||
onUpdated,
|
||||
proxyRefs,
|
||||
@ -66,7 +63,7 @@ const isTarget = computed(
|
||||
)
|
||||
|
||||
const rootNode = shallowRef<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode, false)
|
||||
const nodeSize = useResizeObserver(rootNode)
|
||||
|
||||
// Compute the scene-space bounding rectangle of the expression's widget. Those bounds are later
|
||||
// used for edge positioning. Querying and updating those bounds is relatively expensive, so we only
|
||||
@ -82,7 +79,7 @@ const randomUuid = uuidv4() as PortId
|
||||
// effects depending on the port ID value will not be re-triggered unnecessarily.
|
||||
const portId = cachedGetter<PortId>(() => {
|
||||
assert(!isUuid(props.input.portId))
|
||||
return asNot<TokenId>(props.input.portId)
|
||||
return props.input.portId
|
||||
})
|
||||
|
||||
const innerWidget = computed(() => {
|
||||
@ -100,7 +97,7 @@ const randSlice = randomUuid.slice(0, 4)
|
||||
watchEffect(
|
||||
(onCleanup) => {
|
||||
const id = portId.value
|
||||
const instance = markRaw(new PortViewInstance(portRect, tree.nodeId, props.onUpdate))
|
||||
const instance = new PortViewInstance(portRect, tree.nodeId, props.onUpdate)
|
||||
graph.addPortInstance(id, instance)
|
||||
onCleanup(() => graph.removePortInstance(id, instance))
|
||||
},
|
||||
@ -109,7 +106,7 @@ watchEffect(
|
||||
|
||||
function updateRect() {
|
||||
let domNode = rootNode.value
|
||||
const rootDomNode = domNode?.closest('.node')
|
||||
const rootDomNode = domNode?.closest('.GraphNode')
|
||||
if (domNode == null || rootDomNode == null) return
|
||||
|
||||
const exprClientRect = Rect.FromDomRect(domNode.getBoundingClientRect())
|
||||
|
@ -16,11 +16,9 @@ import {
|
||||
type SuggestionEntryArgument,
|
||||
} from '@/stores/suggestionDatabase/entry.ts'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { TokenId } from '@/util/ast/abstract.ts'
|
||||
import { targetIsOutside } from '@/util/autoBlur'
|
||||
import { ArgumentInfoKey } from '@/util/callTree'
|
||||
import { arrayEquals } from '@/util/data/array'
|
||||
import { asNot } from '@/util/data/types.ts'
|
||||
import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@ -53,11 +51,9 @@ function tagFromEntry(entry: SuggestionEntry): Tag {
|
||||
return {
|
||||
label: entry.name,
|
||||
expression:
|
||||
entry.selfType != null
|
||||
? `_.${entry.name}`
|
||||
: entry.memberOf
|
||||
? `${qnLastSegment(entry.memberOf)}.${entry.name}`
|
||||
: entry.name,
|
||||
entry.selfType != null ? `_.${entry.name}`
|
||||
: entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}`
|
||||
: entry.name,
|
||||
requiredImports: requiredImports(suggestions.entries, entry),
|
||||
}
|
||||
}
|
||||
@ -98,7 +94,11 @@ const selectedTag = computed(() => {
|
||||
// To prevent partial prefix matches, we arrange tags in reverse lexicographical order.
|
||||
const sortedTags = tags.value
|
||||
.map((tag, index) => [removeSurroundingParens(tag.expression), index] as [string, number])
|
||||
.sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0))
|
||||
.sort(([a], [b]) =>
|
||||
a < b ? 1
|
||||
: a > b ? -1
|
||||
: 0,
|
||||
)
|
||||
const [_, index] = sortedTags.find(([expr]) => currentExpression.startsWith(expr)) ?? []
|
||||
return index != null ? tags.value[index] : undefined
|
||||
}
|
||||
@ -146,13 +146,7 @@ watch(selectedIndex, (_index) => {
|
||||
value = conflicts[0]?.fullyQualified
|
||||
}
|
||||
}
|
||||
props.onUpdate({
|
||||
edit,
|
||||
portUpdate: {
|
||||
value,
|
||||
origin: asNot<TokenId>(props.input.portId),
|
||||
},
|
||||
})
|
||||
props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } })
|
||||
})
|
||||
|
||||
const isHovered = ref(false)
|
||||
|
@ -14,9 +14,9 @@ const icon = computed(() => tree.icon)
|
||||
export const widgetDefinition = defineWidget(WidgetInput.isAst, {
|
||||
priority: 1,
|
||||
score: (props, _db) =>
|
||||
props.input.value.id === injectWidgetTree().connectedSelfArgumentId
|
||||
? Score.Perfect
|
||||
: Score.Mismatch,
|
||||
props.input.value.id === injectWidgetTree().connectedSelfArgumentId ?
|
||||
Score.Perfect
|
||||
: Score.Mismatch,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -1,23 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import EnsoTextInputWidget from '@/components/widgets/EnsoTextInputWidget.vue'
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import AutoSizedInput from '@/components/widgets/AutoSizedInput.vue'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { useGraphStore } from '@/stores/graph'
|
||||
import { Ast } from '@/util/ast'
|
||||
import type { TokenId } from '@/util/ast/abstract'
|
||||
import { asNot } from '@/util/data/types'
|
||||
import { MutableModule } from '@/util/ast/abstract'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
const value = computed({
|
||||
const graph = useGraphStore()
|
||||
|
||||
const inputTextLiteral = computed((): Ast.TextLiteral | undefined => {
|
||||
if (props.input.value instanceof Ast.TextLiteral) return props.input.value
|
||||
const valueStr = WidgetInput.valueRepr(props.input)
|
||||
const parsed = valueStr != null ? Ast.parse(valueStr) : undefined
|
||||
if (parsed instanceof Ast.TextLiteral) return parsed
|
||||
return undefined
|
||||
})
|
||||
|
||||
function makeNewLiteral(value: string) {
|
||||
return Ast.TextLiteral.new(value, MutableModule.Transient())
|
||||
}
|
||||
|
||||
const emptyTextLiteral = makeNewLiteral('')
|
||||
const shownLiteral = computed(() => inputTextLiteral.value ?? emptyTextLiteral)
|
||||
const closeToken = computed(() => shownLiteral.value.close ?? shownLiteral.value.open)
|
||||
|
||||
const textContents = computed({
|
||||
get() {
|
||||
const valueStr = WidgetInput.valueRepr(props.input)
|
||||
return typeof valueStr === 'string' && Ast.parse(valueStr) instanceof Ast.TextLiteral
|
||||
? valueStr
|
||||
: ''
|
||||
return shownLiteral.value.rawTextContent
|
||||
},
|
||||
set(value) {
|
||||
props.onUpdate({
|
||||
portUpdate: { value: value.toString(), origin: asNot<TokenId>(props.input.portId) },
|
||||
})
|
||||
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>
|
||||
@ -36,13 +61,29 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown -->
|
||||
<EnsoTextInputWidget v-model="value" class="WidgetText r-24" @pointerdown.stop />
|
||||
<label class="WidgetText r-24" @pointerdown.stop>
|
||||
<NodeWidget v-if="shownLiteral.open" :input="WidgetInput.FromAst(shownLiteral.open)" />
|
||||
<AutoSizedInput v-model.lazy="textContents" />
|
||||
<NodeWidget v-if="closeToken" :input="WidgetInput.FromAst(closeToken)" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.WidgetText {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
background: var(--color-widget);
|
||||
border-radius: var(--radius-full);
|
||||
position: relative;
|
||||
user-select: none;
|
||||
border-radius: var(--radius-full);
|
||||
padding: 0px 4px;
|
||||
min-width: 24px;
|
||||
justify-content: center;
|
||||
|
||||
&:has(> .AutoSizedInput:focus) {
|
||||
outline: none;
|
||||
background: var(--color-widget-focus);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -10,9 +10,9 @@ const props = defineProps(widgetProps(widgetDefinition))
|
||||
export const widgetDefinition = defineWidget(ArgumentInfoKey, {
|
||||
priority: -1,
|
||||
score: (props) =>
|
||||
props.nesting < 2 && props.input[ArgumentInfoKey].appKind === ApplicationKind.Prefix
|
||||
? Score.Perfect
|
||||
: Score.Mismatch,
|
||||
props.nesting < 2 && props.input[ArgumentInfoKey].appKind === ApplicationKind.Prefix ?
|
||||
Score.Perfect
|
||||
: Score.Mismatch,
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -4,16 +4,15 @@ import ListWidget from '@/components/widgets/ListWidget.vue'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MutableModule, type TokenId } from '@/util/ast/abstract.ts'
|
||||
import { asNot } from '@/util/data/types.ts'
|
||||
import { MutableModule } from '@/util/ast/abstract.ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
|
||||
const itemConfig = computed(() =>
|
||||
props.input.dynamicConfig?.kind === 'Vector_Editor'
|
||||
? props.input.dynamicConfig.item_editor
|
||||
: undefined,
|
||||
props.input.dynamicConfig?.kind === 'Vector_Editor' ?
|
||||
props.input.dynamicConfig.item_editor
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const defaultItem = computed(() => {
|
||||
@ -35,7 +34,7 @@ const value = computed({
|
||||
// TODO[ao]: here we re-create AST. It would be better to reuse existing AST nodes.
|
||||
const newCode = `[${value.map((item) => item.code()).join(', ')}]`
|
||||
props.onUpdate({
|
||||
portUpdate: { value: newCode, origin: asNot<TokenId>(props.input.portId) },
|
||||
portUpdate: { value: newCode, origin: props.input.portId },
|
||||
})
|
||||
},
|
||||
})
|
||||
@ -51,8 +50,8 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
else if (props.input.expectedType?.startsWith('Standard.Base.Data.Vector.Vector'))
|
||||
return Score.Good
|
||||
else if (props.input.value instanceof Ast.Ast) {
|
||||
return props.input.value.children().next().value.code() === '['
|
||||
? Score.Perfect
|
||||
return props.input.value.children().next().value.code() === '[' ?
|
||||
Score.Perfect
|
||||
: Score.Mismatch
|
||||
} else return Score.Mismatch
|
||||
},
|
||||
|
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { useApproach } from '@/composables/animation'
|
||||
import type { Vec2 } from '@/util/data/vec2'
|
||||
import { computed, watch, type Ref } from 'vue'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
position: Vec2
|
||||
@ -9,7 +9,15 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const hidden = computed(() => props.anchor == null)
|
||||
const lastSetAnchor: Ref<Vec2 | undefined> = computed(() => props.anchor ?? lastSetAnchor.value)
|
||||
const lastSetAnchor = shallowRef<Vec2>()
|
||||
watch(
|
||||
() => props.anchor,
|
||||
(anchor) => {
|
||||
if (anchor !== null && lastSetAnchor.value !== anchor) {
|
||||
lastSetAnchor.value = anchor
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const anchorAnimFactor = useApproach(() => (props.anchor != null ? 1 : 0), 60)
|
||||
watch(
|
||||
|
@ -49,7 +49,7 @@ function blur(event: Event) {
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const contentNode = ref<HTMLElement>()
|
||||
|
||||
onMounted(() => (config.width = Math.max(config.width ?? config.nodeSize.x, MIN_WIDTH_PX)))
|
||||
onMounted(() => (config.width = MIN_WIDTH_PX))
|
||||
|
||||
function hideSelector() {
|
||||
requestAnimationFrame(() => (isSelectorVisible.value = false))
|
||||
@ -61,7 +61,7 @@ const resizeRight = usePointer((pos, _, type) => {
|
||||
}
|
||||
const width =
|
||||
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
|
||||
config.width = Math.max(config.nodeSize.x, width, MIN_WIDTH_PX)
|
||||
config.width = Math.max(width, MIN_WIDTH_PX)
|
||||
}, PointerButtonMask.Main)
|
||||
|
||||
const resizeBottom = usePointer((pos, _, type) => {
|
||||
@ -80,7 +80,7 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
if (pos.delta.x !== 0) {
|
||||
const width =
|
||||
(pos.absolute.x - (contentNode.value?.getBoundingClientRect().left ?? 0)) / config.scale
|
||||
config.width = Math.max(config.nodeSize.x, width)
|
||||
config.width = Math.max(0, width)
|
||||
}
|
||||
if (pos.delta.y !== 0) {
|
||||
const height =
|
||||
@ -117,12 +117,10 @@ const resizeBottomRight = usePointer((pos, _, type) => {
|
||||
class="content scrollable"
|
||||
:class="{ overflow: props.overflow }"
|
||||
:style="{
|
||||
width: config.fullscreen
|
||||
? undefined
|
||||
: `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
|
||||
height: config.fullscreen
|
||||
? undefined
|
||||
: `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
|
||||
width:
|
||||
config.fullscreen ? undefined : `${Math.max(config.width ?? 0, config.nodeSize.x)}px`,
|
||||
height:
|
||||
config.fullscreen ? undefined : `${Math.max(config.height ?? 0, config.nodeSize.y)}px`,
|
||||
}"
|
||||
@wheel.passive="onWheel"
|
||||
>
|
||||
|
@ -380,9 +380,9 @@ function pushPoints(newPoints: Location[]) {
|
||||
) {
|
||||
let position: [number, number] = [point.longitude, point.latitude]
|
||||
let radius =
|
||||
typeof point.radius === 'number' && !Number.isNaN(point.radius)
|
||||
? point.radius
|
||||
: DEFAULT_POINT_RADIUS
|
||||
typeof point.radius === 'number' && !Number.isNaN(point.radius) ?
|
||||
point.radius
|
||||
: DEFAULT_POINT_RADIUS
|
||||
let color = point.color ?? ACCENT_COLOR
|
||||
let label = point.label ?? ''
|
||||
points.push({ position, color, radius, label })
|
||||
|
@ -89,14 +89,16 @@ const fill = computed(() =>
|
||||
|
||||
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
width.value = config.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
width.value =
|
||||
config.fullscreen ?
|
||||
containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.width ?? 0, config.nodeSize.x)
|
||||
})
|
||||
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
|
||||
watchPostEffect(() => {
|
||||
height.value = config.fullscreen
|
||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
height.value =
|
||||
config.fullscreen ?
|
||||
containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.height ?? (config.nodeSize.x * 3) / 4
|
||||
})
|
||||
const boxWidth = computed(() => Math.max(0, width.value - MARGIN.left - MARGIN.right))
|
||||
|
@ -248,14 +248,16 @@ const margin = computed(() => ({
|
||||
}))
|
||||
const width = ref(Math.max(config.width ?? 0, config.nodeSize.x))
|
||||
watchPostEffect(() => {
|
||||
width.value = config.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
width.value =
|
||||
config.fullscreen ?
|
||||
containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.width ?? 0, config.nodeSize.x)
|
||||
})
|
||||
const height = ref(config.height ?? (config.nodeSize.x * 3) / 4)
|
||||
watchPostEffect(() => {
|
||||
height.value = config.fullscreen
|
||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
height.value =
|
||||
config.fullscreen ?
|
||||
containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.height ?? (config.nodeSize.x * 3) / 4
|
||||
})
|
||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||
@ -309,7 +311,9 @@ const zoom = computed(() =>
|
||||
const medDelta = 0.05
|
||||
const maxDelta = 1
|
||||
const wheelSpeedMultiplier =
|
||||
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta
|
||||
event.deltaMode === 1 ? medDelta
|
||||
: event.deltaMode ? maxDelta
|
||||
: minDelta
|
||||
return -event.deltaY * wheelSpeedMultiplier
|
||||
})
|
||||
.scaleExtent(ZOOM_EXTENT)
|
||||
|
@ -46,9 +46,9 @@ const props = defineProps<{ data: Data }>()
|
||||
const theme: Theme = DEFAULT_THEME
|
||||
|
||||
const language = computed(() =>
|
||||
props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect)
|
||||
? props.data.dialect
|
||||
: 'sql',
|
||||
props.data.dialect != null && sqlFormatter.supportedDialects.includes(props.data.dialect) ?
|
||||
props.data.dialect
|
||||
: 'sql',
|
||||
)
|
||||
const formatted = computed(() => {
|
||||
if (props.data.error != null || props.data.code == null) {
|
||||
|
@ -135,9 +135,8 @@ const SCALE_TO_D3_SCALE: Record<ScaleType, () => d3.ScaleContinuousNumeric<numbe
|
||||
|
||||
const data = computed<Data>(() => {
|
||||
let rawData = props.data
|
||||
const unfilteredData = Array.isArray(rawData)
|
||||
? rawData.map((y, index) => ({ x: index, y }))
|
||||
: rawData.data ?? []
|
||||
const unfilteredData =
|
||||
Array.isArray(rawData) ? rawData.map((y, index) => ({ x: index, y })) : rawData.data ?? []
|
||||
const data: Point[] = unfilteredData.filter(
|
||||
(point) =>
|
||||
typeof point.x === 'number' &&
|
||||
@ -203,15 +202,15 @@ const margin = computed(() => {
|
||||
}
|
||||
})
|
||||
const width = computed(() =>
|
||||
config.fullscreen
|
||||
? containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.width ?? 0, config.nodeSize.x),
|
||||
config.fullscreen ?
|
||||
containerNode.value?.parentElement?.clientWidth ?? 0
|
||||
: Math.max(config.width ?? 0, config.nodeSize.x),
|
||||
)
|
||||
|
||||
const height = computed(() =>
|
||||
config.fullscreen
|
||||
? containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.height ?? (config.nodeSize.x * 3) / 4,
|
||||
config.fullscreen ?
|
||||
containerNode.value?.parentElement?.clientHeight ?? 0
|
||||
: config.height ?? (config.nodeSize.x * 3) / 4,
|
||||
)
|
||||
|
||||
const boxWidth = computed(() => Math.max(0, width.value - margin.value.left - margin.value.right))
|
||||
@ -291,7 +290,9 @@ const zoom = computed(() =>
|
||||
const medDelta = 0.05
|
||||
const maxDelta = 1
|
||||
const wheelSpeedMultiplier =
|
||||
event.deltaMode === 1 ? medDelta : event.deltaMode ? maxDelta : minDelta
|
||||
event.deltaMode === 1 ? medDelta
|
||||
: event.deltaMode ? maxDelta
|
||||
: minDelta
|
||||
return -event.deltaY * wheelSpeedMultiplier
|
||||
})
|
||||
.scaleExtent(ZOOM_EXTENT)
|
||||
|
@ -296,11 +296,9 @@ watchEffect(() => {
|
||||
const dataHeader = ('header' in data_ ? data_.header : [])?.map(toField) ?? []
|
||||
columnDefs = [...indicesHeader, ...dataHeader]
|
||||
const rows =
|
||||
data_.data && data_.data.length > 0
|
||||
? data_.data[0]?.length ?? 0
|
||||
: data_.indices && data_.indices.length > 0
|
||||
? data_.indices[0]?.length ?? 0
|
||||
: 0
|
||||
data_.data && data_.data.length > 0 ? data_.data[0]?.length ?? 0
|
||||
: data_.indices && data_.indices.length > 0 ? data_.indices[0]?.length ?? 0
|
||||
: 0
|
||||
rowData = Array.from({ length: rows }, (_, i) => {
|
||||
const shift = data_.indices ? data_.indices.length : 0
|
||||
return Object.fromEntries(
|
||||
|
82
app/gui2/src/components/widgets/AutoSizedInput.vue
Normal file
82
app/gui2/src/components/widgets/AutoSizedInput.vue
Normal file
@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { useAutoBlur } from '@/util/autoBlur'
|
||||
import { getTextWidthByFont } from '@/util/measurement'
|
||||
import { computed, ref, watch, type StyleValue } from 'vue'
|
||||
|
||||
const [model, modifiers] = defineModel<string>()
|
||||
const props = defineProps<{ autoSelect?: boolean }>()
|
||||
|
||||
const innerModel = modifiers.lazy ? ref(model.value) : model
|
||||
if (modifiers.lazy) watch(model, (newVal) => (innerModel.value = newVal))
|
||||
const onChange = modifiers.lazy ? () => (model.value = innerModel.value) : undefined
|
||||
|
||||
const inputNode = ref<HTMLInputElement>()
|
||||
useAutoBlur(inputNode)
|
||||
function onFocus() {
|
||||
if (props.autoSelect) {
|
||||
inputNode.value?.select()
|
||||
}
|
||||
}
|
||||
|
||||
const cssFont = computed(() => {
|
||||
if (inputNode.value == null) return ''
|
||||
let style = window.getComputedStyle(inputNode.value)
|
||||
return style.font
|
||||
})
|
||||
|
||||
const getTextWidth = (text: string) => getTextWidthByFont(text, cssFont.value)
|
||||
const inputWidth = computed(() => getTextWidth(`${innerModel.value}`))
|
||||
const inputStyle = computed<StyleValue>(() => ({ width: `${inputWidth.value}px` }))
|
||||
|
||||
function onEnterDown() {
|
||||
inputNode.value?.blur()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
inputWidth,
|
||||
getTextWidth,
|
||||
select: () => inputNode.value?.select(),
|
||||
focus: () => inputNode.value?.focus(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
ref="inputNode"
|
||||
v-model="innerModel"
|
||||
class="AutoSizedInput"
|
||||
:style="inputStyle"
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
@keydown.enter.stop="onEnterDown"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.AutoSizedInput {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
line-height: 171.5%;
|
||||
height: 24px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
cursor: default;
|
||||
user-select: all;
|
||||
box-sizing: content-box;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
@ -21,10 +21,18 @@ const sortedValuesAndIndices = computed(() => {
|
||||
])
|
||||
switch (sortDirection.value) {
|
||||
case SortDirection.ascending: {
|
||||
return valuesAndIndices.sort((a, b) => (a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0))
|
||||
return valuesAndIndices.sort((a, b) =>
|
||||
a[0] > b[0] ? 1
|
||||
: a[0] < b[0] ? -1
|
||||
: 0,
|
||||
)
|
||||
}
|
||||
case SortDirection.descending: {
|
||||
return valuesAndIndices.sort((a, b) => (a[0] > b[0] ? -1 : a[0] < b[0] ? 1 : 0))
|
||||
return valuesAndIndices.sort((a, b) =>
|
||||
a[0] > b[0] ? -1
|
||||
: a[0] < b[0] ? 1
|
||||
: 0,
|
||||
)
|
||||
}
|
||||
case SortDirection.none:
|
||||
default: {
|
||||
|
@ -1,135 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useResizeObserver } from '@/composables/events'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [modelValue: string] }>()
|
||||
|
||||
// Edited value reflects the `modelValue`, but does not update it until the user defocuses the field.
|
||||
const editedValue = ref(props.modelValue)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
editedValue.value = newValue
|
||||
},
|
||||
)
|
||||
|
||||
const inputNode = ref<HTMLInputElement>()
|
||||
const inputSize = useResizeObserver(inputNode)
|
||||
const inputMeasurements = computed(() => {
|
||||
if (inputNode.value == null) return { availableWidth: 0, font: '' }
|
||||
let style = window.getComputedStyle(inputNode.value)
|
||||
let availableWidth =
|
||||
inputSize.value.x - (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight))
|
||||
return { availableWidth, font: style.font }
|
||||
})
|
||||
|
||||
const inputStyle = computed<StyleValue>(() => {
|
||||
if (inputNode.value == null) {
|
||||
return {}
|
||||
}
|
||||
const value = `${editedValue.value}`
|
||||
const measurements = inputMeasurements.value
|
||||
const width = getTextWidthByFont(value, measurements.font)
|
||||
return {
|
||||
width: `${width}px`,
|
||||
}
|
||||
})
|
||||
|
||||
/** To prevent other elements from stealing mouse events (which breaks blur),
|
||||
* we instead setup our own `pointerdown` handler while the input is focused.
|
||||
* Any click outside of the input field causes `blur`.
|
||||
* We don’t want to `useAutoBlur` here, because it would require a separate `pointerdown` handler per input widget.
|
||||
* Instead we setup a single handler for the currently focused widget only, and thus safe performance. */
|
||||
function setupAutoBlur() {
|
||||
const options = { capture: true }
|
||||
function callback(event: MouseEvent) {
|
||||
if (blurIfNecessary(inputNode, event)) {
|
||||
window.removeEventListener('pointerdown', callback, options)
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointerdown', callback, options)
|
||||
}
|
||||
|
||||
const separators = /(^('''|"""|['"]))|(('''|"""|['"])$)/g
|
||||
/** Display the value in a more human-readable form for easier editing. */
|
||||
function prepareForEditing() {
|
||||
editedValue.value = unescape(editedValue.value.replace(separators, ''))
|
||||
}
|
||||
|
||||
function focus() {
|
||||
setupAutoBlur()
|
||||
prepareForEditing()
|
||||
}
|
||||
|
||||
const escapedValue = computed(() => `'${escape(editedValue.value)}'`)
|
||||
|
||||
function blur() {
|
||||
emit('update:modelValue', escapedValue.value)
|
||||
editedValue.value = props.modelValue
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="EnsoTextInputWidget"
|
||||
@pointerdown.stop="() => inputNode?.focus()"
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
>
|
||||
<input
|
||||
ref="inputNode"
|
||||
v-model="editedValue"
|
||||
class="value"
|
||||
:style="inputStyle"
|
||||
@keydown.enter.stop="($event.target as HTMLInputElement).blur()"
|
||||
@focus="focus"
|
||||
@blur="blur"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.EnsoTextInputWidget {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
background: var(--color-widget);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.value {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: none;
|
||||
border: none;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-weight: 800;
|
||||
line-height: 171.5%;
|
||||
height: 24px;
|
||||
padding: 0px 4px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: rgba(255, 255, 255, 15%);
|
||||
}
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
@ -46,9 +46,9 @@ const dragMetaMimePrefix = 'application/x-enso-list-item;item='
|
||||
|
||||
function stringToHex(str: string) {
|
||||
return Array.from(str, (c) =>
|
||||
c.charCodeAt(0) < 128
|
||||
? c.charCodeAt(0).toString(16)
|
||||
: encodeURIComponent(c).replace(/%/g, '').toLowerCase(),
|
||||
c.charCodeAt(0) < 128 ?
|
||||
c.charCodeAt(0).toString(16)
|
||||
: encodeURIComponent(c).replace(/%/g, '').toLowerCase(),
|
||||
).join('')
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { PointerButtonMask, usePointer, useResizeObserver } from '@/composables/events'
|
||||
import { blurIfNecessary } from '@/util/autoBlur'
|
||||
import { getTextWidthByFont } from '@/util/measurement'
|
||||
import { computed, ref, watch, type StyleValue } from 'vue'
|
||||
import { PointerButtonMask, usePointer } from '@/composables/events'
|
||||
import { computed, ref, watch, type ComponentInstance, type StyleValue } from 'vue'
|
||||
import AutoSizedInput from './AutoSizedInput.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number | string
|
||||
@ -12,11 +11,11 @@ const emit = defineEmits<{ 'update:modelValue': [modelValue: number | string] }>
|
||||
|
||||
const inputFieldActive = ref(false)
|
||||
// Edited value reflects the `modelValue`, but does not update it until the user defocuses the field.
|
||||
const editedValue = ref(props.modelValue)
|
||||
const editedValue = ref(`${props.modelValue}`)
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
editedValue.value = newValue
|
||||
editedValue.value = `${newValue}`
|
||||
},
|
||||
)
|
||||
const SLIDER_INPUT_THRESHOLD = 4.0
|
||||
@ -24,31 +23,27 @@ const SLIDER_INPUT_THRESHOLD = 4.0
|
||||
const dragPointer = usePointer(
|
||||
(position, event, eventType) => {
|
||||
const slider = event.target
|
||||
if (!(slider instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
if (!(slider instanceof HTMLElement)) return false
|
||||
|
||||
if (eventType === 'stop' && Math.abs(position.relative.x) < SLIDER_INPUT_THRESHOLD) {
|
||||
inputNode.value?.focus()
|
||||
return
|
||||
event.stopImmediatePropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
if (eventType === 'start') {
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
if (inputFieldActive.value || props.limits == null) return
|
||||
if (inputFieldActive.value || props.limits == null) return false
|
||||
|
||||
const { min, max } = props.limits
|
||||
const rect = slider.getBoundingClientRect()
|
||||
const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left)
|
||||
const fraction = Math.max(0, Math.min(1, fractionRaw))
|
||||
const newValue = min + Math.round(fraction * (max - min))
|
||||
editedValue.value = newValue
|
||||
if (eventType === 'stop') {
|
||||
emit('update:modelValue', editedValue.value)
|
||||
}
|
||||
editedValue.value = `${newValue}`
|
||||
if (eventType === 'stop') emitUpdate()
|
||||
},
|
||||
PointerButtonMask.Main,
|
||||
(event) => !event.ctrlKey && !event.altKey && !event.shiftKey && !event.metaKey,
|
||||
@ -62,132 +57,80 @@ const sliderWidth = computed(() => {
|
||||
}%`
|
||||
})
|
||||
|
||||
const inputNode = ref<HTMLInputElement>()
|
||||
const inputSize = useResizeObserver(inputNode)
|
||||
const inputMeasurements = computed(() => {
|
||||
if (inputNode.value == null) return { availableWidth: 0, font: '' }
|
||||
let style = window.getComputedStyle(inputNode.value)
|
||||
let availableWidth =
|
||||
inputSize.value.x - (parseFloat(style.paddingLeft) + parseFloat(style.paddingRight))
|
||||
return { availableWidth, font: style.font }
|
||||
})
|
||||
const inputComponent = ref<ComponentInstance<typeof AutoSizedInput>>()
|
||||
const MIN_CONTENT_WIDTH = 56
|
||||
|
||||
const inputStyle = computed<StyleValue>(() => {
|
||||
if (inputNode.value == null) {
|
||||
return {}
|
||||
}
|
||||
const value = `${props.modelValue}`
|
||||
const value = `${editedValue.value}`
|
||||
const dotIdx = value.indexOf('.')
|
||||
let indent = 0
|
||||
if (dotIdx >= 0) {
|
||||
if (dotIdx >= 0 && inputComponent.value != null) {
|
||||
const { inputWidth, getTextWidth } = inputComponent.value
|
||||
const textBefore = value.slice(0, dotIdx)
|
||||
const textAfter = value.slice(dotIdx + 1)
|
||||
|
||||
const measurements = inputMeasurements.value
|
||||
const total = getTextWidthByFont(value, measurements.font)
|
||||
const beforeDot = getTextWidthByFont(textBefore, measurements.font)
|
||||
const afterDot = getTextWidthByFont(textAfter, measurements.font)
|
||||
const blankSpace = Math.max(measurements.availableWidth - total, 0)
|
||||
const availableWidth = Math.max(inputWidth, MIN_CONTENT_WIDTH)
|
||||
const beforeDot = getTextWidth(textBefore)
|
||||
const afterDot = getTextWidth(textAfter)
|
||||
const blankSpace = Math.max(availableWidth - inputWidth, 0)
|
||||
indent = Math.min(Math.max(-blankSpace, afterDot - beforeDot), blankSpace)
|
||||
}
|
||||
return {
|
||||
textIndent: `${indent}px`,
|
||||
// Note: The input element here uses `box-sizing: content-box;`.
|
||||
minWidth: `${MIN_CONTENT_WIDTH}px`,
|
||||
}
|
||||
})
|
||||
|
||||
function blur() {
|
||||
inputFieldActive.value = false
|
||||
emit('update:modelValue', editedValue.value)
|
||||
function emitUpdate() {
|
||||
if (`${props.modelValue}` !== editedValue.value) {
|
||||
emit('update:modelValue', editedValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
/** To prevent other elements from stealing mouse events (which breaks blur),
|
||||
* we instead setup our own `pointerdown` handler while the input is focused.
|
||||
* Any click outside of the input field causes `blur`.
|
||||
* We don’t want to `useAutoBlur` here, because it would require a separate `pointerdown` handler per input widget.
|
||||
* Instead we setup a single handler for the currently focused widget only, and thus safe performance. */
|
||||
function setupAutoBlur() {
|
||||
const options = { capture: true }
|
||||
function callback(event: MouseEvent) {
|
||||
if (blurIfNecessary(inputNode, event)) {
|
||||
window.removeEventListener('pointerdown', callback, options)
|
||||
}
|
||||
}
|
||||
window.addEventListener('pointerdown', callback, options)
|
||||
function blur() {
|
||||
inputFieldActive.value = false
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function focus() {
|
||||
inputNode.value?.select()
|
||||
inputFieldActive.value = true
|
||||
setupAutoBlur()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="NumericInputWidget"
|
||||
v-on="dragPointer.events"
|
||||
@keydown.backspace.stop
|
||||
@keydown.delete.stop
|
||||
>
|
||||
<div v-if="props.limits != null" class="fraction" :style="{ width: sliderWidth }"></div>
|
||||
<input
|
||||
ref="inputNode"
|
||||
<label class="NumericInputWidget">
|
||||
<div v-if="props.limits != null" class="slider" :style="{ width: sliderWidth }"></div>
|
||||
<AutoSizedInput
|
||||
ref="inputComponent"
|
||||
v-model="editedValue"
|
||||
class="value"
|
||||
autoSelect
|
||||
:style="inputStyle"
|
||||
@keydown.enter.stop="($event.target as HTMLInputElement).blur()"
|
||||
v-on="dragPointer.events"
|
||||
@blur="blur"
|
||||
@focus="focus"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.NumericInputWidget {
|
||||
position: relative;
|
||||
}
|
||||
.AutoSizedInput {
|
||||
user-select: none;
|
||||
justify-content: space-around;
|
||||
background: var(--color-widget);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: clip;
|
||||
width: 56px;
|
||||
padding: 0px 4px;
|
||||
&:focus {
|
||||
background: var(--color-widget-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.fraction {
|
||||
.slider {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
left: 0;
|
||||
background: var(--color-widget);
|
||||
}
|
||||
|
||||
.value {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
line-height: 171.5%;
|
||||
height: 24px;
|
||||
padding: 0px 4px;
|
||||
appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: rgba(255, 255, 255, 15%);
|
||||
}
|
||||
}
|
||||
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
@ -2,7 +2,7 @@
|
||||
|
||||
import type { Opt } from '@/util/data/opt'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import type { VueInstance } from '@vueuse/core'
|
||||
import { type VueInstance } from '@vueuse/core'
|
||||
import {
|
||||
computed,
|
||||
onScopeDispose,
|
||||
@ -13,6 +13,7 @@ import {
|
||||
watch,
|
||||
watchEffect,
|
||||
type Ref,
|
||||
type ShallowRef,
|
||||
type WatchSource,
|
||||
} from 'vue'
|
||||
|
||||
@ -139,6 +140,45 @@ export function unrefElement(
|
||||
return (plain as VueInstance)?.$el ?? plain
|
||||
}
|
||||
|
||||
interface ResizeObserverData {
|
||||
refCount: number
|
||||
boundRectUsers: number
|
||||
contentRect: ShallowRef<Vec2>
|
||||
boundRect: ShallowRef<Vec2>
|
||||
}
|
||||
|
||||
const resizeObserverData = new WeakMap<Element, ResizeObserverData>()
|
||||
function getOrCreateObserverData(element: Element): ResizeObserverData {
|
||||
const existingData = resizeObserverData.get(element)
|
||||
if (existingData) return existingData
|
||||
const data: ResizeObserverData = {
|
||||
refCount: 0,
|
||||
boundRectUsers: 0,
|
||||
contentRect: shallowRef<Vec2>(Vec2.Zero),
|
||||
boundRect: shallowRef<Vec2>(Vec2.Zero),
|
||||
}
|
||||
resizeObserverData.set(element, data)
|
||||
return data
|
||||
}
|
||||
|
||||
const sharedResizeObserver: ResizeObserver | undefined =
|
||||
typeof ResizeObserver === 'undefined' ? undefined : (
|
||||
new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const data = resizeObserverData.get(entry.target)
|
||||
if (data != null) {
|
||||
if (entry.contentRect != null) {
|
||||
data.contentRect.value = new Vec2(entry.contentRect.width, entry.contentRect.height)
|
||||
}
|
||||
if (data.boundRectUsers > 0) {
|
||||
const rect = entry.target.getBoundingClientRect()
|
||||
data.boundRect.value = new Vec2(rect.width, rect.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* Get DOM node size and keep it up to date.
|
||||
*
|
||||
@ -153,8 +193,8 @@ export function useResizeObserver(
|
||||
elementRef: Ref<Element | undefined | null | VueInstance>,
|
||||
useContentRect = true,
|
||||
): Ref<Vec2> {
|
||||
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
if (!sharedResizeObserver) {
|
||||
const sizeRef = shallowRef<Vec2>(Vec2.Zero)
|
||||
// Fallback implementation for browsers/test environment that do not support ResizeObserver:
|
||||
// Grab the size of the element every time the ref is assigned, or when the page is resized.
|
||||
function refreshSize() {
|
||||
@ -168,36 +208,36 @@ export function useResizeObserver(
|
||||
useEvent(window, 'resize', refreshSize)
|
||||
return sizeRef
|
||||
}
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
let rect: { width: number; height: number } | null = null
|
||||
const target = unrefElement(elementRef)
|
||||
for (const entry of entries) {
|
||||
if (entry.target === target) {
|
||||
if (useContentRect) {
|
||||
rect = entry.contentRect
|
||||
} else {
|
||||
rect = entry.target.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (rect != null) {
|
||||
sizeRef.value = new Vec2(rect.width, rect.height)
|
||||
}
|
||||
})
|
||||
|
||||
const observer = sharedResizeObserver
|
||||
watchEffect((onCleanup) => {
|
||||
const element = unrefElement(elementRef)
|
||||
if (element != null) {
|
||||
observer.observe(element)
|
||||
const data = getOrCreateObserverData(element)
|
||||
if (data.refCount === 0) observer.observe(element)
|
||||
data.refCount += 1
|
||||
if (!useContentRect) {
|
||||
if (data.boundRectUsers === 0) {
|
||||
const rect = element.getBoundingClientRect()
|
||||
data.boundRect.value = new Vec2(rect.width, rect.height)
|
||||
}
|
||||
data.boundRectUsers += 1
|
||||
}
|
||||
onCleanup(() => {
|
||||
if (elementRef.value != null) {
|
||||
observer.unobserve(element)
|
||||
data.refCount -= 1
|
||||
if (!useContentRect) data.boundRectUsers -= 1
|
||||
if (data.refCount === 0) observer.unobserve(element)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return sizeRef
|
||||
return computed(() => {
|
||||
const element = unrefElement(elementRef)
|
||||
if (element == null) return Vec2.Zero
|
||||
const data = getOrCreateObserverData(element)
|
||||
return useContentRect ? data.contentRect.value : data.boundRect.value
|
||||
})
|
||||
}
|
||||
|
||||
export interface EventPosition {
|
||||
@ -240,7 +280,7 @@ export const enum PointerButtonMask {
|
||||
* @returns
|
||||
*/
|
||||
export function usePointer(
|
||||
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void,
|
||||
handler: (pos: EventPosition, event: PointerEvent, eventType: PointerEventType) => void | boolean,
|
||||
requiredButtonMask: number = PointerButtonMask.Main,
|
||||
predicate?: (e: PointerEvent) => boolean,
|
||||
) {
|
||||
@ -256,18 +296,22 @@ export function usePointer(
|
||||
trackedElement?.releasePointerCapture(trackedPointer.value)
|
||||
}
|
||||
|
||||
trackedPointer.value = null
|
||||
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'stop')
|
||||
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'stop') !== false) {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
lastPos = null
|
||||
trackedElement = null
|
||||
}
|
||||
trackedPointer.value = null
|
||||
}
|
||||
|
||||
function doMove(e: PointerEvent) {
|
||||
if (trackedElement != null && initialGrabPos != null && lastPos != null) {
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'move')
|
||||
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'move') !== false) {
|
||||
e.preventDefault()
|
||||
}
|
||||
lastPos = new Vec2(e.clientX, e.clientY)
|
||||
}
|
||||
}
|
||||
@ -280,7 +324,6 @@ export function usePointer(
|
||||
}
|
||||
|
||||
if (trackedPointer.value == null && e.currentTarget instanceof Element) {
|
||||
e.preventDefault()
|
||||
trackedPointer.value = e.pointerId
|
||||
// This is mostly SAFE, as virtually all `Element`s also extend `GlobalEventHandlers`.
|
||||
trackedElement = e.currentTarget as Element & GlobalEventHandlers
|
||||
@ -288,21 +331,21 @@ export function usePointer(
|
||||
trackedElement.setPointerCapture?.(e.pointerId)
|
||||
initialGrabPos = new Vec2(e.clientX, e.clientY)
|
||||
lastPos = initialGrabPos
|
||||
handler(computePosition(e, initialGrabPos, lastPos), e, 'start')
|
||||
if (handler(computePosition(e, initialGrabPos, lastPos), e, 'start') !== false) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
},
|
||||
pointerup(e: PointerEvent) {
|
||||
if (trackedPointer.value !== e.pointerId) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
doStop(e)
|
||||
},
|
||||
pointermove(e: PointerEvent) {
|
||||
if (trackedPointer.value !== e.pointerId) {
|
||||
return
|
||||
}
|
||||
e.preventDefault()
|
||||
// handle release of all masked buttons as stop
|
||||
if ((e.buttons & requiredButtonMask) !== 0) {
|
||||
doMove(e)
|
||||
|
@ -4,7 +4,7 @@ import { useApproach } from '@/composables/animation'
|
||||
import { PointerButtonMask, useEvent, usePointer, useResizeObserver } from '@/composables/events'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { computed, proxyRefs, ref, type Ref } from 'vue'
|
||||
import { computed, proxyRefs, shallowRef, type Ref } from 'vue'
|
||||
|
||||
function elemRect(target: Element | undefined): Rect {
|
||||
if (target != null && target instanceof Element) {
|
||||
@ -17,7 +17,7 @@ function elemRect(target: Element | undefined): Rect {
|
||||
export type NavigatorComposable = ReturnType<typeof useNavigator>
|
||||
export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
const size = useResizeObserver(viewportNode)
|
||||
const targetCenter = ref<Vec2>(Vec2.Zero)
|
||||
const targetCenter = shallowRef<Vec2>(Vec2.Zero)
|
||||
const targetX = computed(() => targetCenter.value.x)
|
||||
const targetY = computed(() => targetCenter.value.y)
|
||||
const centerX = useApproach(targetX)
|
||||
@ -32,7 +32,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
centerY.value = value.y
|
||||
},
|
||||
})
|
||||
const targetScale = ref(1)
|
||||
const targetScale = shallowRef(1)
|
||||
const animatedScale = useApproach(targetScale)
|
||||
const scale = computed({
|
||||
get() {
|
||||
@ -140,7 +140,7 @@ export function useNavigator(viewportNode: Ref<Element | undefined>) {
|
||||
|
||||
let isPointerDown = false
|
||||
let scrolledThisFrame = false
|
||||
const eventMousePos = ref<Vec2 | null>(null)
|
||||
const eventMousePos = shallowRef<Vec2 | null>(null)
|
||||
let eventTargetScrollPos: Vec2 | null = null
|
||||
const sceneMousePos = computed(() =>
|
||||
eventMousePos.value ? clientToScenePos(eventMousePos.value) : null,
|
||||
|
@ -6,7 +6,7 @@ import { type NodeId } from '@/stores/graph'
|
||||
import type { Rect } from '@/util/data/rect'
|
||||
import { intersectionSize } from '@/util/data/set'
|
||||
import type { Vec2 } from '@/util/data/vec2'
|
||||
import { computed, proxyRefs, reactive, ref, shallowRef } from 'vue'
|
||||
import { computed, proxyRefs, ref, shallowReactive, shallowRef } from 'vue'
|
||||
|
||||
export type SelectionComposable<T> = ReturnType<typeof useSelection<T>>
|
||||
export function useSelection<T>(
|
||||
@ -20,7 +20,7 @@ export function useSelection<T>(
|
||||
) {
|
||||
const anchor = shallowRef<Vec2>()
|
||||
const initiallySelected = new Set<T>()
|
||||
const selected = reactive(new Set<T>())
|
||||
const selected = shallowReactive(new Set<T>())
|
||||
const hoveredNode = ref<NodeId>()
|
||||
const hoveredPort = ref<PortId>()
|
||||
|
||||
@ -28,11 +28,13 @@ export function useSelection<T>(
|
||||
if (event.target instanceof Element) {
|
||||
const widgetPort = event.target.closest('.WidgetPort')
|
||||
hoveredPort.value =
|
||||
widgetPort instanceof HTMLElement &&
|
||||
'port' in widgetPort.dataset &&
|
||||
typeof widgetPort.dataset.port === 'string'
|
||||
? (widgetPort.dataset.port as PortId)
|
||||
: undefined
|
||||
(
|
||||
widgetPort instanceof HTMLElement &&
|
||||
'port' in widgetPort.dataset &&
|
||||
typeof widgetPort.dataset.port === 'string'
|
||||
) ?
|
||||
(widgetPort.dataset.port as PortId)
|
||||
: undefined
|
||||
}
|
||||
})
|
||||
|
||||
@ -125,18 +127,19 @@ export function useSelection<T>(
|
||||
const pointer = usePointer((_pos, event, eventType) => {
|
||||
if (eventType === 'start') {
|
||||
readInitiallySelected()
|
||||
} else if (eventType === 'stop') {
|
||||
if (anchor.value == null) {
|
||||
// If there was no drag, we want to handle "clicking-off" selected nodes.
|
||||
selectionEventHandler(event)
|
||||
} else {
|
||||
anchor.value = undefined
|
||||
}
|
||||
initiallySelected.clear()
|
||||
} else if (pointer.dragging) {
|
||||
if (anchor.value == null) {
|
||||
anchor.value = navigator.sceneMousePos?.copy()
|
||||
}
|
||||
selectionEventHandler(event)
|
||||
} else if (eventType === 'stop') {
|
||||
if (anchor.value == null) {
|
||||
// If there was no drag, we want to handle "clicking-off" selected nodes.
|
||||
selectionEventHandler(event)
|
||||
}
|
||||
anchor.value = undefined
|
||||
initiallySelected.clear()
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -56,9 +56,8 @@ export function createContextStore<F extends (...args: any[]) => any>(name: stri
|
||||
): ReturnType<F> {
|
||||
// Right now this function assumes that an array always represents the arguments to the factory.
|
||||
// If we ever need to mock an array as the context value, we'll worry about it then.
|
||||
const constructed: ReturnType<F> = Array.isArray(valueOrArgs)
|
||||
? factory(...valueOrArgs)
|
||||
: valueOrArgs
|
||||
const constructed: ReturnType<F> =
|
||||
Array.isArray(valueOrArgs) ? factory(...valueOrArgs) : valueOrArgs
|
||||
if (app != null) app.provide(provideKey, constructed)
|
||||
else provide(provideKey, constructed)
|
||||
return constructed
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { createContextStore } from '@/providers'
|
||||
import type { AstId } from '@/util/ast/abstract'
|
||||
import type { AstId, TokenId } from '@/util/ast/abstract'
|
||||
import { identity } from '@vueuse/core'
|
||||
|
||||
declare const portIdBrand: unique symbol
|
||||
@ -7,7 +7,7 @@ declare const portIdBrand: unique symbol
|
||||
* Port identification. A port represents a fragment of code displayed/modified by the widget;
|
||||
* usually Ast nodes, but other ids are also possible (like argument placeholders).
|
||||
*/
|
||||
export type PortId = AstId | (string & { [portIdBrand]: never })
|
||||
export type PortId = AstId | TokenId | (string & { [portIdBrand]: never })
|
||||
|
||||
interface PortInfo {
|
||||
portId: PortId
|
||||
|
@ -4,7 +4,7 @@ import type { WidgetConfiguration } from '@/providers/widgetRegistry/configurati
|
||||
import type { GraphDb } from '@/stores/graph/graphDatabase'
|
||||
import type { Typename } from '@/stores/suggestionDatabase/entry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MutableModule, type TokenId } from '@/util/ast/abstract.ts'
|
||||
import { MutableModule } from '@/util/ast/abstract.ts'
|
||||
import { computed, shallowReactive, type Component, type PropType } from 'vue'
|
||||
|
||||
export type WidgetComponent<T extends WidgetInput> = Component<WidgetProps<T>>
|
||||
@ -92,7 +92,7 @@ export interface WidgetInput {
|
||||
*
|
||||
* Also, used as usage key (see {@link usageKeyForInput})
|
||||
*/
|
||||
portId: PortId | TokenId
|
||||
portId: PortId
|
||||
/**
|
||||
* An expected widget value. If Ast.Ast or Ast.Token, the widget represents an existing part of
|
||||
* code. If string, it may be e.g. a default value of an argument.
|
||||
@ -171,12 +171,10 @@ export function widgetProps<T extends WidgetInput>(_def: WidgetDefinition<T>) {
|
||||
type InputMatcherFn<T extends WidgetInput> = (input: WidgetInput) => input is T
|
||||
type InputMatcher<T extends WidgetInput> = keyof WidgetInput | InputMatcherFn<T>
|
||||
|
||||
type InputTy<M> = M extends (infer T)[]
|
||||
? InputTy<T>
|
||||
: M extends InputMatcherFn<infer T>
|
||||
? T
|
||||
: M extends keyof WidgetInput
|
||||
? WidgetInput & Required<Pick<WidgetInput, M>>
|
||||
type InputTy<M> =
|
||||
M extends (infer T)[] ? InputTy<T>
|
||||
: M extends InputMatcherFn<infer T> ? T
|
||||
: M extends keyof WidgetInput ? WidgetInput & Required<Pick<WidgetInput, M>>
|
||||
: never
|
||||
|
||||
export interface WidgetOptions<T extends WidgetInput> {
|
||||
|
@ -169,8 +169,8 @@ export function requiredImports(
|
||||
]
|
||||
switch (entry.kind) {
|
||||
case SuggestionKind.Module:
|
||||
return entry.reexportedIn
|
||||
? unqualifiedImport(entry.reexportedIn)
|
||||
return entry.reexportedIn ?
|
||||
unqualifiedImport(entry.reexportedIn)
|
||||
: [
|
||||
{
|
||||
kind: 'Qualified',
|
||||
@ -181,11 +181,11 @@ export function requiredImports(
|
||||
return unqualifiedImport(entry.reexportedIn ? entry.reexportedIn : entry.definedIn)
|
||||
case SuggestionKind.Constructor:
|
||||
if (directConImport) {
|
||||
return entry.reexportedIn
|
||||
? unqualifiedImport(entry.reexportedIn)
|
||||
: entry.memberOf
|
||||
? unqualifiedImport(entry.memberOf)
|
||||
return (
|
||||
entry.reexportedIn ? unqualifiedImport(entry.reexportedIn)
|
||||
: entry.memberOf ? unqualifiedImport(entry.memberOf)
|
||||
: []
|
||||
)
|
||||
} else {
|
||||
const selfType = selfTypeEntry(db, entry)
|
||||
return selfType ? requiredImports(db, selfType) : []
|
||||
@ -254,11 +254,9 @@ export function requiredImportEquals(left: RequiredImport, right: RequiredImport
|
||||
/** Check if `existing` import statement covers `required`. */
|
||||
export function covers(existing: Import, required: RequiredImport): boolean {
|
||||
const [parent, name] =
|
||||
required.kind === 'Qualified'
|
||||
? qnSplit(required.module)
|
||||
: required.kind === 'Unqualified'
|
||||
? [required.from, required.import]
|
||||
: [undefined, '']
|
||||
required.kind === 'Qualified' ? qnSplit(required.module)
|
||||
: required.kind === 'Unqualified' ? [required.from, required.import]
|
||||
: [undefined, '']
|
||||
const directlyImported =
|
||||
required.kind === 'Qualified' &&
|
||||
existing.imported.kind === 'Module' &&
|
||||
|
@ -34,7 +34,16 @@ import { SourceDocument } from 'shared/ast/sourceDocument'
|
||||
import type { ExpressionUpdate, StackItem } from 'shared/languageServerTypes'
|
||||
import type { LocalOrigin, SourceRangeKey, VisualizationMetadata } from 'shared/yjsModel'
|
||||
import { defaultLocalOrigin, sourceRangeKey, visMetadataEquals } from 'shared/yjsModel'
|
||||
import { computed, markRaw, reactive, ref, toRef, watch, type ShallowRef } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
markRaw,
|
||||
reactive,
|
||||
ref,
|
||||
shallowReactive,
|
||||
toRef,
|
||||
watch,
|
||||
type ShallowRef,
|
||||
} from 'vue'
|
||||
|
||||
export type {
|
||||
Node,
|
||||
@ -53,7 +62,9 @@ export class PortViewInstance {
|
||||
public rect: ShallowRef<Rect | undefined>,
|
||||
public nodeId: NodeId,
|
||||
public onUpdate: (update: WidgetUpdate) => void,
|
||||
) {}
|
||||
) {
|
||||
markRaw(this)
|
||||
}
|
||||
}
|
||||
|
||||
export const useGraphStore = defineStore('graph', () => {
|
||||
@ -77,7 +88,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
toRef(suggestionDb, 'groups'),
|
||||
proj.computedValueRegistry,
|
||||
)
|
||||
const portInstances = reactive(new Map<PortId, Set<PortViewInstance>>())
|
||||
const portInstances = shallowReactive(new Map<PortId, Set<PortViewInstance>>())
|
||||
const editedNodeInfo = ref<NodeEditInfo>()
|
||||
const methodAst = ref<Ast.Function>()
|
||||
|
||||
@ -107,8 +118,12 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
function handleModuleUpdate(module: Module, moduleChanged: boolean, update: ModuleUpdate) {
|
||||
const root = module.root()
|
||||
if (!root) return
|
||||
moduleRoot.value = root
|
||||
if (root instanceof Ast.BodyBlock) topLevel.value = root
|
||||
if (moduleRoot.value != root) {
|
||||
moduleRoot.value = root
|
||||
}
|
||||
if (root instanceof Ast.BodyBlock && topLevel.value != root) {
|
||||
topLevel.value = root
|
||||
}
|
||||
// We can cast maps of unknown metadata fields to `NodeMetadata` because all `NodeMetadata` fields are optional.
|
||||
const nodeMetadataUpdates = update.metadataUpdated as any as {
|
||||
id: AstId
|
||||
@ -375,25 +390,38 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
}
|
||||
|
||||
function updateNodeRect(nodeId: NodeId, rect: Rect) {
|
||||
const nodeAst = syncModule.value?.tryGet(nodeId)
|
||||
if (!nodeAst) return
|
||||
if (rect.pos.equals(Vec2.Zero) && !nodeAst.nodeMetadata.get('position')) {
|
||||
const { position } = nonDictatedPlacement(rect.size, {
|
||||
nodeRects: visibleNodeAreas.value,
|
||||
// The rest of the properties should not matter.
|
||||
selectedNodeRects: [],
|
||||
screenBounds: Rect.Zero,
|
||||
mousePosition: Vec2.Zero,
|
||||
})
|
||||
editNodeMetadata(nodeAst, (metadata) =>
|
||||
metadata.set('position', { x: position.x, y: position.y }),
|
||||
)
|
||||
nodeRects.set(nodeId, new Rect(position, rect.size))
|
||||
} else {
|
||||
nodeRects.set(nodeId, rect)
|
||||
nodeRects.set(nodeId, rect)
|
||||
if (rect.pos.equals(Vec2.Zero)) {
|
||||
nodesToPlace.push(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const nodesToPlace = reactive<NodeId[]>([])
|
||||
|
||||
watch(nodesToPlace, (nodeIds) => {
|
||||
if (nodeIds.length === 0) return
|
||||
const nodesToProcess = [...nodeIds]
|
||||
nodesToPlace.length = 0
|
||||
batchEdits(() => {
|
||||
for (const nodeId of nodesToProcess) {
|
||||
const nodeAst = syncModule.value?.get(nodeId)
|
||||
const rect = nodeRects.get(nodeId)
|
||||
if (!rect || !nodeAst || nodeAst.nodeMetadata.get('position') != null) continue
|
||||
const { position } = nonDictatedPlacement(rect.size, {
|
||||
nodeRects: visibleNodeAreas.value,
|
||||
// The rest of the properties should not matter.
|
||||
selectedNodeRects: [],
|
||||
screenBounds: Rect.Zero,
|
||||
mousePosition: Vec2.Zero,
|
||||
})
|
||||
editNodeMetadata(nodeAst, (metadata) =>
|
||||
metadata.set('position', { x: position.x, y: position.y }),
|
||||
)
|
||||
nodeRects.set(nodeId, new Rect(position, rect.size))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function updateVizRect(id: NodeId, rect: Rect | undefined) {
|
||||
if (rect) vizRects.set(id, rect)
|
||||
else vizRects.delete(id)
|
||||
@ -506,6 +534,11 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
return result!
|
||||
}
|
||||
|
||||
function batchEdits(f: () => void) {
|
||||
assert(syncModule.value != null)
|
||||
syncModule.value.transact(f, 'local')
|
||||
}
|
||||
|
||||
function editNodeMetadata(ast: Ast.Ast, f: (metadata: Ast.MutableNodeMetadata) => void) {
|
||||
edit((edit) => f(edit.getVersion(ast).mutableNodeMetadata()), true, true)
|
||||
}
|
||||
@ -626,6 +659,7 @@ export const useGraphStore = defineStore('graph', () => {
|
||||
createNode,
|
||||
deleteNodes,
|
||||
ensureCorrectNodeOrder,
|
||||
batchEdits,
|
||||
setNodeContent,
|
||||
setNodePosition,
|
||||
setNodeVisualization,
|
||||
|
@ -45,7 +45,6 @@ import {
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect,
|
||||
type ShallowRef,
|
||||
type WatchSource,
|
||||
type WritableComputedRef,
|
||||
} from 'vue'
|
||||
@ -220,9 +219,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
||||
executionContextId: this.id,
|
||||
expression: config.expression,
|
||||
visualizationModule: config.visualizationModule,
|
||||
...(config.positionalArgumentsExpressions
|
||||
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
...(config.positionalArgumentsExpressions ?
|
||||
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
}),
|
||||
'Failed to attach visualization',
|
||||
).then(() => {
|
||||
@ -237,9 +236,9 @@ export class ExecutionContext extends ObservableV2<ExecutionContextNotification>
|
||||
executionContextId: this.id,
|
||||
expression: config.expression,
|
||||
visualizationModule: config.visualizationModule,
|
||||
...(config.positionalArgumentsExpressions
|
||||
? { positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
...(config.positionalArgumentsExpressions ?
|
||||
{ positionalArgumentsExpressions: config.positionalArgumentsExpressions }
|
||||
: {}),
|
||||
}),
|
||||
'Failed to modify visualization',
|
||||
).then(() => {
|
||||
@ -582,9 +581,7 @@ export const useProjectStore = defineStore('project', () => {
|
||||
diagnostics.value = newDiagnostics
|
||||
})
|
||||
|
||||
function useVisualizationData(
|
||||
configuration: WatchSource<Opt<NodeVisualizationConfiguration>>,
|
||||
): ShallowRef<Result<{}> | undefined> {
|
||||
function useVisualizationData(configuration: WatchSource<Opt<NodeVisualizationConfiguration>>) {
|
||||
const id = random.uuidv4() as Uuid
|
||||
|
||||
watch(
|
||||
@ -598,28 +595,28 @@ export const useProjectStore = defineStore('project', () => {
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
return shallowRef(
|
||||
computed(() => {
|
||||
const json = visualizationDataRegistry.getRawData(id)
|
||||
if (!json?.ok) return json ?? undefined
|
||||
else return Ok(JSON.parse(json.value))
|
||||
}),
|
||||
)
|
||||
return computed(() => {
|
||||
const json = visualizationDataRegistry.getRawData(id)
|
||||
if (!json?.ok) return json ?? undefined
|
||||
const parsed = Ok(JSON.parse(json.value))
|
||||
markRaw(parsed)
|
||||
return parsed
|
||||
})
|
||||
}
|
||||
|
||||
const dataflowErrors = new ReactiveMapping(computedValueRegistry.db, (id, info) => {
|
||||
const config = computed(() =>
|
||||
info.payload.type === 'DataflowError'
|
||||
? {
|
||||
expressionId: id,
|
||||
visualizationModule: 'Standard.Visualization.Preprocessor',
|
||||
expression: {
|
||||
module: 'Standard.Visualization.Preprocessor',
|
||||
definedOnType: 'Standard.Visualization.Preprocessor',
|
||||
name: 'error_preprocessor',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
info.payload.type === 'DataflowError' ?
|
||||
{
|
||||
expressionId: id,
|
||||
visualizationModule: 'Standard.Visualization.Preprocessor',
|
||||
expression: {
|
||||
module: 'Standard.Visualization.Preprocessor',
|
||||
definedOnType: 'Standard.Visualization.Preprocessor',
|
||||
name: 'error_preprocessor',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
)
|
||||
const data = useVisualizationData(config)
|
||||
return computed<{ kind: 'Dataflow'; message: string } | undefined>(() => {
|
||||
|
@ -416,10 +416,10 @@ async function rewriteImports(code: string, dir: string, id: string | undefined)
|
||||
const pathJSON = JSON.stringify(path)
|
||||
const destructureExpression = `{ ${specifiers.join(', ')} }`
|
||||
const rewritten =
|
||||
namespace != null
|
||||
? `const ${namespace} = await window.__visualizationModules[${pathJSON}];` +
|
||||
(specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '')
|
||||
: `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];`
|
||||
namespace != null ?
|
||||
`const ${namespace} = await window.__visualizationModules[${pathJSON}];` +
|
||||
(specifiers.length > 0 ? `\nconst ${destructureExpression} = ${namespace};` : '')
|
||||
: `const ${destructureExpression} = await window.__visualizationModules[${pathJSON}];`
|
||||
s.overwrite(stmt.start!, stmt.end!, rewritten)
|
||||
if (isBuiltin) {
|
||||
// No further action is needed.
|
||||
@ -437,6 +437,7 @@ async function rewriteImports(code: string, dir: string, id: string | undefined)
|
||||
if (mimetype != null) {
|
||||
return importAsset(path, mimetype)
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -484,9 +485,9 @@ onmessage = async (
|
||||
case 'compile-request': {
|
||||
try {
|
||||
const path = event.data.path
|
||||
await (event.data.recompile
|
||||
? importVue(path)
|
||||
: map.setIfUndefined(alreadyCompiledModules, path, () => importVue(path)))
|
||||
await (event.data.recompile ?
|
||||
importVue(path)
|
||||
: map.setIfUndefined(alreadyCompiledModules, path, () => importVue(path)))
|
||||
postMessage<CompilationResultResponse>({
|
||||
type: 'compilation-result-response',
|
||||
id: event.data.id,
|
||||
|
@ -235,12 +235,12 @@ export const useVisualizationStore = defineStore('visualization', () => {
|
||||
|
||||
function* types(type: Opt<string>) {
|
||||
const types =
|
||||
type == null
|
||||
? metadata.keys()
|
||||
: new Set([
|
||||
...(metadata.visualizationIdToType.reverseLookup(type) ?? []),
|
||||
...(metadata.visualizationIdToType.reverseLookup('Any') ?? []),
|
||||
])
|
||||
type == null ?
|
||||
metadata.keys()
|
||||
: new Set([
|
||||
...(metadata.visualizationIdToType.reverseLookup(type) ?? []),
|
||||
...(metadata.visualizationIdToType.reverseLookup('Any') ?? []),
|
||||
])
|
||||
for (const type of types) yield fromVisualizationId(type)
|
||||
}
|
||||
|
||||
|
@ -9,10 +9,13 @@ import {
|
||||
readTokenSpan,
|
||||
walkRecursive,
|
||||
} from '@/util/ast'
|
||||
import { fc, test } from '@fast-check/vitest'
|
||||
import { initializeFFI } from 'shared/ast/ffi'
|
||||
import { Token, Tree } from 'shared/ast/generated/ast'
|
||||
import type { LazyObject } from 'shared/ast/parserSupport'
|
||||
import { assert, expect, test } from 'vitest'
|
||||
import { escapeTextLiteral, unescapeTextLiteral } from 'shared/ast/text'
|
||||
import { assert, expect } from 'vitest'
|
||||
import { TextLiteral } from '../abstract'
|
||||
|
||||
await initializeFFI()
|
||||
|
||||
@ -224,3 +227,46 @@ test.each([
|
||||
expect(readAstSpan(ast, code)).toBe(expected?.repr)
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
['', ''],
|
||||
['\\x20', ' ', ' '],
|
||||
['\\b', '\b'],
|
||||
['abcdef_123', 'abcdef_123'],
|
||||
['\\t\\r\\n\\v\\"\\\'\\`', '\t\r\n\v"\'`'],
|
||||
['\\u00B6\\u{20}\\U\\u{D8\\xBFF}', '\xB6 \0\xD8\xBFF}', '\xB6 \\0\xD8\xBFF}'],
|
||||
['\\`foo\\` \\`bar\\` \\`baz\\`', '`foo` `bar` `baz`'],
|
||||
])(
|
||||
'Applying and escaping text literal interpolation',
|
||||
(escapedText: string, rawText: string, roundtrip?: string) => {
|
||||
const actualApplied = unescapeTextLiteral(escapedText)
|
||||
const actualEscaped = escapeTextLiteral(rawText)
|
||||
|
||||
expect(actualEscaped).toBe(roundtrip ?? escapedText)
|
||||
expect(actualApplied).toBe(rawText)
|
||||
},
|
||||
)
|
||||
|
||||
const sometimesUnicodeString = fc.oneof(fc.string(), fc.unicodeString())
|
||||
|
||||
test.prop({ rawText: sometimesUnicodeString })('Text interpolation roundtrip', ({ rawText }) => {
|
||||
expect(unescapeTextLiteral(escapeTextLiteral(rawText))).toBe(rawText)
|
||||
})
|
||||
|
||||
test.prop({ rawText: sometimesUnicodeString })('AST text literal new', ({ rawText }) => {
|
||||
const literal = TextLiteral.new(rawText)
|
||||
expect(literal.rawTextContent).toBe(rawText)
|
||||
})
|
||||
|
||||
test.prop({
|
||||
boundary: fc.constantFrom('"', "'"),
|
||||
rawText: sometimesUnicodeString,
|
||||
})('AST text literal rawTextContent', ({ boundary, rawText }) => {
|
||||
const literal = TextLiteral.new('')
|
||||
literal.setBoundaries(boundary)
|
||||
literal.setRawTextContent(rawText)
|
||||
expect(literal.rawTextContent).toBe(rawText)
|
||||
const expectInterpolated = rawText.includes('"') || boundary === "'"
|
||||
const expectedCode = expectInterpolated ? `'${escapeTextLiteral(rawText)}'` : `"${rawText}"`
|
||||
expect(literal.code()).toBe(expectedCode)
|
||||
})
|
||||
|
@ -107,11 +107,9 @@ function printArgPattern(application: ArgumentApplication | Ast.Ast) {
|
||||
|
||||
while (current instanceof ArgumentApplication) {
|
||||
const sigil =
|
||||
current.argument instanceof ArgumentPlaceholder
|
||||
? '?'
|
||||
: current.appTree instanceof Ast.App && current.appTree.argumentName
|
||||
? '='
|
||||
: '@'
|
||||
current.argument instanceof ArgumentPlaceholder ? '?'
|
||||
: current.appTree instanceof Ast.App && current.appTree.argumentName ? '='
|
||||
: '@'
|
||||
parts.push(sigil + (current.argument.argInfo?.name ?? '_'))
|
||||
current = current.target
|
||||
}
|
||||
|
@ -92,11 +92,11 @@ test.each([
|
||||
).toBe(extracted != null)
|
||||
expect(
|
||||
patternAst.match(targetAst)?.map((match) => module.tryGet(match)?.code()),
|
||||
extracted != null
|
||||
? `'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted)
|
||||
.slice(1, -1)
|
||||
.replace(/"/g, "'")}`
|
||||
: `'${target}' does not match '${pattern}'`,
|
||||
extracted != null ?
|
||||
`'${target}' matches '${pattern}' with '__'s corresponding to ${JSON.stringify(extracted)
|
||||
.slice(1, -1)
|
||||
.replace(/"/g, "'")}`
|
||||
: `'${target}' does not match '${pattern}'`,
|
||||
).toStrictEqual(extracted)
|
||||
})
|
||||
|
||||
|
@ -1,13 +0,0 @@
|
||||
import * as astText from '@/util/ast/text'
|
||||
import { unescape } from 'querystring'
|
||||
import { expect, test } from 'vitest'
|
||||
|
||||
test.each([
|
||||
{ string: 'abcdef_123', escaped: 'abcdef_123' },
|
||||
{ string: '\t\r\n\v"\'`', escaped: '\\t\\r\\n\\v\\"\\\'``' },
|
||||
{ string: '`foo` `bar` `baz`', escaped: '``foo`` ``bar`` ``baz``' },
|
||||
])('`escape`', ({ string, escaped }) => {
|
||||
const result = astText.escape(string)
|
||||
expect(result).toBe(escaped)
|
||||
expect(unescape(escaped)).toBe(string)
|
||||
})
|
@ -302,10 +302,9 @@ export class AliasAnalyzer {
|
||||
const expression = caseLine.case?.expression
|
||||
if (pattern) {
|
||||
const armStart = parsedTreeOrTokenRange(pattern)[0]
|
||||
const armEnd = expression
|
||||
? parsedTreeOrTokenRange(expression)[1]
|
||||
: arrow
|
||||
? parsedTreeOrTokenRange(arrow)[1]
|
||||
const armEnd =
|
||||
expression ? parsedTreeOrTokenRange(expression)[1]
|
||||
: arrow ? parsedTreeOrTokenRange(arrow)[1]
|
||||
: parsedTreeOrTokenRange(pattern)[1]
|
||||
|
||||
const armRange: SourceRange = [armStart, armEnd]
|
||||
|
@ -18,9 +18,8 @@ import { tryGetSoleValue } from 'shared/util/data/iterable'
|
||||
import type { ExternalId, IdMap, SourceRange } from 'shared/yjsModel'
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
type ExtractType<V, T> = T extends ReadonlyArray<infer Ts>
|
||||
? Extract<V, { type: Ts }>
|
||||
: Extract<V, { type: T }>
|
||||
type ExtractType<V, T> =
|
||||
T extends ReadonlyArray<infer Ts> ? Extract<V, { type: Ts }> : Extract<V, { type: T }>
|
||||
|
||||
type OneOrArray<T> = T | readonly T[]
|
||||
|
||||
@ -149,8 +148,8 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
|
||||
}
|
||||
|
||||
whitespaceLength() {
|
||||
return 'whitespaceLengthInCodeBuffer' in this.inner
|
||||
? this.inner.whitespaceLengthInCodeBuffer
|
||||
return 'whitespaceLengthInCodeBuffer' in this.inner ?
|
||||
this.inner.whitespaceLengthInCodeBuffer
|
||||
: this.inner.whitespaceLengthInCodeParsed
|
||||
}
|
||||
|
||||
@ -179,10 +178,9 @@ export class AstExtended<T extends Tree | Token = Tree | Token, HasIdMap extends
|
||||
}
|
||||
}
|
||||
|
||||
type CondType<T, Cond extends boolean> = Cond extends true
|
||||
? T
|
||||
: Cond extends false
|
||||
? undefined
|
||||
type CondType<T, Cond extends boolean> =
|
||||
Cond extends true ? T
|
||||
: Cond extends false ? undefined
|
||||
: T | undefined
|
||||
|
||||
class AstExtendedCtx<HasIdMap extends boolean> {
|
||||
|
@ -3,9 +3,9 @@ import { Ast } from '@/util/ast'
|
||||
|
||||
export function nodeFromAst(ast: Ast.Ast): NodeDataFromAst | undefined {
|
||||
const { nodeCode, documentation } =
|
||||
ast instanceof Ast.Documented
|
||||
? { nodeCode: ast.expression, documentation: ast.documentation() }
|
||||
: { nodeCode: ast, documentation: undefined }
|
||||
ast instanceof Ast.Documented ?
|
||||
{ nodeCode: ast.expression, documentation: ast.documentation() }
|
||||
: { nodeCode: ast, documentation: undefined }
|
||||
if (!nodeCode) return
|
||||
const pattern = nodeCode instanceof Ast.Assignment ? nodeCode.pattern : undefined
|
||||
const rootSpan = nodeCode instanceof Ast.Assignment ? nodeCode.expression : nodeCode
|
||||
|
@ -1,27 +0,0 @@
|
||||
import { swapKeysAndValues } from '@/util/record'
|
||||
|
||||
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]!)
|
||||
}
|
@ -1,10 +1,43 @@
|
||||
import { useEvent } from '@/composables/events'
|
||||
import type { Ref } from 'vue'
|
||||
import { watchEffect, type Ref } from 'vue'
|
||||
|
||||
/** Automatically `blur` the currently active element on any mouse click outside of `root`.
|
||||
* It is useful when other elements may capture pointer events, preventing default browser behavior for focus change. */
|
||||
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | MathMLElement | undefined>) {
|
||||
useEvent(window, 'pointerdown', (event) => blurIfNecessary(root, event), { capture: true })
|
||||
export function useAutoBlur(root: Ref<HTMLElement | SVGElement | undefined>) {
|
||||
watchEffect((onCleanup) => {
|
||||
const element = root.value
|
||||
if (element) {
|
||||
autoBlurRoots.add(element)
|
||||
onCleanup(() => autoBlurRoots.delete(element))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const autoBlurRoots = new Set<HTMLElement | SVGElement | MathMLElement>()
|
||||
|
||||
export function registerAutoBlurHandler() {
|
||||
useEvent(
|
||||
window,
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
if (
|
||||
!(event.target instanceof Element) ||
|
||||
(!(document.activeElement instanceof HTMLElement) &&
|
||||
!(document.activeElement instanceof SVGElement) &&
|
||||
!(document.activeElement instanceof MathMLElement))
|
||||
)
|
||||
return false
|
||||
|
||||
for (const root of autoBlurRoots) {
|
||||
if (root.contains(document.activeElement) && !root.contains(event.target)) {
|
||||
document.activeElement.blur()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns true if the target of the event is in the DOM subtree of the given `area` element. */
|
||||
@ -14,21 +47,3 @@ export function targetIsOutside(
|
||||
): boolean {
|
||||
return !!area.value && e.target instanceof Element && !area.value.contains(e.target)
|
||||
}
|
||||
|
||||
/** Internal logic of `useAutoBlur`, useful for direct usage in some cases.
|
||||
* Returns `true` if `event` does not target `root` and blurs currently active element.
|
||||
* Otherwise returns `false` and does nothing. */
|
||||
export function blurIfNecessary(
|
||||
root: Ref<HTMLElement | SVGElement | MathMLElement | undefined>,
|
||||
event: MouseEvent,
|
||||
): boolean {
|
||||
if (!root.value?.contains(document.activeElement) || !targetIsOutside(event, root)) return false
|
||||
if (
|
||||
!(document.activeElement instanceof HTMLElement) &&
|
||||
!(document.activeElement instanceof SVGElement) &&
|
||||
!(document.activeElement instanceof MathMLElement)
|
||||
)
|
||||
return false
|
||||
document.activeElement.blur()
|
||||
return true
|
||||
}
|
||||
|
@ -178,8 +178,8 @@ export class ArgumentApplication {
|
||||
const argFor = (key: 'lhs' | 'rhs', index: number) => {
|
||||
const tree = interpreted[key]
|
||||
const info = tryGetIndex(suggestion?.arguments, index) ?? unknownArgInfoNamed(key)
|
||||
return tree != null
|
||||
? ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg)
|
||||
return tree != null ?
|
||||
ArgumentAst.WithRetrievedConfig(tree, index, info, kind, widgetCfg)
|
||||
: ArgumentPlaceholder.WithRetrievedConfig(callId, index, info, kind, false, widgetCfg)
|
||||
}
|
||||
return new ArgumentApplication(
|
||||
@ -308,9 +308,9 @@ export class ArgumentApplication {
|
||||
})
|
||||
} else {
|
||||
const argumentFromDefinition =
|
||||
argumentInCode.argName == null
|
||||
? takeNextArgumentFromDefinition()
|
||||
: takeNamedArgumentFromDefinition(argumentInCode.argName)
|
||||
argumentInCode.argName == null ?
|
||||
takeNextArgumentFromDefinition()
|
||||
: takeNamedArgumentFromDefinition(argumentInCode.argName)
|
||||
const { index, info } = argumentFromDefinition ?? {}
|
||||
resolvedArgs.push({
|
||||
appTree: argumentInCode.appTree,
|
||||
@ -318,9 +318,9 @@ export class ArgumentApplication {
|
||||
argumentInCode.argument,
|
||||
index,
|
||||
info ??
|
||||
(argumentInCode.argName != null
|
||||
? unknownArgInfoNamed(argumentInCode.argName)
|
||||
: undefined),
|
||||
(argumentInCode.argName != null ?
|
||||
unknownArgInfoNamed(argumentInCode.argName)
|
||||
: undefined),
|
||||
ApplicationKind.Prefix,
|
||||
widgetCfg,
|
||||
),
|
||||
@ -375,9 +375,9 @@ export class ArgumentApplication {
|
||||
toWidgetInput(): WidgetInput {
|
||||
return {
|
||||
portId:
|
||||
this.argument instanceof ArgumentAst
|
||||
? this.appTree.id
|
||||
: (`app:${this.argument.portId}` as PortId),
|
||||
this.argument instanceof ArgumentAst ?
|
||||
this.appTree.id
|
||||
: (`app:${this.argument.portId}` as PortId),
|
||||
value: this.appTree,
|
||||
[ArgumentApplicationKey]: this,
|
||||
}
|
||||
|
@ -20,11 +20,11 @@ const STRING_TO_BOOLEAN: Record<string, boolean> = {
|
||||
* a string 'true', 'false', '1', or '0', it is converted to a boolean value. Otherwise, null is
|
||||
* returned. */
|
||||
function parseBoolean(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean'
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? STRING_TO_BOOLEAN[value] ?? null
|
||||
return (
|
||||
typeof value === 'boolean' ? value
|
||||
: typeof value === 'string' ? STRING_TO_BOOLEAN[value] ?? null
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
export interface StringConfig {
|
||||
@ -69,12 +69,12 @@ export interface Group<T = Required<RawGroup>> extends Config<T> {
|
||||
}
|
||||
|
||||
export interface Config<T = Required<RawConfig>> {
|
||||
options: T extends { options: infer Options extends object }
|
||||
? { [K in keyof Options]: Option<Options[K]> }
|
||||
: {}
|
||||
groups: T extends { groups: infer Groups extends object }
|
||||
? { [K in keyof Groups]: Group<Groups[K]> }
|
||||
: {}
|
||||
options: T extends { options: infer Options extends object } ?
|
||||
{ [K in keyof Options]: Option<Options[K]> }
|
||||
: {}
|
||||
groups: T extends { groups: infer Groups extends object } ?
|
||||
{ [K in keyof Groups]: Group<Groups[K]> }
|
||||
: {}
|
||||
}
|
||||
|
||||
function loadOption<T>(option: T): Option<T> {
|
||||
@ -87,12 +87,14 @@ function loadOption<T>(option: T): Option<T> {
|
||||
description: String(obj.description ?? ''),
|
||||
defaultDescription: obj.defaultDescription != null ? String(obj.defaultDescription) : undefined,
|
||||
value:
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
||||
? value
|
||||
: '',
|
||||
(
|
||||
typeof value === 'string' ||
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
(Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
||||
) ?
|
||||
value
|
||||
: '',
|
||||
primary: Boolean(obj.primary ?? true),
|
||||
} satisfies Option<RawOption> as any
|
||||
}
|
||||
@ -111,13 +113,13 @@ export function loadConfig<T>(config: T): Config<T> {
|
||||
}
|
||||
return {
|
||||
options:
|
||||
'options' in config && typeof config.options === 'object' && config.options != null
|
||||
? Object.fromEntries(Object.entries(config.options).map(([k, v]) => [k, loadOption(v)]))
|
||||
: {},
|
||||
'options' in config && typeof config.options === 'object' && config.options != null ?
|
||||
Object.fromEntries(Object.entries(config.options).map(([k, v]) => [k, loadOption(v)]))
|
||||
: {},
|
||||
groups:
|
||||
'groups' in config && typeof config.groups === 'object' && config.groups != null
|
||||
? Object.fromEntries(Object.entries(config.groups).map(([k, v]) => [k, loadGroup(v)]))
|
||||
: {},
|
||||
'groups' in config && typeof config.groups === 'object' && config.groups != null ?
|
||||
Object.fromEntries(Object.entries(config.groups).map(([k, v]) => [k, loadGroup(v)]))
|
||||
: {},
|
||||
} satisfies Config as any
|
||||
}
|
||||
|
||||
|
@ -20,7 +20,7 @@ export function nextEvent<O extends ObservableV2<any>, NAME extends string>(
|
||||
|
||||
declare const EVENTS_BRAND: unique symbol
|
||||
declare module 'lib0/observable' {
|
||||
interface ObservableV2<EVENTS extends { [key: string]: (...arg0: any[]) => void }> {
|
||||
interface ObservableV2<EVENTS extends { [key in keyof EVENTS]: (...arg0: any[]) => void }> {
|
||||
[EVENTS_BRAND]: EVENTS
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +0,0 @@
|
||||
export function asNot<Not>(value: any): Excluding<Not, typeof value> {
|
||||
return value as any
|
||||
}
|
||||
|
||||
type Excluding<Not, Value> = Value extends Not ? never : Value
|
@ -1,3 +1,5 @@
|
||||
import { shallowReactive } from 'vue'
|
||||
|
||||
let _measureContext: CanvasRenderingContext2D | undefined
|
||||
function getMeasureContext() {
|
||||
return (_measureContext ??= document.createElement('canvas').getContext('2d')!)
|
||||
@ -14,11 +16,47 @@ export function getTextWidthBySizeAndFamily(
|
||||
|
||||
/** Helper function to get text width. `font` is a CSS font specification as per https://developer.mozilla.org/en-US/docs/Web/CSS/font. */
|
||||
export function getTextWidthByFont(text: string | null | undefined, font: string) {
|
||||
if (text == null) {
|
||||
if (text == null || font == '' || !fontReady(font)) {
|
||||
return 0
|
||||
}
|
||||
const context = getMeasureContext()
|
||||
context.font = font
|
||||
const metrics = context.measureText(' ' + text)
|
||||
const metrics = context.measureText(text)
|
||||
return metrics.width
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores loading status of queried fonts, so we can make the check synchronous and reactive.
|
||||
* This is supposed to be global, since the font loading state is scoped to the document and cannot
|
||||
* revert back to loading (assuming we don't dynamically change existing @font-face definitions to
|
||||
* point to different URLs, which would be incredibly cursed).
|
||||
*/
|
||||
const fontsReady = shallowReactive(new Map())
|
||||
|
||||
/**
|
||||
* Check if given font is ready to use. In case if it is not, the check will automatically register
|
||||
* a reactive dependency, which will be notified once loading is complete.
|
||||
* @param font
|
||||
* @returns
|
||||
*/
|
||||
function fontReady(font: string): boolean {
|
||||
const readyState = fontsReady.get(font)
|
||||
if (readyState === undefined) {
|
||||
let syncReady
|
||||
try {
|
||||
// This operation can fail if the provided font string is not a valid CSS font specifier.
|
||||
syncReady = document.fonts.check(font)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
// In case of exception, treat the font as if it was loaded. That way we don't attempt loading
|
||||
// it again, and the browser font fallback logic should still make things behave more or less
|
||||
// correct.
|
||||
syncReady = true
|
||||
}
|
||||
fontsReady.set(font, syncReady)
|
||||
if (syncReady) return true
|
||||
else document.fonts.load(font).then(() => fontsReady.set(font, true))
|
||||
return false
|
||||
}
|
||||
return readyState
|
||||
}
|
||||
|
@ -209,24 +209,21 @@ type NormalizeKeybindSegment = {
|
||||
[K in KeybindSegment as Lowercase<K>]: K
|
||||
}
|
||||
type SuggestedKeybindSegment = ModifierPlus | Pointer | Key
|
||||
type AutocompleteKeybind<T extends string, Key extends string = never> = T extends '+'
|
||||
? T
|
||||
: T extends `${infer First}+${infer Rest}`
|
||||
? Lowercase<First> extends LowercaseModifier
|
||||
? `${NormalizeKeybindSegment[Lowercase<First>] & string}+${AutocompleteKeybind<Rest>}`
|
||||
: Lowercase<First> extends LowercasePointer | LowercaseKey
|
||||
? AutocompleteKeybind<Rest, NormalizeKeybindSegment[Lowercase<First>] & string>
|
||||
type AutocompleteKeybind<T extends string, Key extends string = never> =
|
||||
T extends '+' ? T
|
||||
: T extends `${infer First}+${infer Rest}` ?
|
||||
Lowercase<First> extends LowercaseModifier ?
|
||||
`${NormalizeKeybindSegment[Lowercase<First>] & string}+${AutocompleteKeybind<Rest>}`
|
||||
: Lowercase<First> extends LowercasePointer | LowercaseKey ?
|
||||
AutocompleteKeybind<Rest, NormalizeKeybindSegment[Lowercase<First>] & string>
|
||||
: `${Modifier}+${AutocompleteKeybind<Rest>}`
|
||||
: T extends ''
|
||||
? SuggestedKeybindSegment
|
||||
: Lowercase<T> extends LowercasePointer | LowercaseKey
|
||||
? NormalizeKeybindSegment[Lowercase<T>]
|
||||
: Lowercase<T> extends LowercaseModifier
|
||||
? [Key] extends [never]
|
||||
? `${NormalizeKeybindSegment[Lowercase<T>] & string}+${SuggestedKeybindSegment}`
|
||||
: T extends '' ? SuggestedKeybindSegment
|
||||
: Lowercase<T> extends LowercasePointer | LowercaseKey ? NormalizeKeybindSegment[Lowercase<T>]
|
||||
: Lowercase<T> extends LowercaseModifier ?
|
||||
[Key] extends [never] ?
|
||||
`${NormalizeKeybindSegment[Lowercase<T>] & string}+${SuggestedKeybindSegment}`
|
||||
: `${NormalizeKeybindSegment[Lowercase<T>] & string}+${Key}`
|
||||
: [Key] extends [never]
|
||||
? SuggestedKeybindSegment
|
||||
: [Key] extends [never] ? SuggestedKeybindSegment
|
||||
: Key
|
||||
|
||||
type AutocompleteKeybinds<T extends string[]> = {
|
||||
@ -235,8 +232,9 @@ type AutocompleteKeybinds<T extends string[]> = {
|
||||
|
||||
// `never extends T ? Result : InferenceSource` is a trick to unify `T` with the actual type of the
|
||||
// argument.
|
||||
type Keybinds<T extends Record<K, string[]>, K extends keyof T = keyof T> = never extends T
|
||||
? {
|
||||
type Keybinds<T extends Record<K, string[]>, K extends keyof T = keyof T> =
|
||||
never extends T ?
|
||||
{
|
||||
[K in keyof T]: AutocompleteKeybinds<T[K]>
|
||||
}
|
||||
: T
|
||||
@ -353,9 +351,9 @@ export function defineKeybinds<
|
||||
return (event, stopAndPrevent = true) => {
|
||||
const eventModifierFlags = modifierFlagsForEvent(event)
|
||||
const keybinds =
|
||||
event instanceof KeyboardEvent
|
||||
? keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags]
|
||||
: mouseShortcuts[buttonFlagsForEvent(event)]?.[eventModifierFlags]
|
||||
event instanceof KeyboardEvent ?
|
||||
keyboardShortcuts[event.key.toLowerCase() as Key_]?.[eventModifierFlags]
|
||||
: mouseShortcuts[buttonFlagsForEvent(event)]?.[eventModifierFlags]
|
||||
let handle = handlers[DefaultHandler]
|
||||
if (keybinds != null) {
|
||||
for (const bindingName in handlers) {
|
||||
|
@ -42,8 +42,9 @@ watchEffect(async (onCleanup) => {
|
||||
const prefixLength = props.prefix?.length ?? 0
|
||||
const directory = maybeDirectory
|
||||
const ls = await projectStore.lsRpcConnection
|
||||
const maybeProjectRoot = (await projectStore.contentRoots).find((root) => root.type === 'Project')
|
||||
?.id
|
||||
const maybeProjectRoot = (await projectStore.contentRoots).find(
|
||||
(root) => root.type === 'Project',
|
||||
)?.id
|
||||
if (!maybeProjectRoot) return
|
||||
const projectRoot = maybeProjectRoot
|
||||
async function walkFiles(
|
||||
|
@ -18,6 +18,7 @@
|
||||
"outDir": "../../node_modules/.cache/tsc",
|
||||
"baseUrl": ".",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
@ -22,6 +22,7 @@
|
||||
"outDir": "../../node_modules/.cache/tsc",
|
||||
"baseUrl": ".",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
|
@ -30,17 +30,16 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
...(process.env.E2E === 'true'
|
||||
? { '/src/main.ts': fileURLToPath(new URL('./e2e/main.ts', import.meta.url)) }
|
||||
: {}),
|
||||
...(process.env.E2E === 'true' ?
|
||||
{ '/src/main.ts': fileURLToPath(new URL('./e2e/main.ts', import.meta.url)) }
|
||||
: {}),
|
||||
shared: fileURLToPath(new URL('./shared', import.meta.url)),
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
define: {
|
||||
REDIRECT_OVERRIDE: IS_CLOUD_BUILD
|
||||
? 'undefined'
|
||||
: JSON.stringify(`http://localhost:${localServerPort}`),
|
||||
REDIRECT_OVERRIDE:
|
||||
IS_CLOUD_BUILD ? 'undefined' : JSON.stringify(`http://localhost:${localServerPort}`),
|
||||
IS_CLOUD_BUILD: JSON.stringify(IS_CLOUD_BUILD),
|
||||
PROJECT_MANAGER_URL: JSON.stringify(projectManagerUrl),
|
||||
IS_DEV_MODE: JSON.stringify(process.env.NODE_ENV === 'development'),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user