Documentation Panel (#8118)

Implementing most parts of the Documentation Panel in the new GUI.

Known issues:
- Links do not work (yet, covered by #7992)
- Some pages are entirely empty – I’m investigating. Missing doc sections likely cause this, so we probably want to add some placeholder.
- Tags do not look as in design. I tried implementing them correctly but didn't have enough time to finish everything. Some initial implementation is in place, though. I will create a separate task for them.


https://github.com/enso-org/enso/assets/6566674/a656ae78-5d4c-45f4-a0a5-e07fa573253e
This commit is contained in:
Ilya Bogdanov 2023-10-30 04:27:40 +04:00 committed by GitHub
parent f2651d58e4
commit 523a32471e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 874 additions and 117 deletions

View File

@ -69,3 +69,13 @@ npm run test:e2e -- --debug
```sh ```sh
npm run lint npm run lint
``` ```
## Icons license
We use two Twemoji SVG icons for our documentation panel, you can find them at:
- `src/assets/icon-important.svg`
- `src/assets/icon-info.svg`
Twemoji SVG icons are licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/.
Copyright 2020 Twitter, Inc and other contributors.

View File

@ -48,7 +48,6 @@
"install": "^0.13.0", "install": "^0.13.0",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",
"lib0": "^0.2.85", "lib0": "^0.2.85",
"lorem-ipsum": "^2.0.8",
"magic-string": "^0.30.3", "magic-string": "^0.30.3",
"murmurhash": "^2.0.1", "murmurhash": "^2.0.1",
"pinia": "^2.1.6", "pinia": "^2.1.6",

View File

@ -1,5 +1,5 @@
/* M PLUS 1 font import. */ /* M PLUS 1 font import. */
@import url('https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600;800&display=swap');
/* color palette from <https://github.com/vuejs/theme> */ /* color palette from <https://github.com/vuejs/theme> */
:root { :root {

View File

@ -0,0 +1,11 @@
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path d="M21.1981 14.1473L27.3799 22.6473C29.3027 25.2912 27.4141 29 24.1449 29H7.85509C4.58595 29 2.69733 25.2912 4.62015 22.6473L10.802 14.1473C11.1273 13.7 11.5367 13.3316 12 13.0575V6H11.5C10.6716 6 10 5.32843 10 4.5C10 3.67157 10.6716 3 11.5 3H20.5C21.3284 3 22 3.67157 22 4.5C22 5.32843 21.3284 6 20.5 6H20V13.0575C20.4633 13.3316 20.8727 13.7 21.1981 14.1473Z" fill="#6DA85E" fill-opacity="0.7"/>
<g clip-path="url(#clip0_7717_245736)">
<path d="M21.1981 14.1473L27.3799 22.6473C29.3027 25.2912 27.4141 29 24.1449 29H7.85509C4.58595 29 2.69733 25.2912 4.62015 22.6473L10.802 14.1473C11.1273 13.7 11.5367 13.3316 12 13.0575V6H11.5C10.6716 6 10 5.32843 10 4.5C10 3.67157 10.6716 3 11.5 3H20.5C21.3284 3 22 3.67157 22 4.5C22 5.32843 21.3284 6 20.5 6H20V13.0575C20.4633 13.3316 20.8727 13.7 21.1981 14.1473Z" fill="#6DA85E"/>
</g>
<defs>
<clipPath id="clip0_7717_245736">
<rect width="32" height="12" fill="white" transform="translate(0 20)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,4 @@
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<circle fill="#BE1931" cx="18" cy="32" r="3"/>
<path fill="#BE1931" d="M21 24c0 1.657-1.344 3-3 3-1.657 0-3-1.343-3-3V5c0-1.657 1.343-3 3-3 1.656 0 3 1.343 3 3v19z"/>
</svg>

After

Width:  |  Height:  |  Size: 239 B

View File

@ -0,0 +1,4 @@
<svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
<path fill="#3B88C3" d="M0 4c0-2.209 1.791-4 4-4h28c2.209 0 4 1.791 4 4v28c0 2.209-1.791 4-4 4H4c-2.209 0-4-1.791-4-4V4z"/>
<path fill="#FFF" d="M20.512 8.071c0 1.395-1.115 2.573-2.511 2.573-1.333 0-2.511-1.209-2.511-2.573 0-1.271 1.178-2.45 2.511-2.45 1.333.001 2.511 1.148 2.511 2.45zm-4.744 6.728c0-1.488.931-2.481 2.232-2.481 1.302 0 2.232.992 2.232 2.481v11.906c0 1.488-.93 2.48-2.232 2.48s-2.232-.992-2.232-2.48V14.799z"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

View File

@ -0,0 +1,18 @@
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1_7717_245722" fill="white">
<rect x="2" y="2" width="13" height="13" rx="2"/>
</mask>
<rect x="2" y="2" width="13" height="13" rx="2" stroke="#1F71D3" stroke-width="13" mask="url(#path-1-inside-1_7717_245722)"/>
<mask id="path-2-inside-2_7717_245722" fill="white">
<rect x="17" y="2" width="13" height="13" rx="2"/>
</mask>
<rect x="17" y="2" width="13" height="13" rx="2" stroke="#1F71D3" stroke-opacity="0.4" stroke-width="13" mask="url(#path-2-inside-2_7717_245722)"/>
<mask id="path-3-inside-3_7717_245722" fill="white">
<rect x="2" y="17" width="13" height="13" rx="2"/>
</mask>
<rect x="2" y="17" width="13" height="13" rx="2" stroke="#1F71D3" stroke-opacity="0.7" stroke-width="13" mask="url(#path-3-inside-3_7717_245722)"/>
<mask id="path-4-inside-4_7717_245722" fill="white">
<rect x="17" y="17" width="13" height="13" rx="2"/>
</mask>
<rect x="17" y="17" width="13" height="13" rx="2" stroke="#1F71D3" stroke-opacity="0.6" stroke-width="13" mask="url(#path-4-inside-4_7717_245722)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1272,4 +1272,4 @@
</svg> </svg>
</g> </g>
<!-- Please run `npm run generate` whenever this file is edited. --> <!-- Please run `npm run generate` whenever this file is edited. -->
</svg> </svg>

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

View File

@ -3,6 +3,7 @@ import { componentBrowserBindings } from '@/bindings'
import { makeComponentList, type Component } from '@/components/ComponentBrowser/component' import { makeComponentList, type Component } from '@/components/ComponentBrowser/component'
import { Filtering } from '@/components/ComponentBrowser/filtering' import { Filtering } from '@/components/ComponentBrowser/filtering'
import { Input } from '@/components/ComponentBrowser/input' import { Input } from '@/components/ComponentBrowser/input'
import { default as DocumentationPanel } from '@/components/DocumentationPanel.vue'
import SvgIcon from '@/components/SvgIcon.vue' import SvgIcon from '@/components/SvgIcon.vue'
import ToggleIcon from '@/components/ToggleIcon.vue' import ToggleIcon from '@/components/ToggleIcon.vue'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
@ -14,8 +15,8 @@ import type { useNavigator } from '@/util/navigator'
import type { Opt } from '@/util/opt' import type { Opt } from '@/util/opt'
import { allRanges } from '@/util/range' import { allRanges } from '@/util/range'
import { Vec2 } from '@/util/vec2' import { Vec2 } from '@/util/vec2'
import { LoremIpsum } from 'lorem-ipsum' import type { SuggestionId } from 'shared/languageServerTypes/suggestions'
import { computed, nextTick, onMounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch, type Ref } from 'vue'
const ITEM_SIZE = 32 const ITEM_SIZE = 32
const TOP_BAR_HEIGHT = 32 const TOP_BAR_HEIGHT = 32
@ -44,8 +45,10 @@ const transform = computed(() => {
const nav = props.navigator const nav = props.navigator
const translate = nav.translate const translate = nav.translate
const position = translate.add(props.position).scale(nav.scale) const position = translate.add(props.position).scale(nav.scale)
const x = Math.round(position.x)
const y = Math.round(position.y)
return `translate(${position.x}px, ${position.y}px) translateY(-100%)` return `translate(${x}px, ${y}px) translateY(-100%)`
}) })
// === Input and Filtering === // === Input and Filtering ===
@ -169,9 +172,13 @@ const highlightHeight = computed(() => (selected.value != null ? ITEM_SIZE : 0))
const animatedHighlightPosition = useApproach(highlightPosition) const animatedHighlightPosition = useApproach(highlightPosition)
const animatedHighlightHeight = useApproach(highlightHeight) const animatedHighlightHeight = useApproach(highlightHeight)
const selectedSuggestion = computed(() => { const selectedSuggestionId = computed(() => {
if (selected.value === null) return null if (selected.value === null) return null
const id = components.value[selected.value]?.suggestionId return components.value[selected.value]?.suggestionId ?? null
})
const selectedSuggestion = computed(() => {
const id = selectedSuggestionId.value
if (id == null) return null if (id == null) return null
return suggestionDbStore.entries.get(id) ?? null return suggestionDbStore.entries.get(id) ?? null
}) })
@ -236,7 +243,20 @@ function updateScroll() {
// === Documentation Panel === // === Documentation Panel ===
const docsVisible = ref(true) const docsVisible = ref(true)
const docs = new LoremIpsum().generateParagraphs(6)
const displayedDocs: Ref<Opt<SuggestionId>> = ref(null)
const docEntry = computed({
get() {
return displayedDocs.value
},
set(value) {
displayedDocs.value = value
},
})
watch(selectedSuggestionId, (id) => {
docEntry.value = id
})
// === Accepting Entry === // === Accepting Entry ===
@ -376,8 +396,8 @@ const handler = componentBrowserBindings.handler({
</div> </div>
</div> </div>
</div> </div>
<div class="panel docs scrollable" :class="{ hidden: !docsVisible }" @wheel.stop.passive> <div class="panel docs" :class="{ hidden: !docsVisible }">
{{ docs }} <DocumentationPanel v-model:selectedEntry="docEntry" />
</div> </div>
</div> </div>
<div class="CBInput"> <div class="CBInput">
@ -433,7 +453,6 @@ const handler = componentBrowserBindings.handler({
width: 406px; width: 406px;
clip-path: inset(0 0 0 0 round 20px); clip-path: inset(0 0 0 0 round 20px);
transition: clip-path 0.2s; transition: clip-path 0.2s;
overflow-y: auto;
} }
.docs.hidden { .docs.hidden {
clip-path: inset(0 100% 0 0 round 20px); clip-path: inset(0 100% 0 0 round 20px);

View File

@ -0,0 +1,102 @@
<script setup lang="ts">
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 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 Opt } from '@/util/opt'
import { computed } from 'vue'
const props = defineProps<{ selectedEntry: Opt<SuggestionId> }>()
const emit = defineEmits<{ 'update:selectedEntry': [id: SuggestionId] }>()
const db = useSuggestionDbStore()
const documentation = computed<Docs>(() => {
const entry = props.selectedEntry
return entry ? lookupDocumentation(db.entries, entry) : placeholder('No suggestion selected.')
})
const sections = computed<Sections>(() => {
const docs: Docs = documentation.value
const fallback = { tags: [], synopsis: [], examples: [] }
return docs.kind === 'Placeholder' ? fallback : docs.sections
})
const methods = computed<FunctionDocs[]>(() => {
const docs = documentation.value
return docs.kind === 'Module' || docs.kind === 'Type' ? docs.methods : []
})
const constructors = computed<FunctionDocs[]>(() => {
const docs = documentation.value
return docs.kind === 'Type' ? docs.constructors : []
})
const types = computed<TypeDocs[]>(() => {
const docs = documentation.value
return docs.kind === 'Module' ? docs.types : []
})
</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" />
<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)"
/>
<DocsHeader v-if="constructors.length > 0" kind="methods" label="Constructors" />
<DocsList
:items="{ kind: 'Constructors', items: constructors }"
@linkClicked="emit('update:selectedEntry', $event)"
/>
<DocsHeader v-if="methods.length > 0" kind="methods" label="Methods" />
<DocsList
:items="{ kind: 'Methods', items: methods }"
@linkClicked="emit('update:selectedEntry', $event)"
/>
<DocsHeader v-if="sections.examples.length > 0" kind="examples" label="Examples" />
<DocsExamples :examples="sections.examples" />
</div>
</div>
</template>
<style scoped>
.DocumentationPanel {
--enso-docs-type-name-color: #9640da;
--enso-docs-methods-header-color: #1f71d3;
--enso-docs-method-name-color: #1f71d3;
--enso-docs-types-header-color: #1f71d3;
--enso-docs-examples-header-color: #6da85e;
--enso-docs-important-background-color: #edefe7;
--enso-docs-info-background-color: #e6f1f8;
--enso-docs-example-background-color: #e6f1f8;
--enso-docs-background-color: #eaeaea;
--enso-docs-text-color: rbga(0, 0, 0, 0.6);
--enso-docs-tag-background-color: #dcd8d8;
--enso-docs-code-background-color: #dddcde;
font-family: 'M PLUS 1', DejaVuSansMonoBook, sans-serif;
font-size: 11.5px;
line-height: 160%;
color: var(--enso-docs-text-color);
background-color: var(--enso-docs-background-color);
padding: 8px 12px 4px 8px;
white-space: normal;
clip-path: inset(0 0 4px 0);
height: 100%;
overflow-y: auto;
}
.sections {
padding: 0 8px;
}
</style>

View File

@ -0,0 +1,30 @@
<script setup lang="ts">
import type { Example } from '@/components/DocumentationPanel/ir'
const props = defineProps<{ examples: Example[] }>()
</script>
<template>
<div v-if="props.examples.length > 0">
<div v-for="(example, index) in props.examples" :key="index" class="exampleContainer">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="example.body"></div>
</div>
</div>
</template>
<style scoped>
.exampleContainer {
background-color: var(--enso-docs-example-background-color);
border-radius: 0.25rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
}
:deep(.example) {
white-space: pre;
overflow-x: auto;
margin: 0.05rem 0.1rem;
box-shadow: inset 0 0 1px 0.5px rgba(0, 0, 0, 0.2);
}
</style>

View File

@ -0,0 +1,88 @@
<script setup lang="ts">
import iconExamples from '@/assets/icon-examples.svg'
import iconMethods from '@/assets/icon-methods.svg'
import { computed } from 'vue'
export type Kind = 'types' | 'methods' | 'examples'
const props = defineProps<{ kind: Kind; label: string }>()
const classes = computed(() => ['Header', props.kind])
const iconMap: Record<Kind, string> = {
types: iconMethods,
methods: iconMethods,
examples: iconExamples,
}
const icon = computed(() => {
return iconMap[props.kind] || iconMethods
})
</script>
<template>
<div :class="classes">
<div class="headerIcon">
<img :src="icon" />
</div>
<div class="headerText">{{ props.label }}</div>
</div>
</template>
<style scoped>
.Header {
display: flex;
align-items: center;
gap: 6px;
margin-top: 16px;
&:first-child {
margin-top: 0;
}
}
.headerIcon {
display: flex;
padding-top: 4px;
align-items: flex-start;
> img {
pointer-events: none;
width: 16px;
height: 16px;
}
}
.headerText {
padding-top: 2px;
font-size: 14px;
font-weight: 800;
line-height: 24px;
}
.methods {
color: var(--enso-docs-methods-header-color);
}
.types {
color: var(--enso-docs-types-header-color);
}
.examples {
color: var(--enso-docs-examples-header-color);
.headerIcon {
padding-top: 3px;
> img {
width: 12px;
height: 12px;
}
}
.headerText {
font-size: 13px;
line-height: 22px;
}
}
</style>

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import type { FunctionDocs, TypeDocs } from '@/components/DocumentationPanel/ir'
import type { Doc } from '@/util/docParser'
import type { SuggestionEntryArgument, SuggestionId } from 'shared/languageServerTypes/suggestions'
import { computed } from 'vue'
const props = defineProps<{ items: ListItems }>()
const emit = defineEmits<{ linkClicked: [id: SuggestionId] }>()
interface Methods {
kind: 'Methods'
items: FunctionDocs[]
}
interface Constructors {
kind: 'Constructors'
items: FunctionDocs[]
}
interface Types {
kind: 'Types'
items: TypeDocs[]
}
type ListItems = Methods | Constructors | Types
function firstParagraph(synopsis: Doc.Section[]): string | undefined {
if (synopsis[0] && 'Paragraph' in synopsis[0]) {
return synopsis[0].Paragraph.body
}
}
function argumentsList(args: SuggestionEntryArgument[]): string {
return args
.map((arg) => {
const defaultValue = arg.defaultValue ? ` = ${arg.defaultValue}` : ''
return `${arg.name}${defaultValue}`
})
.join(', ')
}
const annotations = computed<Array<string | undefined>>(() => {
return props.items.items.map((item) => firstParagraph(item.sections.synopsis))
})
</script>
<template>
<ul v-if="props.items.items.length > 0">
<li v-for="(item, index) in props.items.items" :key="index" :class="props.items.kind">
<a
:class="['link', props.items.kind]"
@pointerdown.stop.prevent="emit('linkClicked', item.id)"
>
<span class="entryName">{{ item.name }}</span>
<span class="arguments">{{ ' ' + argumentsList(item.arguments) }}</span>
</a>
<!-- eslint-disable vue/no-v-html -->
<span v-if="annotations[index]" v-html="' ' + annotations[index]"></span>
<!-- eslint-enable vue/no-v-html -->
</li>
</ul>
</template>
<style scoped>
.link {
cursor: pointer;
font-weight: 600;
&:hover {
text-decoration: underline;
}
&.Types {
color: var(--enso-docs-type-name-color);
}
&.Methods {
color: var(--enso-docs-method-name-color);
}
&.Constructors {
color: var(--enso-docs-type-name-color);
}
}
.entryName {
opacity: 0.85;
}
.arguments {
opacity: 0.34;
}
ul {
margin: 0;
padding: 0;
list-style-type: none;
list-style-position: inside;
}
li {
&:before {
content: '•';
font-size: 13px;
font-weight: 700;
margin-right: 3px;
}
&.Types:before {
color: var(--enso-docs-type-name-color);
}
&.Methods:before {
color: var(--enso-docs-method-name-color);
}
&.Constructors:before {
color: var(--enso-docs-method-name-color);
}
}
</style>

View File

@ -0,0 +1,106 @@
<script setup lang="ts">
import iconImportant from '@/assets/icon-important.svg'
import iconInfo from '@/assets/icon-info.svg'
import type { Doc } from '@/util/docParser'
const props = defineProps<{ sections: Doc.Section[] }>()
</script>
<template>
<!-- eslint-disable vue/no-v-html -->
<div v-if="props.sections.length > 0">
<template v-for="(section, _i) in props.sections" :key="_i">
<p v-if="'Paragraph' in section" class="paragraph">
<span v-html="section.Paragraph.body"></span>
</p>
<p v-else-if="'Keyed' in section" class="paragraph">
{{ section.Keyed.key + ': ' + section.Keyed.body }}
</p>
<div v-else-if="'Marked' in section" :class="[section.Marked.mark, 'markedContainer']">
<div v-if="'header' in section.Marked" class="markedHeader">
<img :src="section.Marked.mark == 'Info' ? iconInfo : iconImportant" class="markedIcon" />
{{ section.Marked.header }}
</div>
<p class="paragraph" v-html="section.Marked.body" />
</div>
<ul v-else-if="'List' in section">
<li v-for="(item, index) in section.List.items" :key="index" v-html="item"></li>
</ul>
<ul v-else-if="'Arguments' in section">
<li v-for="(arg, index) in section.Arguments.args" :key="index">
<span class="argument">{{ arg.name }}</span
>:&nbsp;
<span v-html="arg.description"></span>
</li>
</ul>
</template>
</div>
<!-- eslint-enable vue/no-v-html -->
</template>
<style scoped>
ul {
margin: 0;
padding: 0;
list-style-type: none;
list-style-position: inside;
}
li:before {
content: '•';
font-size: 13px;
font-weight: 600;
margin-right: 6px;
}
.paragraph {
margin: 0;
padding: 1px 0;
}
.argument {
font-weight: 800;
}
/* Marked sections, such as `Info` and `Important` sections. */
.Info {
background-color: var(--enso-docs-info-background-color);
}
.Important {
background-color: var(--enso-docs-important-background-color);
}
.markedContainer {
border-radius: 0.5rem;
padding: 0.5rem;
margin: 0.5rem 0;
}
.markedHeader {
font-weight: 600;
font-size: 13px;
margin: 0;
display: flex;
align-items: center;
gap: 0.25em;
}
.markedIcon {
pointer-events: none;
width: 1em;
height: 1em;
margin: 0 0.05em 0 0.05em;
vertical-align: -0.1em;
fill: none;
}
/* Code. The words emphasized with backticks. */
:deep(code) {
background-color: var(--enso-docs-code-background-color);
border-radius: 4px;
padding: 3px;
}
</style>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import type { Doc } from '@/util/docParser'
import { computed } from 'vue'
const props = defineProps<{ tags: Doc.Section.Tag[] }>()
// Split Alias tags into separate ones.
const tags = computed<Doc.Section.Tag[]>(() => {
return props.tags.flatMap((tag) => {
if (tag.tag === 'Alias') {
return tag.body.split(/,\s*/).map((body) => ({ tag: 'Alias', body }))
} else {
return tag
}
})
})
interface View {
label: string
class?: string
style?: { [key: string]: string | number | undefined }
}
const defaultView = (tag: Doc.Tag, body: Doc.HtmlString): View => ({
label: tag + (body.length > 0 ? `=${body}` : ''),
})
const tagsMap: Partial<Record<Doc.Tag, (body: Doc.HtmlString) => View>> = {
Alias: (body) => ({ label: body }),
}
const view = (tag: Doc.Section.Tag): View => {
const maybeView = tagsMap[tag.tag]
return maybeView ? maybeView(tag.body) : defaultView(tag.tag, tag.body)
}
</script>
<template>
<div v-if="tags.length > 0" class="Tags">
<div
v-for="(tag, index) in tags"
:key="index"
:class="view(tag).class || 'tag'"
:style="view(tag).style || {}"
>
{{ view(tag).label }}
</div>
</div>
</template>
<style scoped>
.Tags {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: start;
gap: 2px;
}
.tag {
display: flex;
height: 24px;
align-items: flex-start;
background-color: var(--enso-docs-tag-background-color);
border-radius: 4px;
padding: 1px 5px;
}
</style>

View File

@ -0,0 +1,179 @@
import type { SuggestionDb } from '@/stores/suggestionDatabase'
import type { SuggestionEntry, SuggestionId } from '@/stores/suggestionDatabase/entry'
import { SuggestionKind } from '@/stores/suggestionDatabase/entry'
import type { Doc } from '@/util/docParser'
import type { SuggestionEntryArgument } from 'shared/languageServerTypes/suggestions'
// === Types ===
/**
* Intermediate representation of the entries documentation.
*/
export type Docs = FunctionDocs | TypeDocs | ModuleDocs | LocalDocs | Placeholder
export interface Placeholder {
kind: 'Placeholder'
text: string
}
export interface FunctionDocs {
kind: 'Function'
id: SuggestionId
name: string
arguments: SuggestionEntryArgument[]
sections: Sections
}
export interface TypeDocs {
kind: 'Type'
id: SuggestionId
name: string
arguments: SuggestionEntryArgument[]
sections: Sections
methods: FunctionDocs[]
constructors: FunctionDocs[]
}
export interface ModuleDocs {
kind: 'Module'
id: SuggestionId
name: string
sections: Sections
types: TypeDocs[]
methods: FunctionDocs[]
}
export interface LocalDocs {
kind: 'Local'
id: SuggestionId
name: string
sections: Sections
}
// === Sections ===
export interface Example {
header?: string
body: Doc.HtmlString
}
export function placeholder(text: string): Placeholder {
return { kind: 'Placeholder', text }
}
/**
* Documentation sections split into three categories.
*
* These categories of sections are present in almost every documentation page.
*/
export interface Sections {
tags: Doc.Section.Tag[]
synopsis: Doc.Section[]
examples: Example[]
}
// Split doc sections into categories.
function filterSections(sections: Iterable<Doc.Section>): Sections {
const tags = []
const synopsis = []
const examples = []
for (const section of sections) {
const isTag = 'Tag' in section
const isExample = 'Marked' in section && section.Marked.mark === 'Example'
if (isTag) {
tags.push(section.Tag)
} else if (isExample) {
examples.push({ ...section.Marked } as Example)
} else {
synopsis.push(section)
}
}
return { tags, synopsis, examples }
}
// === Lookup ===
export function lookupDocumentation(db: SuggestionDb, id: SuggestionId): Docs {
const entry = db.get(id)
if (!entry)
return placeholder(
`Documentation not available. Entry with id ${id} not found in the database.`,
)
const handle = handleDocumentation[entry.kind]
return handle ? handle(db, entry, id) : placeholder(`Entry kind ${entry.kind} was not handled.`)
}
function getChildren(db: SuggestionDb, id: SuggestionId, kind: SuggestionKind): Docs[] {
if (!id) return []
const children = Array.from(db.parent.reverseLookup(id))
return children.reduce((acc: Docs[], id: SuggestionId) => {
if (db.get(id)?.kind === kind) {
const docs = lookupDocumentation(db, id)
acc.push(docs)
}
return acc
}, [])
}
function asFunctionDocs(docs: Docs[]): FunctionDocs[] {
return docs.flatMap((doc) => {
if (doc.kind === 'Function') {
return [doc]
} else {
console.error(`Unexpected docs type: ${docs}, expected Function`)
return []
}
})
}
function asTypeDocs(docs: Docs[]): TypeDocs[] {
return docs.flatMap((doc) => {
if (doc.kind === 'Type') {
return [doc]
} else {
console.error(`Unexpected docs type: ${docs}, expected Type`)
return []
}
})
}
type DocsHandle = (db: SuggestionDb, entry: SuggestionEntry, id: SuggestionId) => Docs
const handleFunction: DocsHandle = (_db, entry, id) => ({
kind: 'Function',
id,
name: entry.name,
arguments: entry.arguments,
sections: filterSections(entry.documentation),
})
const handleDocumentation: Record<SuggestionKind, DocsHandle> = {
[SuggestionKind.Function]: handleFunction,
[SuggestionKind.Method]: handleFunction,
[SuggestionKind.Constructor]: handleFunction,
[SuggestionKind.Local]: (_db, entry, id) => ({
kind: 'Local',
id,
name: entry.name,
sections: filterSections(entry.documentation),
}),
[SuggestionKind.Type]: (db, entry, id) => ({
kind: 'Type',
id,
name: entry.name,
arguments: entry.arguments,
sections: filterSections(entry.documentation),
methods: asFunctionDocs(getChildren(db, id, SuggestionKind.Method)),
constructors: asFunctionDocs(getChildren(db, id, SuggestionKind.Constructor)),
}),
[SuggestionKind.Module]: (db, entry, id) => ({
kind: 'Module',
id,
name: entry.name,
sections: filterSections(entry.documentation),
types: asTypeDocs(getChildren(db, id, SuggestionKind.Type)),
methods: asFunctionDocs(getChildren(db, id, SuggestionKind.Method)),
}),
}

View File

@ -417,15 +417,15 @@ const activeStyle = computed(() => {
if (base.value == null) return {} if (base.value == null) return {}
if (navigator?.sceneMousePos == null) return {} if (navigator?.sceneMousePos == null) return {}
const length = base.value.getTotalLength() const length = base.value.getTotalLength()
let offset = lengthTo(navigator?.sceneMousePos) let offset = lengthTo(navigator.sceneMousePos)
if (offset == null) return {} if (offset == null) return {}
offset = length - offset offset = length - offset
if (offset < length / 2) { if (offset < length / 2) {
offset += length offset += length
} }
return { return {
'stroke-dasharray': length, strokeDasharray: length,
'stroke-dashoffset': offset, strokeDashoffset: offset,
} }
}) })
@ -459,22 +459,23 @@ const arrowTransform = computed(() => {
</script> </script>
<template> <template>
<path <template v-if="basePath">
v-if="basePath" <path
:d="basePath" :d="basePath"
class="edge io" class="edge io"
@pointerdown="click" @pointerdown="click"
@pointerenter="hovered = true" @pointerenter="hovered = true"
@pointerleave="hovered = false" @pointerleave="hovered = false"
/> />
<path v-if="basePath" ref="base" :d="basePath" class="edge visible base" /> <path ref="base" :d="basePath" class="edge visible base" />
<path v-if="activePath" :d="activePath" class="edge visible active" :style="activeStyle" /> <polygon
<polygon v-if="arrowTransform"
v-if="arrowTransform" :transform="arrowTransform"
:transform="arrowTransform" points="0,-9.375 -9.375,9.375 9.375,9.375"
points="0,-9.375 -9.375,9.375 9.375,9.375" class="arrow visible"
class="arrow visible" />
/> <path v-if="activePath" :d="activePath" class="edge visible active" :style="activeStyle" />
</template>
</template> </template>
<style scoped> <style scoped>
@ -486,6 +487,7 @@ const arrowTransform = computed(() => {
} }
.edge { .edge {
fill: none; fill: none;
stroke-linecap: round;
} }
.edge.io { .edge.io {
stroke-width: 14; stroke-width: 14;

View File

@ -7,6 +7,7 @@ import { injectGraphSelection } from '@/providers/graphSelection'
import type { Node } from '@/stores/graph' import type { Node } from '@/stores/graph'
import { useProjectStore } from '@/stores/project' import { useProjectStore } from '@/stores/project'
import { useSuggestionDbStore } from '@/stores/suggestionDatabase' import { useSuggestionDbStore } from '@/stores/suggestionDatabase'
import { useApproach } from '@/util/animation'
import { colorFromString } from '@/util/colors' import { colorFromString } from '@/util/colors'
import { usePointer, useResizeObserver } from '@/util/events' import { usePointer, useResizeObserver } from '@/util/events'
import { methodNameToIcon, typeNameToIcon } from '@/util/getIconName' import { methodNameToIcon, typeNameToIcon } from '@/util/getIconName'
@ -65,6 +66,17 @@ watchEffect(() => {
} }
}) })
const outputHovered = ref(false)
const hoverAnimation = useApproach(() => (outputHovered.value ? 1 : 0), 50, 0.01)
const bgStyleVariables = computed(() => {
return {
'--node-width': `${nodeSize.value.x}px`,
'--node-height': `${nodeSize.value.y}px`,
'--hover-animation': `${hoverAnimation.value}`,
}
})
const transform = computed(() => { const transform = computed(() => {
let pos = props.node.position let pos = props.node.position
return `translate(${pos.x}px, ${pos.y}px)` return `translate(${pos.x}px, ${pos.y}px)`
@ -420,81 +432,98 @@ function hoverExpr(id: ExprId | undefined) {
@updateHoveredExpr="hoverExpr($event)" @updateHoveredExpr="hoverExpr($event)"
/></span> /></span>
</div> </div>
<div key="outputPort" class="outputPort" @pointerdown="emit('outputPortAction')"> <svg class="bgPaths" :style="bgStyleVariables">
<svg viewBox="-22 -35 22 38" xmlns="http://www.w3.org/2000/svg" class="outputPortCap"> <rect class="bgFill" />
<path d="M 0 0 a 19 19 0 0 1 -19 -19" class="outputPortCapLine" /> <rect
<rect height="6" width="6" x="0" y="-3" class="outputPortCapButt" /> class="outputPortHoverArea"
</svg> @pointerenter="outputHovered = true"
<svg @pointerleave="outputHovered = false"
viewBox="0 -35 1 38" @pointerdown="emit('outputPortAction')"
preserveAspectRatio="none"
xmlns="http://www.w3.org/2000/svg"
class="outputPortBar"
>
<path d="M 0 0 h 1" class="outputPortBarLine" />
</svg>
<svg viewBox="0 -35 22 38" xmlns="http://www.w3.org/2000/svg" class="outputPortCap">
<path d="M 0 0 a 19 19 0 0 0 19 -19" class="outputPortCapLine" />
<rect height="6" width="6" x="-6" y="-3" class="outputPortCapButt" />
</svg>
/> />
</div> <rect class="outputPort" />
</svg>
<div class="outputTypeName">{{ outputTypeName }}</div> <div class="outputTypeName">{{ outputTypeName }}</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.outputPort { .bgPaths {
width: 100%; width: 100%;
margin: 0; height: 100%;
position: fixed; position: absolute;
overflow: visible;
top: 0px; top: 0px;
left: 0px; left: 0px;
display: flex; display: flex;
opacity: 0;
--output-port-max-width: 6px;
--output-port-overlap: 0.1px;
--output-port-hover-width: 8px;
} }
.outputPort:hover { .outputPort,
opacity: 1; .outputPortHoverArea {
} x: calc(0px - var(--output-port-width) / 2);
.outputPortCap { y: calc(0px - var(--output-port-width) / 2);
flex: none; width: calc(var(--node-width) + var(--output-port-width));
height: 38px; height: calc(var(--node-height) + var(--output-port-width));
width: 22px; rx: calc(var(--node-border-radius) + var(--output-port-width) / 2);
}
.outputPortCapLine {
fill: none; fill: none;
stroke: var(--node-group-color); stroke: var(--node-color-port);
opacity: 30%; stroke-width: calc(var(--output-port-width) + var(--output-port-overlap));
stroke-width: 6px; transition: stroke 0.2s ease;
--horizontal-line: calc(var(--node-width) - var(--node-border-radius) * 2);
--vertical-line: calc(var(--node-height) - var(--node-border-radius) * 2);
--radius-arclength: calc(
(var(--node-border-radius) + var(--output-port-width) * 0.5) * 2 * 3.141592653589793
);
stroke-dasharray: calc(var(--horizontal-line) + var(--radius-arclength) * 0.5) 10000%;
stroke-dashoffset: calc(
0px - var(--horizontal-line) - var(--vertical-line) - var(--radius-arclength) * 0.25
);
stroke-linecap: round; stroke-linecap: round;
} }
.outputPortCapButt {
fill: var(--node-group-color); .outputPort {
opacity: 30%; --output-port-width: calc(
var(--output-port-max-width) * var(--hover-animation) - var(--output-port-overlap)
);
pointer-events: none;
} }
.outputPortBar {
height: 38px; .outputPortHoverArea {
width: 100%; --output-port-width: var(--output-port-hover-width);
stroke: transparent;
pointer-events: all;
} }
.outputPortBarLine {
fill: none; .bgFill {
stroke: var(--node-group-color); width: var(--node-width);
opacity: 30%; height: var(--node-height);
/* 6px + extra width to prevent antialiasing issues: rx: var(--node-border-radius);
The 1px on the top will draw mostly under the node, but will ensure the line meets the node.
(The 1px on the bottom will be clipped.) */ fill: var(--node-color-primary);
stroke-width: 8px; transition: fill 0.2s ease;
} }
.bgPaths .bgPaths:hover {
opacity: 1;
}
.GraphNode { .GraphNode {
--node-height: 32px; --node-height: 32px;
--node-border-radius: calc(var(--node-height) * 0.5); --node-border-radius: 16px;
--output-port-padding: 6px;
--node-group-color: #357ab9; --node-group-color: #357ab9;
--node-color-primary: color-mix(in oklab, var(--node-group-color) 100%, transparent 0%); --node-color-primary: color-mix(
in oklab,
var(--node-group-color) 100%,
var(--node-group-color) 0%
);
--node-color-port: color-mix(in oklab, var(--node-color-primary) 75%, white 15%); --node-color-port: color-mix(in oklab, var(--node-color-primary) 75%, white 15%);
--node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgba(255, 0, 0) 70%); --node-color-error: color-mix(in oklab, var(--node-group-color) 30%, rgb(255, 0, 0) 70%);
&.executionState-Unknown, &.executionState-Unknown,
&.executionState-Pending { &.executionState-Pending {
@ -507,9 +536,6 @@ function hoverExpr(id: ExprId | undefined) {
::selection { ::selection {
background-color: rgba(255, 255, 255, 20%); background-color: rgba(255, 255, 255, 20%);
} }
padding-left: var(--output-port-padding);
padding-right: var(--output-port-padding);
} }
.node { .node {
@ -518,8 +544,6 @@ function hoverExpr(id: ExprId | undefined) {
left: 0; left: 0;
caret-shape: bar; caret-shape: bar;
height: var(--node-height); height: var(--node-height);
background: var(--node-color-primary);
background-clip: padding-box;
border-radius: var(--node-border-radius); border-radius: var(--node-border-radius);
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -527,14 +551,12 @@ function hoverExpr(id: ExprId | undefined) {
white-space: nowrap; white-space: nowrap;
padding: 4px 8px; padding: 4px 8px;
z-index: 2; z-index: 2;
transition: transition: outline 0.2s ease;
background 0.2s ease,
outline 0.2s ease;
outline: 0px solid transparent; outline: 0px solid transparent;
} }
.GraphNode .selection { .GraphNode .selection {
position: absolute; position: absolute;
inset: calc(0px - var(--selected-node-border-width) + var(--output-port-padding)); inset: calc(0px - var(--selected-node-border-width));
--node-current-selection-width: 0px; --node-current-selection-width: 0px;
&:before { &:before {

View File

@ -118,7 +118,7 @@ function hover(part: 'tree' | 'token', isHovered: boolean) {
v-else v-else
ref="rootNode" ref="rootNode"
:class="['Tree', spanClass]" :class="['Tree', spanClass]"
:data-span-start="props.ast.span()[0] - nodeSpanStart" :data-span-start="props.ast.span()[0] - nodeSpanStart - whitespace.length"
>{{ whitespace >{{ whitespace
}}<template v-for="child in children" :key="child.astId"> }}<template v-for="child in children" :key="child.astId">
<NodeTree <NodeTree

24
package-lock.json generated
View File

@ -45,7 +45,6 @@
"install": "^0.13.0", "install": "^0.13.0",
"isomorphic-ws": "^5.0.0", "isomorphic-ws": "^5.0.0",
"lib0": "^0.2.85", "lib0": "^0.2.85",
"lorem-ipsum": "^2.0.8",
"magic-string": "^0.30.3", "magic-string": "^0.30.3",
"murmurhash": "^2.0.1", "murmurhash": "^2.0.1",
"pinia": "^2.1.6", "pinia": "^2.1.6",
@ -11329,29 +11328,6 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lorem-ipsum": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/lorem-ipsum/-/lorem-ipsum-2.0.8.tgz",
"integrity": "sha512-5RIwHuCb979RASgCJH0VKERn9cQo/+NcAi2BMe9ddj+gp7hujl6BI+qdOG4nVsLDpwWEJwTVYXNKP6BGgbcoGA==",
"dependencies": {
"commander": "^9.3.0"
},
"bin": {
"lorem-ipsum": "dist/bin/lorem-ipsum.bin.js"
},
"engines": {
"node": ">= 8.x",
"npm": ">= 5.x"
}
},
"node_modules/lorem-ipsum/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/loupe": { "node_modules/loupe": {
"version": "2.3.6", "version": "2.3.6",
"dev": true, "dev": true,