mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 08:52:58 +03:00
Help tab (#10771)
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:
parent
b8c036c476
commit
3a4b942aae
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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/)
|
||||
})
|
@ -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;
|
||||
|
50
app/gui2/src/components/ComponentDocumentation.vue
Normal file
50
app/gui2/src/components/ComponentDocumentation.vue
Normal 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>
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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),
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
})
|
||||
})
|
88
app/ide-desktop/common/src/utilities/style/tabBar.ts
Normal file
88
app/ide-desktop/common/src/utilities/style/tabBar.ts
Normal 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(' ')}")`
|
||||
}
|
@ -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)
|
||||
|
Loading…
Reference in New Issue
Block a user