mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 15:52:05 +03:00
Navigating documentation panel with breadcrumbs (#8176)
Closes #8131 https://github.com/enso-org/enso/assets/6566674/69609307-d5f5-4185-af65-aed1f6b85978
This commit is contained in:
parent
a862ea7948
commit
4de51b25ff
@ -1,15 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { Item as Breadcrumb } from '@/components/DocumentationPanel/DocsBreadcrumbs.vue'
|
||||
import Breadcrumbs from '@/components/DocumentationPanel/DocsBreadcrumbs.vue'
|
||||
import DocsExamples from '@/components/DocumentationPanel/DocsExamples.vue'
|
||||
import DocsHeader from '@/components/DocumentationPanel/DocsHeader.vue'
|
||||
import DocsList from '@/components/DocumentationPanel/DocsList.vue'
|
||||
import DocsSynopsis from '@/components/DocumentationPanel/DocsSynopsis.vue'
|
||||
import DocsTags from '@/components/DocumentationPanel/DocsTags.vue'
|
||||
import { HistoryStack } from '@/components/DocumentationPanel/history'
|
||||
import type { Docs, FunctionDocs, Sections, TypeDocs } from '@/components/DocumentationPanel/ir'
|
||||
import { lookupDocumentation, placeholder } from '@/components/DocumentationPanel/ir'
|
||||
import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
|
||||
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
|
||||
import type { Icon as IconName } from '@/util/iconName'
|
||||
import { type Opt } from '@/util/opt'
|
||||
import { computed } from 'vue'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
import { qnSegments, qnSlice } from '@/util/qualifiedName'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ selectedEntry: Opt<SuggestionId> }>()
|
||||
const emit = defineEmits<{ 'update:selectedEntry': [id: SuggestionId] }>()
|
||||
@ -40,29 +46,113 @@ const types = computed<TypeDocs[]>(() => {
|
||||
const docs = documentation.value
|
||||
return docs.kind === 'Module' ? docs.types : []
|
||||
})
|
||||
|
||||
const isPlaceholder = computed(() => 'Placeholder' in documentation.value)
|
||||
|
||||
const name = computed<Opt<QualifiedName>>(() => {
|
||||
const docs = documentation.value
|
||||
return docs.kind === 'Placeholder' ? null : docs.name
|
||||
})
|
||||
|
||||
// === Breadcrumbs ===
|
||||
|
||||
const color = computed<string>(() => {
|
||||
const id = props.selectedEntry
|
||||
if (id) {
|
||||
const entry = db.entries.get(id)
|
||||
const groupIndex = entry?.groupIndex ?? -1
|
||||
const group = db.groups[groupIndex]
|
||||
if (group) {
|
||||
const name = group.name.replace(/\s/g, '-')
|
||||
return `var(--group-color-${name})`
|
||||
}
|
||||
}
|
||||
return 'var(--group-color-fallback)'
|
||||
})
|
||||
|
||||
const icon = computed<IconName>(() => {
|
||||
const id = props.selectedEntry
|
||||
if (id) {
|
||||
const entry = db.entries.get(id)
|
||||
return entry?.iconName ?? 'marketplace'
|
||||
} else {
|
||||
return 'marketplace'
|
||||
}
|
||||
})
|
||||
|
||||
const historyStack = new HistoryStack()
|
||||
|
||||
// Reset breadcrumbs history when the user selects the entry from the component list.
|
||||
watch(
|
||||
() => props.selectedEntry,
|
||||
(entry) => {
|
||||
if (entry && historyStack.current.value !== entry) {
|
||||
historyStack.reset(entry)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Update displayed documentation page when the user uses breadcrumbs.
|
||||
watch(historyStack.current, (current) => {
|
||||
if (current) {
|
||||
emit('update:selectedEntry', current)
|
||||
}
|
||||
})
|
||||
|
||||
const breadcrumbs = computed<Breadcrumb[]>(() => {
|
||||
if (name.value) {
|
||||
const segments = qnSegments(name.value)
|
||||
return segments.slice(1).map((s) => ({ label: s.toLowerCase() }))
|
||||
} else {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
function handleBreadcrumbClick(index: number) {
|
||||
if (name.value) {
|
||||
const qName = qnSlice(name.value, 0, index + 2)
|
||||
if (qName.ok) {
|
||||
const [id] = db.entries.nameToId.lookup(qName.value)
|
||||
if (id) {
|
||||
historyStack.record(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="DocumentationPanel scrollable" @wheel.stop.passive>
|
||||
<h1 v-if="documentation.kind === 'Placeholder'">{{ documentation.text }}</h1>
|
||||
<DocsTags v-if="sections.tags.length > 0" :tags="sections.tags" />
|
||||
<Breadcrumbs
|
||||
v-if="!isPlaceholder"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
:color="color"
|
||||
:icon="icon"
|
||||
:canGoForward="historyStack.canGoForward()"
|
||||
:canGoBackward="historyStack.canGoBackward()"
|
||||
@click="(index) => handleBreadcrumbClick(index)"
|
||||
@forward="historyStack.forward()"
|
||||
@backward="historyStack.backward()"
|
||||
/>
|
||||
<DocsTags v-if="sections.tags.length > 0" class="tags" :tags="sections.tags" />
|
||||
<div class="sections">
|
||||
<span v-if="sections.synopsis.length == 0">{{ 'No documentation available.' }}</span>
|
||||
<DocsSynopsis :sections="sections.synopsis" />
|
||||
<DocsHeader v-if="types.length > 0" kind="types" label="Types" />
|
||||
<DocsList
|
||||
:items="{ kind: 'Types', items: types }"
|
||||
@linkClicked="emit('update:selectedEntry', $event)"
|
||||
@linkClicked="historyStack.record($event)"
|
||||
/>
|
||||
<DocsHeader v-if="constructors.length > 0" kind="methods" label="Constructors" />
|
||||
<DocsList
|
||||
:items="{ kind: 'Constructors', items: constructors }"
|
||||
@linkClicked="emit('update:selectedEntry', $event)"
|
||||
@linkClicked="historyStack.record($event)"
|
||||
/>
|
||||
<DocsHeader v-if="methods.length > 0" kind="methods" label="Methods" />
|
||||
<DocsList
|
||||
:items="{ kind: 'Methods', items: methods }"
|
||||
@linkClicked="emit('update:selectedEntry', $event)"
|
||||
@linkClicked="historyStack.record($event)"
|
||||
/>
|
||||
<DocsHeader v-if="sections.examples.length > 0" kind="examples" label="Examples" />
|
||||
<DocsExamples :examples="sections.examples" />
|
||||
@ -89,11 +179,18 @@ const types = computed<TypeDocs[]>(() => {
|
||||
line-height: 160%;
|
||||
color: var(--enso-docs-text-color);
|
||||
background-color: var(--enso-docs-background-color);
|
||||
padding: 8px 12px 4px 8px;
|
||||
padding: 4px 12px 4px 4px;
|
||||
white-space: normal;
|
||||
clip-path: inset(0 0 4px 0);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tags {
|
||||
margin: 4px 0 0 8px;
|
||||
}
|
||||
|
||||
.sections {
|
||||
|
@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
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">
|
||||
<SvgIcon v-if="props.icon" :name="props.icon || ''" />
|
||||
<span @pointerdown="emit('click')" v-text="props.text"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Breadcrumb {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
span {
|
||||
display: inline-block;
|
||||
height: 24px;
|
||||
padding: 1px 0px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
}
|
||||
</style>
|
@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
|
||||
import Breadcrumb from '@/components/DocumentationPanel/DocsBreadcrumb.vue'
|
||||
|
||||
export interface Item {
|
||||
label: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbs: Item[]
|
||||
color: string
|
||||
icon: Icon
|
||||
canGoForward: boolean
|
||||
canGoBackward: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{ click: [index: number]; backward: []; forward: [] }>()
|
||||
|
||||
/**
|
||||
* Shrink first and middle elements in the breacrumbs, keeping the original size of others.
|
||||
*/
|
||||
function shrinkFactor(index: number): number {
|
||||
const middle = Math.floor(props.breadcrumbs.length / 2)
|
||||
return index === middle || index === 0 ? 100 : 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Breadcrumbs" :style="{ 'background-color': color }">
|
||||
<div class="breadcrumbs-controls">
|
||||
<SvgIcon
|
||||
name="arrow_left"
|
||||
draggable="false"
|
||||
:class="['icon', 'button', 'arrow', { inactive: !props.canGoBackward }]"
|
||||
@pointerdown="emit('backward')"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="arrow_right"
|
||||
draggable="false"
|
||||
:class="['icon', 'button', 'arrow', { inactive: !props.canGoForward }]"
|
||||
@pointerdown="emit('forward')"
|
||||
/>
|
||||
</div>
|
||||
<TransitionGroup name="breadcrumbs">
|
||||
<template v-for="(breadcrumb, index) in props.breadcrumbs" :key="[index, breadcrumb.label]">
|
||||
<SvgIcon v-if="index > 0" name="arrow_right_head_only" class="arrow" />
|
||||
<Breadcrumb
|
||||
:text="breadcrumb.label"
|
||||
:icon="index === props.breadcrumbs.length - 1 ? props.icon : undefined"
|
||||
:style="{ 'flex-shrink': shrinkFactor(index) }"
|
||||
@click="emit('click', index)"
|
||||
/>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.Breadcrumbs {
|
||||
display: flex;
|
||||
height: 32px;
|
||||
padding: 8px 10px 8px 8px;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 16px;
|
||||
transition: background-color 0.5s;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.breadcrumbs-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inactive {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: white;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
.breadcrumbs-move,
|
||||
.breadcrumbs-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.breadcrumbs-leave-active {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.breadcrumbs-enter-from {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { FunctionDocs, TypeDocs } from '@/components/DocumentationPanel/ir'
|
||||
import type { Doc } from '@/util/docParser'
|
||||
import { qnSplit } from '@/util/qualifiedName'
|
||||
import type { SuggestionEntryArgument, SuggestionId } from 'shared/languageServerTypes/suggestions'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@ -51,7 +52,7 @@ const annotations = computed<Array<string | undefined>>(() => {
|
||||
:class="['link', props.items.kind]"
|
||||
@pointerdown.stop.prevent="emit('linkClicked', item.id)"
|
||||
>
|
||||
<span class="entryName">{{ item.name }}</span>
|
||||
<span class="entryName">{{ qnSplit(item.name)[1] }}</span>
|
||||
<span class="arguments">{{ ' ' + argumentsList(item.arguments) }}</span>
|
||||
</a>
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
|
97
app/gui2/src/components/DocumentationPanel/history.ts
Normal file
97
app/gui2/src/components/DocumentationPanel/history.ts
Normal file
@ -0,0 +1,97 @@
|
||||
import type { SuggestionId } from '@/stores/suggestionDatabase/entry'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Simple stack for going forward and backward through the history of visited documentation pages
|
||||
*/
|
||||
export class HistoryStack {
|
||||
private stack: SuggestionId[]
|
||||
private index: Ref<number>
|
||||
public current: ComputedRef<SuggestionId | undefined>
|
||||
|
||||
constructor() {
|
||||
this.stack = reactive([])
|
||||
this.index = ref(0)
|
||||
this.current = computed(() => this.stack[this.index.value] ?? undefined)
|
||||
}
|
||||
|
||||
public reset(current: SuggestionId) {
|
||||
this.stack.length = 0
|
||||
this.stack.push(current)
|
||||
this.index.value = 0
|
||||
}
|
||||
|
||||
public record(id: SuggestionId) {
|
||||
this.stack.splice(this.index.value + 1)
|
||||
this.stack.push(id)
|
||||
this.index.value = this.stack.length - 1
|
||||
}
|
||||
|
||||
public forward() {
|
||||
if (this.canGoForward()) {
|
||||
this.index.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
public backward() {
|
||||
if (this.canGoBackward()) {
|
||||
this.index.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
public canGoBackward(): boolean {
|
||||
return this.index.value > 0
|
||||
}
|
||||
|
||||
public canGoForward(): boolean {
|
||||
return this.index.value < this.stack.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.vitest) {
|
||||
const { test, expect } = import.meta.vitest
|
||||
const ID_1 = 10
|
||||
const ID_2 = 20
|
||||
const ID_3 = 30
|
||||
|
||||
test('HistoryStack basic operations', () => {
|
||||
const stack = new HistoryStack()
|
||||
expect(stack.current.value).toBeUndefined()
|
||||
expect(stack.canGoBackward()).toBeFalsy()
|
||||
expect(stack.canGoForward()).toBeFalsy()
|
||||
|
||||
stack.reset(ID_1)
|
||||
expect(stack.current.value).toStrictEqual(ID_1)
|
||||
stack.forward()
|
||||
expect(stack.current.value).toStrictEqual(ID_1)
|
||||
expect(stack.canGoBackward()).toBeFalsy()
|
||||
expect(stack.canGoForward()).toBeFalsy()
|
||||
|
||||
stack.record(ID_2)
|
||||
expect(stack.current.value).toStrictEqual(ID_2)
|
||||
expect(stack.canGoBackward()).toBeTruthy()
|
||||
expect(stack.canGoForward()).toBeFalsy()
|
||||
|
||||
stack.backward()
|
||||
expect(stack.current.value).toStrictEqual(ID_1)
|
||||
expect(stack.canGoBackward()).toBeFalsy()
|
||||
expect(stack.canGoForward()).toBeTruthy()
|
||||
stack.backward()
|
||||
expect(stack.current.value).toStrictEqual(ID_1)
|
||||
stack.forward()
|
||||
expect(stack.current.value).toStrictEqual(ID_2)
|
||||
expect(stack.canGoForward()).toBeFalsy()
|
||||
stack.forward()
|
||||
expect(stack.current.value).toStrictEqual(ID_2)
|
||||
|
||||
stack.backward()
|
||||
expect(stack.current.value).toStrictEqual(ID_1)
|
||||
stack.record(ID_3)
|
||||
expect(stack.current.value).toStrictEqual(ID_3)
|
||||
stack.forward()
|
||||
expect(stack.current.value).toStrictEqual(ID_3)
|
||||
stack.backward()
|
||||
expect(stack.current.value).toStrictEqual(ID_1)
|
||||
})
|
||||
}
|
@ -1,7 +1,8 @@
|
||||
import type { SuggestionDb } from '@/stores/suggestionDatabase'
|
||||
import type { SuggestionEntry, SuggestionId } from '@/stores/suggestionDatabase/entry'
|
||||
import { SuggestionKind } from '@/stores/suggestionDatabase/entry'
|
||||
import { SuggestionKind, entryQn } from '@/stores/suggestionDatabase/entry'
|
||||
import type { Doc } from '@/util/docParser'
|
||||
import type { QualifiedName } from '@/util/qualifiedName'
|
||||
import type { SuggestionEntryArgument } from 'shared/languageServerTypes/suggestions'
|
||||
|
||||
// === Types ===
|
||||
@ -20,7 +21,7 @@ export interface Placeholder {
|
||||
export interface FunctionDocs {
|
||||
kind: 'Function'
|
||||
id: SuggestionId
|
||||
name: string
|
||||
name: QualifiedName
|
||||
arguments: SuggestionEntryArgument[]
|
||||
sections: Sections
|
||||
}
|
||||
@ -28,7 +29,7 @@ export interface FunctionDocs {
|
||||
export interface TypeDocs {
|
||||
kind: 'Type'
|
||||
id: SuggestionId
|
||||
name: string
|
||||
name: QualifiedName
|
||||
arguments: SuggestionEntryArgument[]
|
||||
sections: Sections
|
||||
methods: FunctionDocs[]
|
||||
@ -38,7 +39,7 @@ export interface TypeDocs {
|
||||
export interface ModuleDocs {
|
||||
kind: 'Module'
|
||||
id: SuggestionId
|
||||
name: string
|
||||
name: QualifiedName
|
||||
sections: Sections
|
||||
types: TypeDocs[]
|
||||
methods: FunctionDocs[]
|
||||
@ -47,7 +48,7 @@ export interface ModuleDocs {
|
||||
export interface LocalDocs {
|
||||
kind: 'Local'
|
||||
id: SuggestionId
|
||||
name: string
|
||||
name: QualifiedName
|
||||
sections: Sections
|
||||
}
|
||||
|
||||
@ -144,7 +145,7 @@ type DocsHandle = (db: SuggestionDb, entry: SuggestionEntry, id: SuggestionId) =
|
||||
const handleFunction: DocsHandle = (_db, entry, id) => ({
|
||||
kind: 'Function',
|
||||
id,
|
||||
name: entry.name,
|
||||
name: entryQn(entry),
|
||||
arguments: entry.arguments,
|
||||
sections: filterSections(entry.documentation),
|
||||
})
|
||||
@ -156,13 +157,13 @@ const handleDocumentation: Record<SuggestionKind, DocsHandle> = {
|
||||
[SuggestionKind.Local]: (_db, entry, id) => ({
|
||||
kind: 'Local',
|
||||
id,
|
||||
name: entry.name,
|
||||
name: entryQn(entry),
|
||||
sections: filterSections(entry.documentation),
|
||||
}),
|
||||
[SuggestionKind.Type]: (db, entry, id) => ({
|
||||
kind: 'Type',
|
||||
id,
|
||||
name: entry.name,
|
||||
name: entryQn(entry),
|
||||
arguments: entry.arguments,
|
||||
sections: filterSections(entry.documentation),
|
||||
methods: asFunctionDocs(getChildren(db, id, SuggestionKind.Method)),
|
||||
@ -171,7 +172,7 @@ const handleDocumentation: Record<SuggestionKind, DocsHandle> = {
|
||||
[SuggestionKind.Module]: (db, entry, id) => ({
|
||||
kind: 'Module',
|
||||
id,
|
||||
name: entry.name,
|
||||
name: entryQn(entry),
|
||||
sections: filterSections(entry.documentation),
|
||||
types: asTypeDocs(getChildren(db, id, SuggestionKind.Type)),
|
||||
methods: asFunctionDocs(getChildren(db, id, SuggestionKind.Method)),
|
||||
|
@ -63,6 +63,18 @@ export function qnJoin(left: QualifiedName, right: QualifiedName): QualifiedName
|
||||
return `${left}.${right}` as QualifiedName
|
||||
}
|
||||
|
||||
export function qnSegments(name: QualifiedName): Identifier[] {
|
||||
return name.split('.').map((segment) => segment as Identifier)
|
||||
}
|
||||
|
||||
export function qnSlice(
|
||||
name: QualifiedName,
|
||||
start?: number | undefined,
|
||||
end?: number | undefined,
|
||||
): Result<QualifiedName> {
|
||||
return tryQualifiedName(qnSegments(name).slice(start, end).join('.'))
|
||||
}
|
||||
|
||||
/** Checks if given full qualified name is considered a top element of some project.
|
||||
*
|
||||
* The fully qualified names consists of namespace, project name, and then a path (possibly empty).
|
||||
|
Loading…
Reference in New Issue
Block a user