Add context sensitive help to right side panel; implements #10582.

https://github.com/user-attachments/assets/0d6bce44-735a-4deb-86c9-72a088ab1d59

# Important Notes
- The rightmost (i.e. current) breadcrumb is no longer clickable.
This commit is contained in:
Kaz Wesley 2024-08-07 10:49:24 -04:00 committed by GitHub
parent b8c036c476
commit 3a4b942aae
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 397 additions and 135 deletions

View File

@ -5,6 +5,7 @@ import * as reactQuery from '@tanstack/react-query'
import invariant from 'tiny-invariant'
import type * as text from 'enso-common/src/text'
import * as tabBar from 'enso-common/src/utilities/style/tabBar'
import * as projectHooks from '#/hooks/projectHooks'
@ -82,39 +83,10 @@ export default function TabBar(props: TabBarProps) {
selectedTabRef.current = element
const bounds = element.getBoundingClientRect()
const rootBounds = backgroundElement.getBoundingClientRect()
const tabLeft = bounds.left - rootBounds.left + TAB_RADIUS_PX
const tabRight = bounds.right - rootBounds.left - TAB_RADIUS_PX
const rightSegments = [
'M 0 0',
`L ${rootBounds.width + window.outerWidth} 0`,
`L ${rootBounds.width + window.outerWidth} ${rootBounds.height}`,
`L ${tabRight + TAB_RADIUS_PX} ${rootBounds.height}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabRight} ${rootBounds.height - TAB_RADIUS_PX}`,
]
const leftSegments = [
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabLeft - TAB_RADIUS_PX} ${rootBounds.height}`,
`L 0 ${rootBounds.height}`,
'Z',
]
const segments = [
...rightSegments,
`L ${tabRight} ${TAB_RADIUS_PX}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 0 ${tabRight - TAB_RADIUS_PX} 0`,
`L ${tabLeft + TAB_RADIUS_PX} 0`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 0 ${tabLeft} ${TAB_RADIUS_PX}`,
`L ${tabLeft} ${rootBounds.height - TAB_RADIUS_PX}`,
...leftSegments,
]
backgroundElement.style.clipPath = `path("${segments.join(' ')}")`
const rootSegments = [
...rightSegments,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabRight - TAB_RADIUS_PX} ${rootBounds.height}`,
`L ${tabLeft + TAB_RADIUS_PX} ${rootBounds.height}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${tabLeft} ${rootBounds.height - TAB_RADIUS_PX}`,
...leftSegments,
]
const { clipPath, rootClipPath } = tabBar.barClipPath(bounds, rootBounds, TAB_RADIUS_PX)
backgroundElement.style.clipPath = clipPath
if (rootElement) {
rootElement.style.clipPath = `path("${rootSegments.join(' ')}")`
rootElement.style.clipPath = rootClipPath
}
}
}
@ -214,18 +186,7 @@ export function Tab(props: InternalTabProps) {
const element = ref.current
if (element) {
const bounds = element.getBoundingClientRect()
const segments = [
`M 0 ${bounds.height}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 0 ${TAB_RADIUS_PX} ${bounds.height - TAB_RADIUS_PX}`,
`L ${TAB_RADIUS_PX} ${TAB_RADIUS_PX}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${TAB_RADIUS_PX * 2} 0`,
`L ${bounds.width - TAB_RADIUS_PX * 2} 0`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 1 ${bounds.width - TAB_RADIUS_PX} ${TAB_RADIUS_PX}`,
`L ${bounds.width - TAB_RADIUS_PX} ${bounds.height - TAB_RADIUS_PX}`,
`A ${TAB_RADIUS_PX} ${TAB_RADIUS_PX} 0 0 0 ${bounds.width} ${bounds.height}`,
'Z',
]
element.style.clipPath = `path("${segments.join(' ')}")`
element.style.clipPath = tabBar.tabClipPath(bounds, TAB_RADIUS_PX)
}
}
})

View File

