mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 02:01:47 +03:00
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:
parent
2cfd6fa672
commit
921632e38d
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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'],
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
|
262
app/gui2/src/components/ComponentBrowser/ComponentList.vue
Normal file
262
app/gui2/src/components/ComponentBrowser/ComponentList.vue
Normal 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>
|
@ -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
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user