New design of CB: two modes (#10814)

Fixes #10603

[Screencast from 2024-08-14 12-10-51.webm](https://github.com/user-attachments/assets/fcd5bfa4-b128-4a84-a19f-c14e78dae8c9)

What is not yet implemented: the filtering. That means that spaces keep their special meaning, and we still display modules and types.

The component list itself was refactored to a separate vue component.

The logic of default visualization type in preview changed a bit: as now there is no selected component, we remember with what suggestion have we switched to code edit mode.
This commit is contained in:
Adam Obuchowicz 2024-08-19 15:28:38 +02:00 committed by GitHub
parent 2cfd6fa672
commit 921632e38d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 493 additions and 466 deletions

View File

@ -3,9 +3,14 @@
#### Enso IDE
- [Table Editor Widget][10774] displayed in `Table.new` component.
- [New design of Component Browser][10814] - the component list is under the
input and shown only in the initial "component browsing" mode - after picking
any suggestion with Tab or new button the mode is switched to "code editing",
where visualization preview is displayed instead.
- [Drilldown for XML][10824]
[10774]: https://github.com/enso-org/enso/pull/10774
[10814]: https://github.com/enso-org/enso/pull/10814
[10824]: https://github.com/enso-org/enso/pull/10824
#### Enso Standard Library

View File

@ -106,7 +106,7 @@ test('Graph Editor pans to Component Browser', async ({ page }) => {
// Dragging out an edge to the bottom of the viewport; when the CB pans into view, some nodes are out of view.
await page.mouse.move(100, 1100)
await page.mouse.down({ button: 'middle' })
await page.mouse.move(100, 80)
await page.mouse.move(100, 280)
await page.mouse.up({ button: 'middle' })
await expect(locate.graphNodeByBinding(page, 'five')).toBeInViewport()
const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final'))
@ -240,14 +240,10 @@ test('Visualization preview: type-based visualization selection', async ({ 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 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
// visualization is no longer selected.
await expect(locate.jsonVisualization(page)).toBeHidden()
await locate.componentBrowser(page).getByTestId('switchToEditMode').click()
await expect(locate.tableVisualization(page)).toBeVisible()
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount)
@ -258,17 +254,15 @@ test('Visualization preview: user visualization selection', async ({ page }) =>
const nodeCount = await locate.graphNode(page).count()
await locate.addNewNodeButton(page).click()
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 expect(locate.jsonVisualization(page)).toExist()
await locate.componentBrowser(page).getByTestId('switchToEditMode').click()
await expect(locate.jsonVisualization(page)).toBeVisible()
await expect(locate.jsonVisualization(page)).toContainText('"visualizedExpr": "4"')
await locate.toggleVisualizationSelectorButton(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
// visualization is no longer selected.
await expect(locate.jsonVisualization(page)).toBeHidden()
await expect(locate.tableVisualization(page)).toBeVisible()
await page.keyboard.press('Escape')
await expect(locate.componentBrowser(page)).toBeHidden()
await expect(locate.graphNode(page)).toHaveCount(nodeCount)

View File

@ -19,8 +19,9 @@ export const interactionBindings = defineKeybinds('current-interaction', {
})
export const componentBrowserBindings = defineKeybinds('component-browser', {
applySuggestion: ['Tab'],
applySuggestionAndSwitchToEditMode: ['Tab'],
acceptSuggestion: ['Enter'],
acceptCode: ['Enter'],
acceptInput: ['Mod+Enter'],
acceptAIPrompt: ['Tab', 'Enter'],
moveUp: ['ArrowUp'],

View File

@ -1,15 +1,24 @@
<script lang="ts">
/** One of the modes of the component browser:
* * "component browsing" when user wants to add new component
* * "code editing" for editing existing, or just added nodes
* See https://github.com/enso-org/enso/issues/10598 for design details.
*/
export enum ComponentBrowserMode {
COMPONENT_BROWSING,
CODE_EDITING,
}
</script>
<script setup lang="ts">
import { componentBrowserBindings } from '@/bindings'
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { type Component } from '@/components/ComponentBrowser/component'
import ComponentEditor from '@/components/ComponentBrowser/ComponentEditor.vue'
import ComponentList from '@/components/ComponentBrowser/ComponentList.vue'
import { Filtering } from '@/components/ComponentBrowser/filtering'
import { useComponentBrowserInput, type Usage } from '@/components/ComponentBrowser/input'
import { useScrolling } from '@/components/ComponentBrowser/scrolling'
import DocumentationPanel from '@/components/DocumentationPanel.vue'
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.vue'
import { useApproach } from '@/composables/animation'
import SvgButton from '@/components/SvgButton.vue'
import { useResizeObserver } from '@/composables/events'
import type { useNavigator } from '@/composables/navigator'
import { groupColorStyle } from '@/composables/nodeColors'
@ -21,30 +30,22 @@ import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { SuggestionKind, type Typename } from '@/stores/suggestionDatabase/entry'
import type { VisualizationDataSource } from '@/stores/visualization'
import { endOnClickOutside, isNodeOutside } from '@/util/autoBlur'
import { cancelOnClickOutside, isNodeOutside } from '@/util/autoBlur'
import { tryGetIndex } from '@/util/data/array'
import type { Opt } from '@/util/data/opt'
import { allRanges } from '@/util/data/range'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { DEFAULT_ICON, suggestionEntryToIcon } from '@/util/getIconName'
import { iconOfNode } from '@/util/getIconName.ts'
import { debouncedGetter } from '@/util/reactivity'
import type { ComponentInstance, Ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, watchEffect } from 'vue'
import type { SuggestionId } from 'ydoc-shared/languageServerTypes/suggestions'
import type { VisualizationIdentifier } from 'ydoc-shared/yjsModel'
import LoadingSpinner from './LoadingSpinner.vue'
const ITEM_SIZE = 32
const TOP_BAR_HEIGHT = 32
// Difference in position between the component browser and a node for the input of the component browser to
// be placed at the same position as the node.
const COMPONENT_BROWSER_TO_NODE_OFFSET = new Vec2(-4, -4)
const WIDTH = 600
const INPUT_AREA_HEIGHT = 40
const PANELS_HEIGHT = 384
// Height of the visualization area, starting from the bottom of the input area.
const VISUALIZATION_HEIGHT = 190
const PAN_MARGINS = {
top: 48,
bottom: 40,
@ -77,8 +78,15 @@ const emit = defineEmits<{
}>()
const cbRoot = ref<HTMLElement>()
const componentList = ref<ComponentInstance<typeof ComponentList>>()
const cbOpen: Interaction = endOnClickOutside(cbRoot, {
const mode = ref<ComponentBrowserMode>(
props.usage.type === 'newNode' ?
ComponentBrowserMode.COMPONENT_BROWSING
: ComponentBrowserMode.CODE_EDITING,
)
const cbOpen: Interaction = cancelOnClickOutside(cbRoot, {
cancel: () => emit('canceled'),
end: () => {
// In AI prompt mode likely the input is not a valid mode.
@ -107,26 +115,23 @@ const originScenePos = computed(() => {
function panIntoView() {
const origin = originScenePos.value
const inputArea = new Rect(
const screenRect = cbRoot.value?.getBoundingClientRect()
if (!screenRect) return
const area = new Rect(
origin,
new Vec2(WIDTH, INPUT_AREA_HEIGHT).scale(clientToSceneFactor.value),
new Vec2(screenRect.width, screenRect.height).scale(clientToSceneFactor.value),
)
const panelsAreaDimensions = new Vec2(WIDTH, PANELS_HEIGHT).scale(clientToSceneFactor.value)
const panelsArea = new Rect(origin.sub(new Vec2(0, panelsAreaDimensions.y)), panelsAreaDimensions)
const vizHeight = VISUALIZATION_HEIGHT * clientToSceneFactor.value
const margins = scaleValues(PAN_MARGINS, clientToSceneFactor.value)
props.navigator.panTo([
// Always include the bottom-left of the input area.
{ x: inputArea.left, y: inputArea.bottom },
// Try to reach the top-right corner of the panels.
{ x: inputArea.right, y: panelsArea.top },
// Extend down to include the visualization.
{ y: inputArea.bottom + vizHeight },
// Always include the top-left of the input area.
{ x: area.left, y: area.top },
// Try to reach the bottom-right corner of the panels.
{ x: area.right, y: area.bottom },
// Top (and left) margins are more important than bottom (and right) margins because the screen has controls across
// the top and on the left.
{ x: inputArea.left - margins.left, y: panelsArea.top - margins.top },
{ x: area.left - margins.left, y: area.top - margins.top },
// If the screen is very spacious, even the bottom right gets some breathing room.
{ x: inputArea.right + margins.right, y: inputArea.bottom + vizHeight + margins.bottom },
{ x: area.right + margins.right, y: area.bottom + margins.bottom },
])
}
@ -149,35 +154,27 @@ const transform = computed(() => {
const x = Math.round(screenPosition.x)
const y = Math.round(screenPosition.y)
return `translate(${x}px, ${y}px) translateY(-100%)`
return `translate(${x}px, ${y}px)`
})
// === Selection ===
const selected = ref<Component | null>(null)
const selectedSuggestionId = computed(() => selected.value?.suggestionId)
const selectedSuggestion = computed(() => {
const id = selectedSuggestionId.value
if (id == null) return null
return suggestionDbStore.entries.get(id) ?? null
})
// === Input and Filtering ===
const input = useComponentBrowserInput()
const filterFlags = ref({ showUnstable: false, showLocal: false })
const currentFiltering = computed(() => {
const currentModule = projectStore.modulePath
return new Filtering(
{
...input.filter.value,
...filterFlags.value,
},
currentModule?.ok ? currentModule.value : undefined,
)
})
watch(currentFiltering, () => {
selected.value = input.autoSelectFirstComponent.value ? 0 : null
scrolling.targetScroll.value = { type: 'bottom' }
// Update `highlightPosition` synchronously, so the subsequent animation `skip` have an effect.
if (selectedPosition.value != null) {
highlightPosition.value = selectedPosition.value
}
animatedHighlightPosition.skip()
animatedHighlightHeight.skip()
return new Filtering(input.filter.value, currentModule?.ok ? currentModule.value : undefined)
})
onUnmounted(() => {
@ -216,63 +213,6 @@ function handleDefocus(e: FocusEvent) {
const inputElement = ref<ComponentInstance<typeof ComponentEditor>>()
const inputSize = useResizeObserver(inputElement, false)
// === Components List and Positions ===
const components = computed(() =>
makeComponentList(suggestionDbStore.entries, currentFiltering.value),
)
const visibleComponents = computed(() => {
if (scroller.value == null) return []
const scrollPos = scrolling.scrollPosition.value
const topmostVisible = componentAtY(scrollPos)
const bottommostVisible = Math.max(0, componentAtY(scrollPos + scrollerSize.value.y))
return components.value.slice(bottommostVisible, topmostVisible + 1).map((component, i) => {
return { component, index: i + bottommostVisible }
})
})
function componentPos(index: number) {
return listContentHeight.value - (index + 1) * ITEM_SIZE
}
function componentAtY(pos: number) {
return Math.floor((listContentHeight.value - pos) / ITEM_SIZE)
}
function componentStyle(index: number) {
return { transform: `translateY(${componentPos(index)}px)` }
}
/**
* Group colors are populated in `GraphEditor`, and for each group in suggestion database a CSS variable is created.
*/
function componentColor(component: Component): string {
return groupColorStyle(tryGetIndex(suggestionDbStore.groups, component.group))
}
// === Highlight ===
const selected = ref<number | null>(null)
const highlightPosition = ref(0)
const selectedPosition = computed(() =>
selected.value != null ? componentPos(selected.value) : null,
)
const highlightHeight = computed(() => (selected.value != null ? ITEM_SIZE : 0))
const animatedHighlightPosition = useApproach(highlightPosition)
const animatedHighlightHeight = useApproach(highlightHeight)
const selectedSuggestionId = computed(() => {
if (selected.value === null) return null
return components.value[selected.value]?.suggestionId ?? null
})
const selectedSuggestion = computed(() => {
const id = selectedSuggestionId.value
if (id == null) return null
return suggestionDbStore.entries.get(id) ?? null
})
const { getNodeColor } = injectNodeColors()
const nodeColor = computed(() => {
if (props.usage.type === 'editNode') {
@ -295,101 +235,54 @@ watchEffect(() => {
})
const selectedSuggestionIcon = computed(() => {
return selectedSuggestion.value ? suggestionEntryToIcon(selectedSuggestion.value) : undefined
})
const icon = computed(() => {
if (!input.selfArgument.value) return undefined
return selectedSuggestion.value ? suggestionEntryToIcon(selectedSuggestion.value) : DEFAULT_ICON
if (mode.value === ComponentBrowserMode.COMPONENT_BROWSING && selectedSuggestionIcon.value)
return selectedSuggestionIcon.value
if (props.usage.type === 'editNode') {
return iconOfNode(props.usage.node, graphStore.db)
}
return DEFAULT_ICON
})
watch(selectedPosition, (newPos) => {
if (newPos == null) return
highlightPosition.value = newPos
})
const highlightClipPath = computed(() => {
let height = animatedHighlightHeight.value
let position = animatedHighlightPosition.value
let top = position + ITEM_SIZE - height
let bottom = listContentHeight.value - position - ITEM_SIZE
return `inset(${top}px 0px ${bottom}px 0px round 16px)`
})
function selectWithoutScrolling(index: number) {
const scrollPos = scrolling.scrollPosition.value
scrolling.targetScroll.value = { type: 'offset', offset: scrollPos }
selected.value = index
}
// === Preview ===
type PreviewState = { expression: string; suggestionId?: SuggestionId }
const previewed = debouncedGetter<PreviewState>(() => {
if (selectedSuggestionId.value == null || selectedSuggestion.value == null) {
return { expression: input.code.value }
} else {
return {
expression: input.inputAfterApplyingSuggestion(selectedSuggestion.value).newCode,
suggestionId: selectedSuggestionId.value,
}
}
}, 200)
const previewedCode = debouncedGetter<string>(() => input.code.value, 200)
const previewedSuggestionReturnType = computed(() => {
const id = previewed.value.suggestionId
if (id == null) return
return suggestionDbStore.entries.get(id)?.returnType
const id = appliedSuggestion.value
const appliedEntry = id != null ? suggestionDbStore.entries.get(id) : undefined
if (appliedEntry != null) return appliedEntry.returnType
else if (props.usage.type === 'editNode') {
return graphStore.db.getNodeMainSuggestion(props.usage.node)?.returnType
}
return undefined
})
const previewDataSource = computed<VisualizationDataSource | undefined>(() => {
if (input.isAiPrompt.value) return
if (!previewed.value.expression.trim()) return
if (!previewedCode.value.trim()) return
if (!graphStore.methodAst.ok) return
const body = graphStore.methodAst.value.body
if (!body) return
return {
type: 'expression',
expression: previewed.value.expression,
expression: previewedCode.value,
contextId: body.externalId,
}
})
const visualizationSelections = reactive(new Map<SuggestionId | null, VisualizationIdentifier>())
const previewedVisualizationId = computed(() => {
return visualizationSelections.get(previewed.value.suggestionId ?? null)
})
function setVisualization(visualization: VisualizationIdentifier) {
visualizationSelections.set(previewed.value.suggestionId ?? null, visualization)
}
// === Scrolling ===
const scroller = ref<HTMLElement>()
const scrollerSize = useResizeObserver(scroller)
const listContentHeight = computed(() =>
// We add a top padding of TOP_BAR_HEIGHT / 2 - otherwise the topmost entry would be covered
// by top bar.
Math.max(components.value.length * ITEM_SIZE + TOP_BAR_HEIGHT / 2, scrollerSize.value.y),
const visualizationSelection = ref<Opt<VisualizationIdentifier>>(
props.usage.type === 'editNode' ?
graphStore.db.nodeIdToNode.get(props.usage.node)?.vis?.identifier
: undefined,
)
const scrolling = useScrolling(
animatedHighlightPosition,
computed(() => scrollerSize.value.y),
listContentHeight,
ITEM_SIZE,
)
const listContentHeightPx = computed(() => `${listContentHeight.value}px`)
function updateScroll() {
// If the scrollTop value changed significantly, that means the user is scrolling.
if (scroller.value && Math.abs(scroller.value.scrollTop - scrolling.scrollPosition.value) > 1.0) {
scrolling.targetScroll.value = { type: 'offset', offset: scroller.value.scrollTop }
}
}
// === Documentation Panel ===
const docsVisible = ref(true)
const docEntry: Ref<Opt<SuggestionId>> = ref(null)
watch(selectedSuggestionId, (id) => {
@ -398,10 +291,18 @@ watch(selectedSuggestionId, (id) => {
// === Accepting Entry ===
const appliedSuggestion = ref<SuggestionId>()
function applySuggestion(component: Opt<Component> = null) {
const suggestionId = component?.suggestionId ?? selectedSuggestionId.value
if (suggestionId == null) return
input.applySuggestion(suggestionId)
appliedSuggestion.value = suggestionId
}
function applySuggestionAndSwitchToEditMode() {
applySuggestion()
mode.value = ComponentBrowserMode.CODE_EDITING
}
function acceptSuggestion(component: Opt<Component> = null) {
@ -427,14 +328,20 @@ function acceptInput() {
// === Key Events Handler ===
const handler = componentBrowserBindings.handler({
applySuggestion() {
if (input.isAiPrompt.value) return false
applySuggestion()
applySuggestionAndSwitchToEditMode() {
if (mode.value != ComponentBrowserMode.COMPONENT_BROWSING || input.isAiPrompt.value)
return false
applySuggestionAndSwitchToEditMode()
},
acceptSuggestion() {
if (input.isAiPrompt.value) return false
if (mode.value != ComponentBrowserMode.COMPONENT_BROWSING || input.isAiPrompt.value)
return false
acceptSuggestion()
},
acceptCode() {
if (mode.value != ComponentBrowserMode.CODE_EDITING || input.isAiPrompt.value) return false
acceptInput()
},
acceptInput() {
if (input.isAiPrompt.value) return false
acceptInput()
@ -443,18 +350,10 @@ const handler = componentBrowserBindings.handler({
if (input.isAiPrompt.value) input.applyAIPrompt()
},
moveUp() {
if (selected.value != null && selected.value < components.value.length - 1) {
selected.value += 1
}
scrolling.scrollWithTransition({ type: 'selected' })
componentList.value?.moveUp()
},
moveDown() {
if (selected.value == null) {
selected.value = components.value.length - 1
} else if (selected.value > 0) {
selected.value -= 1
}
scrolling.scrollWithTransition({ type: 'selected' })
componentList.value?.moveDown()
},
})
</script>
@ -463,7 +362,7 @@ const handler = componentBrowserBindings.handler({
<div
ref="cbRoot"
class="ComponentBrowser"
:style="{ transform, '--list-height': listContentHeightPx }"
:style="{ transform }"
:data-self-argument="input.selfArgument.value"
tabindex="-1"
@focusout="handleDefocus"
@ -477,126 +376,67 @@ const handler = componentBrowserBindings.handler({
@keydown.arrow-left.stop
@keydown.arrow-right.stop
>
<div class="panels">
<div class="panel components">
<div class="top-bar">
<div class="top-bar-inner">
<ToggleIcon v-model="filterFlags.showLocal" icon="local_scope2" />
<ToggleIcon icon="command3" />
<ToggleIcon v-model="filterFlags.showUnstable" icon="unstable2" />
<ToggleIcon icon="marketplace" />
<ToggleIcon v-model="docsVisible" icon="right_side_panel" class="first-on-right" />
</div>
</div>
<div v-if="!input.isAiPrompt.value" class="components-content">
<div
ref="scroller"
class="list"
:scrollTop.prop="scrolling.scrollPosition.value"
@wheel.stop.passive
@scroll="updateScroll"
>
<div class="list-variant">
<div
v-for="item in visibleComponents"
:key="item.component.suggestionId"
class="component"
:style="componentStyle(item.index)"
@mousemove="selectWithoutScrolling(item.index)"
@click="acceptSuggestion(item.component)"
>
<SvgIcon
:name="item.component.icon"
:style="{ color: componentColor(item.component) }"
/>
<span>
<span v-if="!item.component.matchedRanges" v-text="item.component.label"></span>
<span
v-for="range in allRanges(
item.component.matchedRanges,
item.component.label.length,
)"
v-else
:key="`${range.start},${range.end}`"
class="component-label-segment"
:class="{ match: range.isMatch }"
v-text="item.component.label.slice(range.start, range.end)"
></span>
</span>
</div>
</div>
<div class="list-variant selected" :style="{ clipPath: highlightClipPath }">
<div
v-for="item in visibleComponents"
:key="item.component.suggestionId"
class="component"
:style="{
backgroundColor: componentColor(item.component),
...componentStyle(item.index),
}"
@click="acceptSuggestion(item.component)"
>
<SvgIcon :name="item.component.icon" />
<span>
<span v-if="!item.component.matchedRanges" v-text="item.component.label"></span>
<span
v-for="range in allRanges(
item.component.matchedRanges,
item.component.label.length,
)"
v-else
:key="`${range.start},${range.end}`"
class="component-label-segment"
:class="{ match: range.isMatch }"
v-text="item.component.label.slice(range.start, range.end)"
></span>
</span>
</div>
</div>
</div>
</div>
<LoadingSpinner v-if="input.isAiPrompt.value && input.processingAIPrompt" />
</div>
<div class="panel docs" :class="{ hidden: !docsVisible }">
<DocumentationPanel v-model:selectedEntry="docEntry" :aiMode="input.isAiPrompt.value" />
</div>
</div>
<div class="bottom-panel">
<GraphVisualization
class="visualization-preview"
:nodeSize="inputSize"
:nodePosition="nodePosition"
:scale="1"
:isCircularMenuVisible="false"
:isFullscreen="false"
:isFocused="true"
:width="null"
:height="null"
:dataSource="previewDataSource"
:typename="previewedSuggestionReturnType"
:currentType="previewedVisualizationId"
@update:id="setVisualization($event)"
<GraphVisualization
v-if="mode === ComponentBrowserMode.CODE_EDITING && !input.isAiPrompt.value"
class="visualization-preview"
:nodeSize="inputSize"
:nodePosition="nodePosition"
:scale="1"
:isCircularMenuVisible="false"
:isFullscreen="false"
:isFocused="true"
:width="null"
:height="null"
:dataSource="previewDataSource"
:typename="previewedSuggestionReturnType"
:currentType="visualizationSelection"
@update:id="visualizationSelection = $event"
/>
<ComponentEditor
ref="inputElement"
v-model="input.content.value"
class="component-editor"
:navigator="props.navigator"
:icon="icon"
:nodeColor="nodeColor"
:style="{ '--component-editor-padding': cssComponentEditorPadding }"
>
<SvgButton
name="add"
:title="
mode === ComponentBrowserMode.COMPONENT_BROWSING && selected != null ?
'Accept Suggested Component'
: 'Accept'
"
@click.stop="
mode === ComponentBrowserMode.COMPONENT_BROWSING ? acceptSuggestion() : acceptInput()
"
/>
<ComponentEditor
ref="inputElement"
v-model="input.content.value"
:navigator="props.navigator"
:icon="selectedSuggestionIcon"
:nodeColor="nodeColor"
class="component-editor"
:style="{ '--component-editor-padding': cssComponentEditorPadding }"
<SvgButton
name="edit"
:disabled="mode === ComponentBrowserMode.CODE_EDITING"
:title="selected != null ? 'Edit Suggested Component' : 'Code Edit Mode'"
data-testid="switchToEditMode"
@click.stop="applySuggestionAndSwitchToEditMode()"
/>
</div>
</ComponentEditor>
<ComponentList
v-if="mode === ComponentBrowserMode.COMPONENT_BROWSING && !input.isAiPrompt.value"
ref="componentList"
:filtering="currentFiltering"
:autoSelectFirstComponent="input.autoSelectFirstComponent.value"
@acceptSuggestion="acceptSuggestion($event)"
@update:selectedComponent="selected = $event"
/>
</div>
</template>
<style scoped>
.ComponentBrowser {
--list-height: 0px;
--radius-default: 20px;
--background-color: #eaeaea;
--doc-panel-bottom-clip: 4px;
width: fit-content;
width: 295px;
color: rgba(0, 0, 0, 0.6);
font-size: 11.5px;
display: flex;
@ -604,120 +444,8 @@ const handler = componentBrowserBindings.handler({
gap: 4px;
}
.panels {
display: flex;
flex-direction: row;
gap: 4px;
}
.panel {
height: 380px;
border: none;
border-radius: var(--radius-default);
background-color: var(--background-color);
}
.components {
width: 190px;
position: relative;
}
.components-content {
position: absolute;
top: 0px;
padding: 4px;
width: 100%;
height: 100%;
}
.docs {
width: 406px;
clip-path: inset(0 0 0 0 round var(--radius-default));
transition: clip-path 0.2s;
}
.docs.hidden {
clip-path: inset(0 100% 0 0 round var(--radius-default));
}
.list {
top: var(--radius-default);
width: 100%;
height: calc(100% - var(--radius-default));
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
.list-variant {
top: 0px;
width: 100%;
height: var(--list-height);
position: absolute;
}
.component {
width: 100%;
height: 32px;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 9px;
display: flex;
position: absolute;
line-height: 1;
font-family: var(--font-code);
}
.selected {
color: white;
& svg {
color: white;
}
}
.component-label-segment.match {
font-weight: bold;
}
.top-bar {
width: 100%;
height: 40px;
padding: 4px;
background-color: var(--background-color);
border-radius: var(--radius-default);
position: absolute;
top: 0px;
z-index: 1;
}
.top-bar-inner {
width: 100%;
height: 100%;
border-radius: 16px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: row;
gap: 12px;
padding: 7px;
& * {
color: rgba(0, 0, 0, 0.18);
transition: color 0.2s;
}
& .first-on-right {
margin-left: auto;
}
& > .toggledOn {
color: rgba(0, 0, 0, 0.6);
}
}
.bottom-panel {
position: relative;
}
.component-editor {
position: absolute;
position: relative;
}
.visualization-preview {

View File

@ -90,6 +90,9 @@ const rootStyle = computed(() => {
@pointerup.stop
@click.stop
/>
<div class="buttonPanel">
<slot></slot>
</div>
</div>
</template>
@ -144,4 +147,12 @@ const rootStyle = computed(() => {
width: var(--icon-height);
height: var(--icon-height);
}
.buttonPanel {
display: flex;
flex-direction: row;
flex-shrink: 0;
flex-grow: 0;
gap: 8px;
}
</style>

View File

@ -0,0 +1,262 @@
<script setup lang="ts">
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { Filtering } from '@/components/ComponentBrowser/filtering'
import { useScrolling } from '@/components/ComponentBrowser/scrolling'
import SvgIcon from '@/components/SvgIcon.vue'
import { useApproach } from '@/composables/animation'
import { useResizeObserver } from '@/composables/events'
import { groupColorStyle } from '@/composables/nodeColors'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { tryGetIndex } from '@/util/data/array'
import { allRanges } from '@/util/data/range'
import { computed, ref, watch } from 'vue'
const ITEM_SIZE = 32
const props = defineProps<{
filtering: Filtering
autoSelectFirstComponent: boolean
}>()
const emit = defineEmits<{
acceptSuggestion: [suggestion: Component]
'update:selectedComponent': [selected: Component | null]
}>()
const suggestionDbStore = useSuggestionDbStore()
// === Components List and Positions ===
const components = computed(() => makeComponentList(suggestionDbStore.entries, props.filtering))
const visibleComponents = computed(() => {
if (scroller.value == null) return []
const scrollPos = scrolling.scrollPosition.value
const topmostVisible = componentAtY(scrollPos)
const bottommostVisible = Math.max(0, componentAtY(scrollPos + scrollerSize.value.y))
return components.value.slice(topmostVisible, bottommostVisible + 1).map((component, i) => {
return { component, index: i + topmostVisible }
})
})
function componentPos(index: number) {
return index * ITEM_SIZE
}
function componentAtY(pos: number) {
return Math.floor(pos / ITEM_SIZE)
}
function componentStyle(index: number) {
return { transform: `translateY(${componentPos(index)}px)` }
}
/**
* Group colors are populated in `GraphEditor`, and for each group in suggestion database a CSS variable is created.
*/
function componentColor(component: Component): string {
return groupColorStyle(tryGetIndex(suggestionDbStore.groups, component.group))
}
// === Highlight ===
const selected = ref<number | null>(null)
const highlightPosition = ref(0)
const selectedPosition = computed(() =>
selected.value != null ? componentPos(selected.value) : null,
)
const highlightHeight = computed(() => (selected.value != null ? ITEM_SIZE : 0))
const animatedHighlightPosition = useApproach(highlightPosition)
const animatedHighlightHeight = useApproach(highlightHeight)
const selectedComponent = computed(() => {
if (selected.value === null) return null
return components.value[selected.value] ?? null
})
watch(selectedComponent, (component) => emit('update:selectedComponent', component))
watch(selectedPosition, (newPos) => {
if (newPos == null) return
highlightPosition.value = newPos
})
const highlightClipPath = computed(() => {
let height = animatedHighlightHeight.value
let position = animatedHighlightPosition.value
let top = position + ITEM_SIZE - height
let bottom = listContentHeight.value - position - ITEM_SIZE
return `inset(${top}px 0px ${bottom}px 0px round 16px)`
})
function selectWithoutScrolling(index: number) {
const scrollPos = scrolling.scrollPosition.value
scrolling.targetScroll.value = { type: 'offset', offset: scrollPos }
selected.value = index
}
// === Scrolling ===
const scroller = ref<HTMLElement>()
const scrollerSize = useResizeObserver(scroller)
const listContentHeight = computed(() =>
Math.max(components.value.length * ITEM_SIZE, scrollerSize.value.y),
)
const scrolling = useScrolling(() => animatedHighlightPosition.value)
const listContentHeightPx = computed(() => `${listContentHeight.value}px`)
function updateScroll() {
// If the scrollTop value changed significantly, that means the user is scrolling.
if (scroller.value && Math.abs(scroller.value.scrollTop - scrolling.scrollPosition.value) > 1.0) {
scrolling.targetScroll.value = { type: 'offset', offset: scroller.value.scrollTop }
}
}
// === Filtering Changes ===
watch(
() => props.filtering,
() => {
selected.value = props.autoSelectFirstComponent ? 0 : null
scrolling.targetScroll.value = { type: 'top' }
// Update `highlightPosition` synchronously, so the subsequent animation `skip` have an effect.
if (selectedPosition.value != null) {
highlightPosition.value = selectedPosition.value
}
animatedHighlightPosition.skip()
animatedHighlightHeight.skip()
},
)
// === Expose ===
defineExpose({
moveUp() {
if (selected.value != null && selected.value > 0) {
selected.value -= 1
}
scrolling.scrollWithTransition({ type: 'selected' })
},
moveDown() {
if (selected.value == null) {
selected.value = 0
} else if (selected.value < components.value.length - 1) {
selected.value += 1
}
scrolling.scrollWithTransition({ type: 'selected' })
},
})
</script>
<template>
<div class="ComponentList" :style="{ '--list-height': listContentHeightPx }">
<div
ref="scroller"
class="list"
:scrollTop.prop="scrolling.scrollPosition.value"
@wheel.stop.passive
@scroll="updateScroll"
>
<div class="list-variant">
<div
v-for="item in visibleComponents"
:key="item.component.suggestionId"
class="component"
:style="componentStyle(item.index)"
@mousemove="selectWithoutScrolling(item.index)"
@click="emit('acceptSuggestion', item.component)"
>
<SvgIcon :name="item.component.icon" :style="{ color: componentColor(item.component) }" />
<span>
<span v-if="!item.component.matchedRanges" v-text="item.component.label"></span>
<span
v-for="range in allRanges(item.component.matchedRanges, item.component.label.length)"
v-else
:key="`${range.start},${range.end}`"
class="component-label-segment"
:class="{ match: range.isMatch }"
v-text="item.component.label.slice(range.start, range.end)"
></span>
</span>
</div>
</div>
<div class="list-variant selected" :style="{ clipPath: highlightClipPath }">
<div
v-for="item in visibleComponents"
:key="item.component.suggestionId"
class="component"
:style="{
backgroundColor: componentColor(item.component),
...componentStyle(item.index),
}"
@click="emit('acceptSuggestion', item.component)"
>
<SvgIcon :name="item.component.icon" />
<span>
<span v-if="!item.component.matchedRanges" v-text="item.component.label"></span>
<span
v-for="range in allRanges(item.component.matchedRanges, item.component.label.length)"
v-else
:key="`${range.start},${range.end}`"
class="component-label-segment"
:class="{ match: range.isMatch }"
v-text="item.component.label.slice(range.start, range.end)"
></span>
</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.ComponentList {
--list-height: 0px;
width: 100%;
height: 380px;
/* position: absolute; */
border: none;
border-radius: var(--radius-default);
background-color: var(--background-color);
}
.list {
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
position: relative;
}
.list-variant {
top: 0px;
width: 100%;
height: var(--list-height);
position: absolute;
}
.component {
width: 100%;
height: 32px;
flex-direction: row;
align-items: center;
gap: 8px;
padding: 9px;
display: flex;
position: absolute;
line-height: 1;
font-family: var(--font-code);
}
.selected {
color: white;
& svg {
color: white;
}
}
.component-label-segment.match {
font-weight: bold;
}
</style>

View File

@ -1,24 +1,20 @@
import { useApproach } from '@/composables/animation'
import { computed, ref } from 'vue'
import { ToValue } from '@/util/reactivity'
import { computed, ref, toValue } from 'vue'
export type ScrollTarget =
| { type: 'bottom' }
| { type: 'top' }
| { type: 'selected' }
| { type: 'offset'; offset: number }
export function useScrolling(
selectedPos: { value: number },
scrollerSize: { value: number },
contentSize: { value: number },
entrySize: number,
) {
const targetScroll = ref<ScrollTarget>({ type: 'bottom' })
export function useScrolling(selectedPos: ToValue<number>) {
const targetScroll = ref<ScrollTarget>({ type: 'top' })
const targetScrollPosition = computed(() => {
switch (targetScroll.value.type) {
case 'selected':
return Math.max(selectedPos.value - scrollerSize.value + entrySize, 0)
case 'bottom':
return contentSize.value - scrollerSize.value
return toValue(selectedPos)
case 'top':
return 0.0
case 'offset':
return targetScroll.value.offset
}

View File

@ -53,7 +53,7 @@ import { colorFromString } from '@/util/colors'
import { partition } from '@/util/data/array'
import { every, filterDefined } from '@/util/data/iterable'
import { Rect } from '@/util/data/rect'
import { Err, Ok, unwrapOr, type Result } from '@/util/data/result'
import { Err, Ok, unwrapOr } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2'
import { computedFallback } from '@/util/reactivity'
import { until } from '@vueuse/core'

View File

@ -8,7 +8,7 @@ import { useGraphStore, type NodeId } from '@/stores/graph'
import type { NodeType } from '@/stores/graph/graphDatabase'
import { Ast } from '@/util/ast'
import type { Vec2 } from '@/util/data/vec2'
import { displayedIconOf } from '@/util/getIconName'
import { iconOfNode } from '@/util/getIconName'
import { computed, toRef, watch } from 'vue'
import { DisplayIcon } from './widgets/WidgetIcon.vue'
@ -100,21 +100,7 @@ const widgetTree = provideWidgetTree(
() => emit('openFullMenu'),
)
const expressionInfo = computed(() => graph.db.getExpressionInfo(props.ast.externalId))
const suggestionEntry = computed(() => graph.db.getNodeMainSuggestion(props.nodeId))
const topLevelIcon = computed(() => {
switch (props.nodeType) {
default:
case 'component':
return displayedIconOf(
suggestionEntry.value,
expressionInfo.value?.methodCall?.methodPointer,
expressionInfo.value?.typename ?? 'Unknown',
)
case 'output':
return 'data_output'
}
})
const topLevelIcon = computed(() => iconOfNode(props.nodeId, graph.db))
watch(toRef(widgetTree, 'currentEdit'), (edit) => edit && selectNode())
</script>

View File

@ -15,7 +15,7 @@ const _props = defineProps<{
<template>
<MenuButton :disabled="disabled" class="SvgButton" :title="title">
<SvgIcon :name="name" />
<div v-if="label" v-text="label" />
<div v-if="label">{{ label }}</div>
</MenuButton>
</template>

View File

@ -21,7 +21,7 @@ import {
} from '@/util/reactivity'
import * as objects from 'enso-common/src/utilities/data/object'
import * as set from 'lib0/set'
import { reactive, ref, shallowReactive, watchEffect, WatchStopHandle, type Ref } from 'vue'
import { reactive, ref, shallowReactive, WatchStopHandle, type Ref } from 'vue'
import type { MethodCall, StackItem } from 'ydoc-shared/languageServerTypes'
import type { Opt } from 'ydoc-shared/util/data/opt'
import type { ExternalId, SourceRange, VisualizationMetadata } from 'ydoc-shared/yjsModel'

View File

@ -55,18 +55,43 @@ export function isNodeOutside(element: any, area: Opt<Node>): boolean {
}
/** Returns a new interaction based on the given `interaction`. The new interaction will be ended if a pointerdown event
* occurs outside the given `area` element. */
* occurs outside the given `area` element.
*
* See also {@link cancelOnClickOutside}.
*/
export function endOnClickOutside(
area: Ref<Element | VueInstance | null | undefined>,
area: Ref<Opt<Element | VueInstance>>,
interaction: Interaction,
): Interaction {
const chainedPointerdown = interaction.pointerdown
const handler = injectInteractionHandler()
return handleClickOutside(area, interaction, handler.end.bind(handler))
}
/** Returns a new interaction based on the given `interaction`. The new interaction will be canceled if a pointerdown event
* occurs outside the given `area` element.
*
* See also {@link endOnClickOutside}.
*/
export function cancelOnClickOutside(
area: Ref<Opt<Element | VueInstance>>,
interaction: Interaction,
) {
const handler = injectInteractionHandler()
return handleClickOutside(area, interaction, handler.cancel.bind(handler))
}
/** Common part of {@link cancelOnClickOutside} and {@link endOnClickOutside}. */
function handleClickOutside(
area: Ref<Opt<Element | VueInstance>>,
interaction: Interaction,
handler: (interaction: Interaction) => void,
) {
const chainedPointerdown = interaction.pointerdown
const wrappedInteraction: Interaction = {
...interaction,
pointerdown: (e: PointerEvent, ...args) => {
if (targetIsOutside(e, unrefElement(area))) {
handler.end(wrappedInteraction)
handler(wrappedInteraction)
return false
}
return chainedPointerdown ? chainedPointerdown(e, ...args) : false

View File

@ -1,3 +1,5 @@
import { NodeId } from '@/stores/graph'
import { GraphDb } from '@/stores/graph/graphDatabase'
import {
SuggestionKind,
type SuggestionEntry,
@ -43,3 +45,20 @@ export function displayedIconOf(
return DEFAULT_ICON
}
}
export function iconOfNode(node: NodeId, graphDb: GraphDb) {
const expressionInfo = graphDb.getExpressionInfo(node)
const suggestionEntry = graphDb.getNodeMainSuggestion(node)
const nodeType = graphDb.nodeIdToNode.get(node)?.type
switch (nodeType) {
default:
case 'component':
return displayedIconOf(
suggestionEntry,
expressionInfo?.methodCall?.methodPointer,
expressionInfo?.typename ?? 'Unknown',
)
case 'output':
return 'data_output'
}
}