@ -1,5 +1,6 @@
import { expect, test } from 'playwright/test'
import * as actions from './actions'
import { mockMethodCallInfo } from './expressionUpdates'
import { CONTROL_KEY } from './keyboard'
import * as locate from './locate'
@ -48,3 +49,23 @@ test('Doc panel focus (regression #10471)', async ({ page }) => {
expect(content.includes('The main TEST method')).toBe(true)
await expect(locate.rightDock(page)).toContainText('The main TEST method')
})
test('Component help', async ({ page }) => {
await actions.goToGraph(page, false)
await locate.rightDock(page).getByRole('button', { name: 'Help' }).click()
await expect(locate.rightDock(page)).toHaveText(/Select a single component/)
await locate.graphNodeByBinding(page, 'final').click()
await expect(locate.rightDock(page)).toHaveText(/No documentation available/)
await mockMethodCallInfo(page, 'data', {
methodPointer: {
module: 'Standard.Base.Data',
definedOnType: 'Standard.Base.Data',
name: 'read',
},
notAppliedArguments: [0, 1, 2],
})
await locate.graphNodeByBinding(page, 'data').click()
await expect(locate.rightDock(page)).toHaveText(/Reads a file into Enso/)
})

View File

@ -1,11 +1,11 @@
<script setup lang="ts">
import { componentBrowserBindings } from '@/bindings'
import ComponentEditor from '@/components/ComponentBrowser/ComponentEditor.vue'
import { default as DocumentationPanel } from '@/components/ComponentBrowser/DocumentationPanel.vue'
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import ComponentEditor from '@/components/ComponentBrowser/ComponentEditor.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'
@ -390,15 +390,7 @@ function updateScroll() {
const docsVisible = ref(true)
const displayedDocs: Ref<Opt<SuggestionId>> = ref(null)
const docEntry = computed({
get() {
return displayedDocs.value
},
set(value) {
displayedDocs.value = value
},
})
const docEntry: Ref<Opt<SuggestionId>> = ref(null)
watch(selectedSuggestionId, (id) => {
docEntry.value = id
@ -603,6 +595,7 @@ const handler = componentBrowserBindings.handler({
--list-height: 0px;
--radius-default: 20px;
--background-color: #eaeaea;
--doc-panel-bottom-clip: 4px;
width: fit-content;
color: rgba(0, 0, 0, 0.6);
font-size: 11.5px;

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import DocumentationPanel from '@/components/DocumentationPanel.vue'
import { injectGraphSelection } from '@/providers/graphSelection'
import { useGraphStore } from '@/stores/graph'
import type { SuggestionId } from 'shared/languageServerTypes/suggestions'
import { Err, Ok, type Result } from 'shared/util/data/result'
import { ref, watchEffect } from 'vue'
const selection = injectGraphSelection()
const graphStore = useGraphStore()
const displayedDocs = ref<Result<SuggestionId>>()
function docsForSelection() {
const selected = selection.tryGetSoleSelection()
if (!selected.ok) return Err('Select a single component to display help')
const suggestionId = graphStore.db.nodeMainSuggestionId.lookup(selected.value)
if (suggestionId == null) return Err('No documentation available for selected component')
return Ok(suggestionId)
}
watchEffect(() => (displayedDocs.value = docsForSelection()))
</script>
<template>
<DocumentationPanel
v-if="displayedDocs?.ok"
:selectedEntry="displayedDocs.value"
@update:selectedEntry="displayedDocs = Ok($event)"
/>
<div v-else-if="displayedDocs?.ok === false" class="help-placeholder">
{{ displayedDocs.error.payload }}.
</div>
</template>
<style scoped>
.DocumentationPanel {
--list-height: 0px;
--radius-default: 20px;
--background-color: #fff;
--group-color-fallback: var(--color-dim);
}
.help-placeholder {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
</style>

View File

@ -6,15 +6,21 @@ import ToggleIcon from '@/components/ToggleIcon.vue'
import { useResizeObserver } from '@/composables/events'
import { Rect } from '@/util/data/rect'
import { Vec2 } from '@/util/data/vec2'
import { tabClipPath } from 'enso-common/src/utilities/style/tabBar'
import { computed, ref } from 'vue'
const MIN_DOCK_SIZE_PX = 200
const TAB_EDGE_MARGIN_PX = 4
const TAB_SIZE_PX = { width: 48 - TAB_EDGE_MARGIN_PX, height: 48 }
const TAB_RADIUS_PX = 8
const toolbarElement = ref<HTMLElement>()
const slideInPanel = ref<HTMLElement>()
type Tab = 'docs' | 'help'
const show = defineModel<boolean>('show', { required: true })
const size = defineModel<number | undefined>('size')
const tab = defineModel<Tab>('tab')
const slideInPanel = ref<HTMLElement>()
const computedSize = useResizeObserver(slideInPanel)
const computedBounds = computed(() => new Rect(Vec2.Zero, computedSize.value))
@ -26,6 +32,14 @@ function clampSize(size: number) {
const style = computed(() => ({
width: size.value != null ? `${clampSize(size.value)}px` : 'var(--right-dock-default-width)',
}))
const tabStyle = {
clipPath: tabClipPath(TAB_SIZE_PX, TAB_RADIUS_PX, 'right'),
width: `${TAB_SIZE_PX.width}px`,
height: `${TAB_SIZE_PX.height}px`,
margin: `${-TAB_RADIUS_PX}px ${TAB_EDGE_MARGIN_PX}px ${-TAB_RADIUS_PX}px 0`,
paddingLeft: `${TAB_EDGE_MARGIN_PX / 2}px`,
}
</script>
<template>
@ -38,9 +52,25 @@ const style = computed(() => ({
<SizeTransition width :duration="100">
<div v-if="show" ref="slideInPanel" :style="style" class="DockPanel" data-testid="rightDock">
<div class="content">
<div ref="toolbarElement" class="toolbar"></div>
<div class="scrollArea">
<slot :toolbar="toolbarElement" />
<slot v-if="tab == 'docs'" name="docs" />
<slot v-else-if="tab == 'help'" name="help" />
</div>
<div class="tabBar">
<div class="tab" :style="tabStyle">
<ToggleIcon
:modelValue="tab == 'docs'"
@update:modelValue="tab = 'docs'"
title="Documentation Editor"
icon="text"
/>
</div>
<div class="tab" :style="tabStyle">
<ToggleIcon
:modelValue="tab == 'help'"
@update:modelValue="tab = 'help'"
title="Component Help"
icon="help"
/>
</div>
</div>
<ResizeHandles left :modelValue="computedBounds" @update:modelValue="size = $event.width" />
@ -50,36 +80,45 @@ const style = computed(() => ({
<style scoped>
.DockPanel {
background-color: rgb(255, 255, 255);
position: relative;
--icon-margin: 16px;
--icon-size: 16px;
display: flex;
flex-direction: row;
justify-content: stretch;
}
.content {
width: 100%;
height: 100%;
background-color: #fff;
min-width: 0;
}
.tabBar {
flex: none;
width: calc(2 * var(--icon-margin) + var(--icon-size));
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding-top: calc(2 * var(--icon-margin) + var(--icon-size));
}
.scrollArea {
width: 100%;
overflow-y: auto;
padding-left: 10px;
/* Prevent touchpad back gesture, which can be triggered while panning. */
overscroll-behavior-x: none;
flex-grow: 1;
.tab {
display: flex;
align-items: center;
justify-content: center;
&:has(.toggledOn) {
background-color: #fff;
}
.toolbar {
height: 48px;
padding-left: 4px;
flex-shrink: 0;
}
.toggleDock {
--icon-margin: 16px; /* Must match `--icon-margin` defined above, which is not in scope because of the teleport. */
z-index: 1;
position: absolute;
right: 16px;
top: 16px;
right: var(--icon-margin);
top: var(--icon-margin);
}
</style>

View File

@ -6,12 +6,12 @@ import { useProjectStore } from '@/stores/project'
import type { ToValue } from '@/util/reactivity'
import type { Path } from 'shared/languageServerTypes'
import { Err, Ok, mapOk, withContext, type Result } from 'shared/util/data/result'
import { toRef, toValue } from 'vue'
import { ref, toRef, toValue } from 'vue'
const documentation = defineModel<string>({ required: true })
const _props = defineProps<{
toolbarContainer: HTMLElement | undefined
}>()
const _props = defineProps<{}>()
const toolbarElement = ref<HTMLElement>()
const graphStore = useGraphStore()
const projectStore = useProjectStore()
@ -69,9 +69,36 @@ function useDocumentationImages(
</script>
<template>
<div class="DocumentationEditor">
<div ref="toolbarElement" class="toolbar"></div>
<div class="scrollArea">
<MarkdownEditor
v-model="documentation"
:transformImageUrl="transformImageUrl"
:toolbarContainer="toolbarContainer"
:toolbarContainer="toolbarElement"
/>
</div>
</div>
</template>
<style scoped>
.DocumentationEditor {
display: flex;
flex-direction: column;
}
.scrollArea {
width: 100%;
overflow-y: auto;
padding-left: 10px;
/* Prevent touchpad back gesture, which can be triggered while panning. */
overscroll-behavior-x: none;
flex-grow: 1;
}
.toolbar {
height: 48px;
padding-left: 4px;
flex-shrink: 0;
}
</style>

View File

@ -189,9 +189,9 @@ function openDocs(url: string) {
line-height: 160%;
color: var(--enso-docs-text-color);
background-color: var(--enso-docs-background-color);
padding: 4px 12px 4px 4px;
padding: 4px 12px var(--doc-panel-bottom-clip, 0) 4px;
white-space: normal;
clip-path: inset(0 0 4px 0);
clip-path: inset(0 0 var(--doc-panel-bottom-clip, 0) 0);
height: 100%;
overflow-y: auto;
display: flex;

View File

@ -3,11 +3,10 @@ import SvgIcon from '@/components/SvgIcon.vue'
import type { Icon } from '@/util/iconName'
const props = defineProps<{ text: string; icon?: Icon | undefined }>()
const emit = defineEmits<{ click: [] }>()
</script>
<template>
<div class="Breadcrumb clickable" @click.stop="emit('click')">
<div class="Breadcrumb">
<SvgIcon v-if="props.icon" :name="props.icon || ''" />
<span v-text="props.text"></span>
</div>

View File

@ -44,7 +44,9 @@ function shrinkFactor(index: number): number {
:text="breadcrumb.label"
:icon="index === props.breadcrumbs.length - 1 ? props.icon : undefined"
:style="{ 'flex-shrink': shrinkFactor(index) }"
@click="emit('click', index)"
:class="{ nonInteractive: index === props.breadcrumbs.length - 1 }"
class="clickable"
@click.stop="emit('click', index)"
/>
</template>
</TransitionGroup>
@ -81,4 +83,8 @@ function shrinkFactor(index: number): number {
.breadcrumbs-enter-from {
opacity: 0;
}
.nonInteractive {
pointer-events: none;
}
</style>

View File

@ -11,6 +11,7 @@ import CodeEditor from '@/components/CodeEditor.vue'
import ComponentBrowser from '@/components/ComponentBrowser.vue'
import { type Usage } from '@/components/ComponentBrowser/input'
import { usePlacement } from '@/components/ComponentBrowser/placement'
import ComponentDocumentation from '@/components/ComponentDocumentation.vue'
import DockPanel from '@/components/DockPanel.vue'
import DocumentationEditor from '@/components/DocumentationEditor.vue'
import GraphEdges from '@/components/GraphEditor/GraphEdges.vue'
@ -24,6 +25,7 @@ import GraphMouse from '@/components/GraphMouse.vue'
import PlusButton from '@/components/PlusButton.vue'
import SceneScroller from '@/components/SceneScroller.vue'
import TopBar from '@/components/TopBar.vue'
import { builtinWidgets } from '@/components/widgets'
import { useAstDocumentation } from '@/composables/astDocumentation'
import { useDoubleClick } from '@/composables/doubleClick'
import { keyboardBusy, keyboardBusyExceptIn, unrefElement, useEvent } from '@/composables/events'
@ -37,6 +39,7 @@ import { provideGraphSelection } from '@/providers/graphSelection'
import { provideStackNavigator } from '@/providers/graphStackNavigator'
import { provideInteractionHandler } from '@/providers/interactionHandler'
import { provideKeyboard } from '@/providers/keyboard'
import { injectVisibility } from '@/providers/visibility'
import { provideWidgetRegistry } from '@/providers/widgetRegistry'
import { provideGraphStore, type NodeId } from '@/stores/graph'
import type { RequiredImport } from '@/stores/graph/imports'
@ -50,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 { unwrapOr } from '@/util/data/result'
import { Err, Ok, unwrapOr, type Result } from '@/util/data/result'
import { Vec2 } from '@/util/data/vec2'
import { computedFallback } from '@/util/reactivity'
import { until } from '@vueuse/core'
@ -70,9 +73,6 @@ import {
type ComponentInstance,
} from 'vue'
import { builtinWidgets } from '@/components/widgets'
import { injectVisibility } from '@/providers/visibility'
const keyboard = provideKeyboard()
const projectStore = useProjectStore()
const suggestionDb = provideSuggestionDbStore(projectStore)
@ -101,7 +101,8 @@ const graphNavigator: GraphNavigator = provideGraphNavigator(viewportNode, keybo
// === Client saved state ===
const storedShowDocumentationEditor = ref()
const storedShowRightDock = ref()
const storedRightDockTab = ref()
const rightDockWidth = ref<number>()
/**
@ -119,6 +120,8 @@ interface GraphStoredState {
s: number
/** Whether or not the documentation panel is open. */
doc: boolean
/** The selected tab in the right-side panel. */
rtab: string
/** Width of the right dock. */
rwidth: number | null
}
@ -145,7 +148,8 @@ useSyncLocalStorage<GraphStoredState>({
x: graphNavigator.targetCenter.x,
y: graphNavigator.targetCenter.y,
s: graphNavigator.targetScale,
doc: storedShowDocumentationEditor.value,
doc: storedShowRightDock.value,
rtab: storedRightDockTab.value,
rwidth: rightDockWidth.value ?? null,
}
},
@ -154,7 +158,8 @@ useSyncLocalStorage<GraphStoredState>({
const pos = new Vec2(restored.x ?? 0, restored.y ?? 0)
const scale = restored.s ?? 1
graphNavigator.setCenterAndScale(pos, scale)
storedShowDocumentationEditor.value = restored.doc ?? undefined
storedShowRightDock.value = restored.doc ?? undefined
storedRightDockTab.value = restored.rtab ?? undefined
rightDockWidth.value = restored.rwidth ?? undefined
} else {
await until(visibleAreasReady).toBe(true)
@ -352,27 +357,22 @@ const graphBindingsHandler = graphBindings.handler({
showColorPicker.value = true
},
openDocumentation() {
const failure = 'Unable to show node documentation'
const selected = getSoleSelectionOrToast(failure)
if (selected == null) return
const suggestion = graphStore.db.nodeMainSuggestion.lookup(selected)
const documentation = suggestion && suggestionDocumentationUrl(suggestion)
if (documentation) {
window.open(documentation, '_blank')
} else {
toasts.userActionFailed.show(`${failure}: no documentation available for selected node.`)
const result = tryGetSelectionDocUrl()
if (!result.ok) {
toasts.userActionFailed.show(result.error.message('Unable to show node documentation'))
return
}
window.open(result.value, '_blank')
},
})
function getSoleSelectionOrToast(context: string) {
if (nodeSelection.selected.size === 0) {
toasts.userActionFailed.show(`${context}: no node selected.`)
} else if (nodeSelection.selected.size > 1) {
toasts.userActionFailed.show(`${context}: multiple nodes selected.`)
} else {
return set.first(nodeSelection.selected)
}
function tryGetSelectionDocUrl() {
const selected = nodeSelection.tryGetSoleSelection()
if (!selected.ok) return selected
const suggestion = graphStore.db.getNodeMainSuggestion(selected.value)
const documentation = suggestion && suggestionDocumentationUrl(suggestion)
if (!documentation) return Err('No external documentation available for selected component')
return Ok(documentation)
}
const { handleClick } = useDoubleClick(
@ -405,15 +405,16 @@ const codeEditorHandler = codeEditorBindings.handler({
const docEditor = shallowRef<ComponentInstance<typeof DocumentationEditor>>()
const documentationEditorArea = computed(() => unrefElement(docEditor))
const showDocumentationEditor = computedFallback(
storedShowDocumentationEditor,
const showRightDock = computedFallback(
storedShowRightDock,
// Show documentation editor when documentation exists on first graph visit.
() => !!documentation.state.value,
)
const rightDockTab = computedFallback(storedRightDockTab, () => 'docs')
const documentationEditorHandler = documentationEditorBindings.handler({
toggle() {
showDocumentationEditor.value = !showDocumentationEditor.value
showRightDock.value = !showRightDock.value
},
})
@ -662,6 +663,7 @@ const groupColors = computed(() => {
<div
class="GraphEditor"
:class="{ draggingEdge: graphStore.mouseEditedEdge != null }"
:style="groupColors"
@dragover.prevent
@drop.prevent="handleFileDrop($event)"
>
@ -669,7 +671,6 @@ const groupColors = computed(() => {
<div
ref="viewportNode"
class="viewport"
:style="groupColors"
v-on.="graphNavigator.pointerEvents"
v-on..="nodeSelection.events"
@click="handleClick"
@ -696,10 +697,10 @@ const groupColors = computed(() => {
v-model:recordMode="projectStore.recordMode"
v-model:showColorPicker="showColorPicker"
v-model:showCodeEditor="showCodeEditor"
v-model:showDocumentationEditor="showDocumentationEditor"
v-model:showDocumentationEditor="showRightDock"
:zoomLevel="100.0 * graphNavigator.targetScale"
:componentsSelected="nodeSelection.selected.size"
:class="{ extraRightSpace: !showDocumentationEditor }"
:class="{ extraRightSpace: !showRightDock }"
@fitToAllClicked="zoomToSelected"
@zoomIn="graphNavigator.stepZoom(+1)"
@zoomOut="graphNavigator.stepZoom(-1)"
@ -719,15 +720,21 @@ const groupColors = computed(() => {
</Suspense>
</BottomPanel>
</div>
<DockPanel v-model:show="showDocumentationEditor" v-model:size="rightDockWidth">
<template #default="{ toolbar }">
<DockPanel
v-model:show="showRightDock"
v-model:size="rightDockWidth"
v-model:tab="rightDockTab"
>
<template #docs>
<DocumentationEditor
ref="docEditor"
:modelValue="documentation.state.value"
:toolbarContainer="toolbar"
@update:modelValue="documentation.set"
/>
</template>
<template #help>
<ComponentDocumentation />
</template>
</DockPanel>
</div>
</template>

View File

@ -291,7 +291,7 @@ const isRecordingOverridden = computed({
const expressionInfo = computed(() => graph.db.getExpressionInfo(props.node.innerExpr.externalId))
const executionState = computed(() => expressionInfo.value?.payload.type ?? 'Unknown')
const suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(nodeId.value))
const suggestionEntry = computed(() => graph.db.getNodeMainSuggestion(nodeId.value))
const color = computed(() => graph.db.getNodeColorStyle(nodeId.value))
const documentationUrl = computed(
() => suggestionEntry.value && suggestionDocumentationUrl(suggestionEntry.value),

View File

@ -101,7 +101,7 @@ const widgetTree = provideWidgetTree(
)
const expressionInfo = computed(() => graph.db.getExpressionInfo(props.ast.externalId))
const suggestionEntry = computed(() => graph.db.nodeMainSuggestion.lookup(props.nodeId))
const suggestionEntry = computed(() => graph.db.getNodeMainSuggestion(props.nodeId))
const topLevelIcon = computed(() => {
switch (props.nodeType) {
default:

View File

@ -9,6 +9,7 @@ import { intersectionSize } from '@/util/data/set'
import type { Vec2 } from '@/util/data/vec2'
import { dataAttribute, elementHierarchy } from '@/util/dom'
import * as set from 'lib0/set'
import { Err, Ok, type Result } from 'shared/util/data/result'
import { computed, ref, shallowReactive, shallowRef } from 'vue'
interface BaseSelectionOptions<T> {
@ -119,6 +120,17 @@ function useSelectionImpl<T, PackedT>(
setSelection(newSelection)
}
/** Returns the single selected component, or an error. */
function tryGetSoleSelection(): Result<T, string> {
if (selected.value.size === 0) {
return Err('No component selected')
} else if (selected.value.size > 1) {
return Err('Multiple components selected')
} else {
return Ok(set.first(selected.value)!)
}
}
const selectionEventHandler = selectionMouseBindings.handler({
replace() {
setSelection(elementsToSelect.value)
@ -215,6 +227,7 @@ function useSelectionImpl<T, PackedT>(
},
committedSelection,
setSelection,
tryGetSoleSelection,
// === Selection changes ===
anchor,
isChanging,

View File

@ -207,20 +207,24 @@ export class GraphDb {
return Array.from(ports, (port) => [id, port])
})
nodeMainSuggestion = new ReactiveMapping(this.nodeIdToNode, (_id, entry) => {
nodeMainSuggestionId = new ReactiveMapping(this.nodeIdToNode, (_id, entry) => {
const expressionInfo = this.getExpressionInfo(entry.innerExpr.id)
const method = expressionInfo?.methodCall?.methodPointer
if (method == null) return
const suggestionId = this.suggestionDb.findByMethodPointer(method)
return this.suggestionDb.findByMethodPointer(method)
})
getNodeMainSuggestion(id: NodeId) {
const suggestionId = this.nodeMainSuggestionId.lookup(id)
if (suggestionId == null) return
return this.suggestionDb.get(suggestionId)
})
}
nodeColor = new ReactiveMapping(this.nodeIdToNode, (id, entry) => {
if (entry.colorOverride != null) return entry.colorOverride
return computeNodeColor(
() => entry.type,
() => tryGetIndex(this.groups.value, this.nodeMainSuggestion.lookup(id)?.groupIndex),
() => tryGetIndex(this.groups.value, this.getNodeMainSuggestion(id)?.groupIndex),
() => this.getExpressionInfo(id)?.typename,
)
})
@ -291,11 +295,7 @@ export class GraphDb {
}
getMethodCallInfo(id: AstId): MethodCallInfo | undefined {
const info = this.getExpressionInfo(id)
if (info == null) return
const payloadFuncSchema =
info.payload.type === 'Value' ? info.payload.functionSchema : undefined
const methodCall = info.methodCall ?? payloadFuncSchema
const methodCall = this.getMethodCall(id)
if (methodCall == null) return
const suggestionId = this.suggestionDb.findByMethodPointer(methodCall.methodPointer)
if (suggestionId == null) return

View File

@ -17,12 +17,16 @@
"./src/utilities/data/dateTime": "./src/utilities/data/dateTime.ts",
"./src/utilities/data/newtype": "./src/utilities/data/newtype.ts",
"./src/utilities/data/object": "./src/utilities/data/object.ts",
"./src/utilities/style/tabBar": "./src/utilities/style/tabBar.ts",
"./src/utilities/uniqueString": "./src/utilities/uniqueString.ts",
"./src/text": "./src/text/index.ts",
"./src/utilities/permissions": "./src/utilities/permissions.ts",
"./src/services/Backend": "./src/services/Backend.ts",
"./src/types": "./src/types.d.ts"
},
"scripts": {
"test": "vitest run"
},
"peerDependencies": {
"@tanstack/query-core": "5.45.0",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0"
@ -32,6 +36,7 @@
"react": "^18.3.1",
"@tanstack/query-persist-client-core": "^5.45.0",
"@tanstack/vue-query": ">= 5.45.0 < 5.46.0",
"vue": "^3.4.19"
"vue": "^3.4.19",
"vitest": "^1.3.1"
}
}

View File

@ -0,0 +1,50 @@
import * as v from 'vitest'
import * as tabBar from '../tabBar'
interface TabClipPathInput {
bounds: { width: number; height: number }
radius: number
side?: 'top' | 'right'
}
const dashboardTabCases = [
{
input: {
bounds: { width: 164.2109375, height: 48 },
radius: 24,
},
expected:
'path("M 0 48 A 24 24 0 0 0 24 24 L 24 24 A 24 24 0 0 1 48 0 L 116.2109375 0 A 24 24 0 0 1 140.2109375 24 L 140.2109375 24 A 24 24 0 0 0 164.2109375 48 M 0 0")',
},
{
input: {
bounds: { width: 209.6171875, height: 48 },
radius: 24,
},
expected:
'path("M 0 48 A 24 24 0 0 0 24 24 L 24 24 A 24 24 0 0 1 48 0 L 161.6171875 0 A 24 24 0 0 1 185.6171875 24 L 185.6171875 24 A 24 24 0 0 0 209.6171875 48 M 0 0")',
},
]
const guiTabCases = [
{
input: {
bounds: { width: 44, height: 48 },
radius: 8,
side: 'right',
},
expected:
'path("M 0 0 A 8 8 0 0 0 8 8 L 36 8 A 8 8 0 0 1 44 16 L 44 32 A 8 8 0 0 1 36 40 L 8 40 A 8 8 0 0 0 0 48 M 44 0")',
},
]
v.test.each([
{ group: 'Dashboard', cases: dashboardTabCases },
{ group: 'GUI', cases: guiTabCases },
])('Tab clip path: $group', ({ group, cases }) => {
cases.forEach(({ input, expected }) => {
const result = tabBar.tabClipPath(input.bounds, input.radius, (input as TabClipPathInput)?.side)
v.expect(result).toBe(expected)
})
})

View File

@ -0,0 +1,88 @@
/** Functions that generate CSS to render the shapes in a tab bar. */
// ===========
// === Tab ===
// ===========
/** Returns a CSS clip-path for a tab of the specified dimensions. */
export function tabClipPath(
bounds: Readonly<{ width: number; height: number }>,
radiusPx: number,
side: 'top' | 'right' = 'top',
) {
console.log('bounds', JSON.stringify(bounds))
console.log('radiusPx', JSON.stringify(radiusPx))
console.log('side', JSON.stringify(side))
const sweep0 = 0
const sweep1 = 1
const xIndex = side === 'top' ? 0 : 1
const x1 = [bounds.width, bounds.height][xIndex]!
const y1 = [bounds.width, bounds.height][1 - xIndex]!
const orient =
side === 'top' ? (x: number, y: number) => [x, y] : (x: number, y: number) => [y1 - y, x]
const pt = (x: number, y: number) => orient(x, y).join(' ')
const result = path([
`M ${pt(0, y1)}`,
`A ${radiusPx} ${radiusPx} 0 0 ${sweep0} ${pt(radiusPx, y1 - radiusPx)}`,
`L ${pt(radiusPx, radiusPx)}`,
`A ${radiusPx} ${radiusPx} 0 0 ${sweep1} ${pt(radiusPx * 2, 0)}`,
`L ${pt(x1 - radiusPx * 2, 0)}`,
`A ${radiusPx} ${radiusPx} 0 0 ${sweep1} ${pt(x1 - radiusPx, radiusPx)}`,
`L ${pt(x1 - radiusPx, y1 - radiusPx)}`,
`A ${radiusPx} ${radiusPx} 0 0 ${sweep0} ${pt(x1, y1)}`,
`M ${pt(0, 0)}`,
])
console.log('result', JSON.stringify(result))
return result
}
// ===============
// === Tab Bar ===
// ===============
/** Returns a CSS clip-path for a tab bar of the specified dimensions and parent dimensions. */
export function barClipPath(
bounds: DOMRectReadOnly,
rootBounds: DOMRectReadOnly,
radiusPx: number,
) {
const tabLeft = bounds.left - rootBounds.left + radiusPx
const tabRight = bounds.right - rootBounds.left - radiusPx
const rightSegments = [
'M 0 0',
`L ${rootBounds.width + window.outerWidth} 0`,
`L ${rootBounds.width + window.outerWidth} ${rootBounds.height}`,
`L ${tabRight + radiusPx} ${rootBounds.height}`,
`A ${radiusPx} ${radiusPx} 0 0 1 ${tabRight} ${rootBounds.height - radiusPx}`,
]
const leftSegments = [
`A ${radiusPx} ${radiusPx} 0 0 1 ${tabLeft - radiusPx} ${rootBounds.height}`,
`L 0 ${rootBounds.height}`,
'Z',
]
const clipPath = path([
...rightSegments,
`L ${tabRight} ${radiusPx}`,
`A ${radiusPx} ${radiusPx} 0 0 0 ${tabRight - radiusPx} 0`,
`L ${tabLeft + radiusPx} 0`,
`A ${radiusPx} ${radiusPx} 0 0 0 ${tabLeft} ${radiusPx}`,
`L ${tabLeft} ${rootBounds.height - radiusPx}`,
...leftSegments,
])
const rootClipPath = path([
...rightSegments,
`A ${radiusPx} ${radiusPx} 0 0 1 ${tabRight - radiusPx} ${rootBounds.height}`,
`L ${tabLeft + radiusPx} ${rootBounds.height}`,
`A ${radiusPx} ${radiusPx} 0 0 1 ${tabLeft} ${rootBounds.height - radiusPx}`,
...leftSegments,
])
return { clipPath, rootClipPath }
}
// ===============
// === Helpers ===
// ===============
function path(segments: string[]) {
return `path("${segments.join(' ')}")`
}

View File

@ -700,6 +700,9 @@ importers:
react:
specifier: ^18.3.1
version: 18.3.1
vitest:
specifier: ^1.3.1
version: 1.6.0(@types/node@20.11.21)(jsdom@24.1.0)(lightningcss@1.25.1)
vue:
specifier: ^3.4.19
version: 3.4.31(typescript@5.5.3)