Skip to content

Commit ef31c57

Browse files
committed
feat: add custom outline actions sidebar
1 parent 5fef919 commit ef31c57

File tree

4 files changed

+203
-0
lines changed

4 files changed

+203
-0
lines changed

docs/vitepress-theme/index.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,44 @@ All other VitePress configuration options work as expected:
265265

266266
Refer to the [VitePress Default Theme Config](https://vitepress.dev/reference/default-theme-config) for all available options.
267267

268+
## Custom Outline Actions
269+
270+
The theme allows you to add custom actions to the right outline sidebar. By default, it includes a "Copy page" button with expandable options for copying markdown links, viewing as markdown, and opening in ChatGPT or Claude.
271+
272+
You can add your own custom actions (like a feedback button) through the theme configuration:
273+
274+
```ts [themeConfig.ts]
275+
export const themeConfig = {
276+
modules: [
277+
// ... your modules
278+
],
279+
outlineActions: [
280+
{
281+
icon: 'i-tabler:message',
282+
label: 'Share feedback',
283+
onClick: () => {
284+
// Open your custom feedback modal
285+
// This function has access to the DOM and can trigger any action
286+
document.dispatchEvent(new CustomEvent('open-feedback-modal'))
287+
}
288+
}
289+
],
290+
// ... rest of your config
291+
}
292+
```
293+
294+
### OutlineAction Interface
295+
296+
Each outline action supports the following properties:
297+
298+
| Property | Type | Description |
299+
| --------- | ----------------------------- | ------------------------------------------ |
300+
| `icon` | `string` | Icon class (e.g., `i-tabler:message`) |
301+
| `label` | `string` | Text displayed next to the icon |
302+
| `onClick` | `() => void \| Promise<void>` | Function called when the action is clicked |
303+
304+
The actions appear below the outline (table of contents) with a horizontal separator, maintaining the same visual style as the outline items.
305+
268306
## Customization
269307

270308
This theme **has not been developed with customatization in mind**. In fact, it has the least possible amount of options on purpose as we want to keep it simple.
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<script setup lang="ts">
2+
import type { OutlineAction } from '../types'
3+
import { useData } from 'vitepress'
4+
import { computed, ref } from 'vue'
5+
import { useSourceCode } from '../composables/useSourceCode'
6+
7+
const { theme } = useData()
8+
const {
9+
copyMarkdownContent,
10+
copyMarkdownLink,
11+
chatGPTUrl,
12+
claudeUrl,
13+
viewAsMarkdown,
14+
copyOptionsConfig,
15+
showCopyMarkdown,
16+
} = useSourceCode()
17+
18+
const isExpanded = ref(false)
19+
20+
const customActions = computed<OutlineAction[]>(() => {
21+
return (theme.value.outlineActions as OutlineAction[] | undefined) || []
22+
})
23+
24+
const allActions = computed(() => {
25+
const actions: OutlineAction[] = []
26+
27+
if (showCopyMarkdown.value) {
28+
actions.push({
29+
icon: 'i-tabler:copy',
30+
label: 'Copy page',
31+
onClick: copyMarkdownContent,
32+
})
33+
}
34+
35+
return [...actions, ...customActions.value]
36+
})
37+
38+
const dropdownOptions = computed(() => {
39+
const options: OutlineAction[] = []
40+
41+
if (copyOptionsConfig.value.markdownLink) {
42+
options.push({
43+
icon: 'i-tabler:link',
44+
label: 'Copy markdown link',
45+
onClick: copyMarkdownLink,
46+
})
47+
}
48+
49+
if (copyOptionsConfig.value.viewMarkdown) {
50+
options.push({
51+
icon: 'i-tabler:eye',
52+
label: 'View as markdown',
53+
onClick: viewAsMarkdown,
54+
})
55+
}
56+
57+
if (copyOptionsConfig.value.chatgpt) {
58+
options.push({
59+
icon: 'i-tabler:brand-openai',
60+
label: 'Open in ChatGPT',
61+
onClick: () => {
62+
if (typeof window !== 'undefined') {
63+
window.open(chatGPTUrl.value, '_blank', 'noopener,noreferrer')
64+
}
65+
},
66+
})
67+
}
68+
69+
if (copyOptionsConfig.value.claude) {
70+
options.push({
71+
icon: 'i-tabler:sparkles',
72+
label: 'Open in Claude',
73+
onClick: () => {
74+
if (typeof window !== 'undefined') {
75+
window.open(claudeUrl.value, '_blank', 'noopener,noreferrer')
76+
}
77+
},
78+
})
79+
}
80+
81+
return options
82+
})
83+
84+
const hasDropdown = computed(() => dropdownOptions.value.length > 0)
85+
86+
function toggleExpanded() {
87+
isExpanded.value = !isExpanded.value
88+
}
89+
</script>
90+
91+
<template>
92+
<div v-if="allActions.length > 0" f-mt-md>
93+
<hr border-neutral-200 f-my-md>
94+
95+
<div flex="~ col gap-4">
96+
<div
97+
v-for="(action, index) in allActions"
98+
:key="index"
99+
flex="~ items-center justify-between"
100+
p-4
101+
cursor-pointer
102+
hover:bg-neutral-100
103+
rounded-6
104+
transition-colors
105+
@click="action.onClick"
106+
>
107+
<div flex="~ items-center gap-8" f-text-xs text-neutral-800>
108+
<div :class="action.icon" />
109+
<span>{{ action.label }}</span>
110+
</div>
111+
112+
<button
113+
v-if="index === 0 && hasDropdown"
114+
type="button"
115+
p-4
116+
hover:bg-neutral-200
117+
rounded-4
118+
transition-colors
119+
@click.stop="toggleExpanded"
120+
>
121+
<div :class="isExpanded ? 'i-tabler:chevron-up' : 'i-tabler:chevron-down'" />
122+
</button>
123+
</div>
124+
125+
<Transition
126+
enter-active-class="transition-all duration-200"
127+
enter-from-class="opacity-0 -translate-y-8"
128+
enter-to-class="opacity-100 translate-y-0"
129+
leave-active-class="transition-all duration-200"
130+
leave-from-class="opacity-100 translate-y-0"
131+
leave-to-class="opacity-0 -translate-y-8"
132+
>
133+
<div v-if="isExpanded && hasDropdown" flex="~ col gap-4" pl-16>
134+
<div
135+
v-for="(option, idx) in dropdownOptions"
136+
:key="idx"
137+
flex="~ items-center gap-8"
138+
p-4
139+
cursor-pointer
140+
hover:bg-neutral-100
141+
rounded-6
142+
transition-colors
143+
f-text-xs
144+
text-neutral-700
145+
@click="option.onClick"
146+
>
147+
<div :class="option.icon" />
148+
<span>{{ option.label }}</span>
149+
</div>
150+
</div>
151+
</Transition>
152+
</div>
153+
</div>
154+
</template>

packages/nimiq-vitepress-theme/src/layout/SecondarySidebar.vue

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { useSecondarySidebar } from '../composables/useSecondarySidebar'
3+
import OutlineActions from './OutlineActions.vue'
34
45
const { withWidget = true } = defineProps<{ withWidget?: boolean }>()
56
@@ -31,6 +32,9 @@ const { headingTree, isHeadingActive, showOutline, showWidget } = useSecondarySi
3132
</ol>
3233
</li>
3334
</ol>
35+
36+
<OutlineActions />
37+
3438
<div v-if="withWidget && showWidget" id="widget" max-w-full :class="{ 'f-mt-md': showOutline }" h-max />
3539
</div>
3640
</template>

packages/nimiq-vitepress-theme/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export interface NimiqVitepressThemeNav<T extends `/${string}/` = any> {
4040
sidebar: NimiqVitepressSidebar<T>[]
4141
}
4242

43+
export interface OutlineAction {
44+
icon: string
45+
label: string
46+
onClick: () => void | Promise<void>
47+
}
48+
4349
export interface NimiqVitepressThemeConfig {
4450
modules: NimiqVitepressThemeNav[]
4551
links?: {
@@ -50,6 +56,7 @@ export interface NimiqVitepressThemeConfig {
5056
showLastUpdated?: boolean
5157
showEditContent?: boolean
5258
search?: { provider: 'local' }
59+
outlineActions?: OutlineAction[]
5360
}
5461

5562
export interface NimiqVitepressFrontmatter {

0 commit comments

Comments
 (0)