feat(right-side-panel): add content right side panel with toggle functionality

This commit is contained in:
洋子
2026-01-22 10:08:50 +00:00
parent b0bba65784
commit b46e286cc0
12 changed files with 397 additions and 18 deletions
+5
View File
@@ -21,6 +21,9 @@
<template v-slot:preview>
<Previewer />
</template>
<template v-slot:content-right-side>
<ContentRightSide />
</template>
<template v-slot:right-before>
<FileTabs />
</template>
@@ -57,6 +60,7 @@ import Terminal from '@fe/components/Terminal.vue'
import FileTabs from '@fe/components/FileTabs.vue'
import Editor from '@fe/components/Editor.vue'
import Previewer from '@fe/components/Previewer.vue'
import ContentRightSide from '@fe/components/ContentRightSide.vue'
import SettingPanel from '@fe/components/SettingPanel.vue'
import ExportPanel from '@fe/components/ExportPanel.vue'
@@ -82,6 +86,7 @@ export default defineComponent({
FileTabs,
Editor,
Previewer,
ContentRightSide,
XFilter,
Premium,
SettingPanel,
@@ -0,0 +1,221 @@
<template>
<div class="content-right-side-panel">
<div class="panel-header">
<div
:class="{'panel-title': true, clickable: panels.length > 1}"
:title="currentPanel?.displayName"
@click="showPanelSwitcher"
>
<span class="title-text">{{ currentPanel?.displayName }}</span>
<svg-icon v-if="panels.length > 1" class="dropdown-icon" name="chevron-down" width="10px" />
</div>
<div class="panel-actions">
<template v-for="btn in actionBtns" :key="btn.type === 'separator' ? undefined : btn.key">
<div v-if="btn.type === 'separator' && !btn.hidden" class="action-separator"></div>
<div
v-else-if="btn.type === 'normal' && !btn.hidden"
class="action-btn"
:title="btn.title"
@click="btn.onClick"
>
<svg-icon :name="btn.icon" width="12px" />
</div>
</template>
<div v-if="actionBtns.length > 0" class="action-separator"></div>
<div class="action-btn" :title="$t('close')" @click="hide">
<svg-icon name="times" width="10px" />
</div>
</div>
</div>
<div class="panel-content">
<keep-alive>
<component v-if="keepAlivePanel" :is="keepAlivePanel.component" :key="keepAlivePanel.name" />
</keep-alive>
<component v-if="nonKeepAlivePanel" :is="nonKeepAlivePanel.component" :key="nonKeepAlivePanel.name" />
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue'
import { registerHook, removeHook } from '@fe/core/hook'
import type { RightSidePanel, Components } from '@fe/types'
import store from '@fe/support/store'
import { ContentRightSide } from '@fe/services/workbench'
import { toggleContentRightSide } from '@fe/services/layout'
import { useQuickFilter } from '@fe/support/ui/quick-filter'
import { useI18n } from '@fe/services/i18n'
import { orderBy } from 'lodash-es'
import SvgIcon from './SvgIcon.vue'
export default defineComponent({
name: 'content-right-side',
components: { SvgIcon },
setup () {
useI18n()
const titleRef = ref<HTMLElement>()
const panels = shallowRef<RightSidePanel[]>(ContentRightSide.getAllPanels())
const currentPanel = computed<RightSidePanel | null>(() => {
const name = store.state.currentRightSidePanel
if (!name) {
return panels.value[0] || null
}
return panels.value.find(p => p.name === name) || panels.value[0] || null
})
const keepAlivePanel = computed<RightSidePanel | null>(() => {
return currentPanel.value?.keepAlive ? currentPanel.value : null
})
const nonKeepAlivePanel = computed<RightSidePanel | null>(() => {
return currentPanel.value && !currentPanel.value.keepAlive ? currentPanel.value : null
})
const actionBtns = computed<Components.RightSidePanel.ActionBtn[]>(() => {
const btns = currentPanel.value?.actionBtns || []
return orderBy(btns.filter(btn => !btn.hidden), x => x.order ?? 256, 'asc')
})
function switchPanel (name: string) {
ContentRightSide.switchPanel(name)
}
function showPanelSwitcher (e: MouseEvent) {
if (panels.value.length <= 1) {
return
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
useQuickFilter().show({
filterInputHidden: true,
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
list: panels.value.map(p => ({ key: p.name, label: p.displayName })),
current: currentPanel.value?.name,
onChoose: ({ key }) => {
switchPanel(key)
},
})
}
function hide () {
toggleContentRightSide(false)
}
function refresh () {
panels.value = ContentRightSide.getAllPanels()
}
onMounted(() => {
registerHook('RIGHT_SIDE_PANEL_CHANGE', refresh)
})
onBeforeUnmount(() => {
removeHook('RIGHT_SIDE_PANEL_CHANGE', refresh)
})
return {
titleRef,
panels,
currentPanel,
keepAlivePanel,
nonKeepAlivePanel,
actionBtns,
switchPanel,
showPanelSwitcher,
hide,
}
},
})
</script>
<style lang="scss" scoped>
.content-right-side-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 30px;
padding: 0 8px;
border-bottom: 1px solid var(--g-color-86);
background: var(--g-color-96);
flex-shrink: 0;
}
.panel-title {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
color: var(--g-color-20);
overflow: hidden;
min-width: 0;
.title-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-icon {
flex-shrink: 0;
color: var(--g-color-40);
}
&.clickable {
cursor: pointer;
padding: 4px 6px;
margin: -4px -6px;
border-radius: 4px;
&:hover {
background: var(--g-color-90);
}
}
}
.panel-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
color: var(--g-color-40);
&:hover {
background: var(--g-color-86);
color: var(--g-color-0);
}
}
.action-separator {
width: 1px;
height: 14px;
background: var(--g-color-80);
margin: 0 4px;
}
.panel-content {
flex: 1;
overflow: auto;
height: 0;
}
</style>
+8
View File
@@ -65,6 +65,14 @@ export default {
subTitle: getKeysLabel('layout.toggle-xterm'),
onClick: () => ctx.layout.toggleXterm()
}] : []),
...(ctx.workbench.ContentRightSide.getAllPanels().length > 0 ? [{
id: 'toggle-content-right-side',
type: 'normal' as any,
checked: ctx.store.state.showContentRightSide,
title: ctx.i18n.t('status-bar.view.content-right-side'),
subTitle: getKeysLabel('layout.toggle-content-right-side'),
onClick: () => ctx.layout.toggleContentRightSide()
}] : []),
{
id: 'toggle-editor-preview-exclusive',
type: 'normal',
-7
View File
@@ -146,10 +146,3 @@ registerAction({
keys: [Alt, 't']
})
registerAction({
name: 'layout.toggle-content-right-side',
description: t('command-desc.layout_toggle-content-right-side'),
handler: toggleContentRightSide,
forUser: true,
keys: [Alt, 'b']
})
+111 -2
View File
@@ -1,9 +1,10 @@
import { debounce } from 'lodash-es'
import { debounce, orderBy } from 'lodash-es'
import * as ioc from '@fe/core/ioc'
import { triggerHook } from '@fe/core/hook'
import { getActionHandler, registerAction } from '@fe/core/action'
import { Alt, Shift } from '@fe/core/keybinding'
import store from '@fe/support/store'
import type { Components } from '@fe/types'
import type { Components, RightSidePanel } from '@fe/types'
import { t } from './i18n'
/**
@@ -136,3 +137,111 @@ export const ControlCenter = {
getActionHandler('control-center.toggle')(visible)
},
}
export const ContentRightSide = {
/**
* Register a right side panel.
* @param panel Panel
* @param override Override the existing panel
*/
registerPanel (panel: RightSidePanel, override = false) {
if (!panel.component) {
throw new Error('Panel component is required')
}
// check if the panel is already registered
if (ioc.get('RIGHT_SIDE_PANEL').some(item => item.name === panel.name)) {
if (override) {
ContentRightSide.removePanel(panel.name)
} else {
throw new Error(`Panel ${panel.name} is already registered`)
}
}
ioc.register('RIGHT_SIDE_PANEL', panel)
triggerHook('RIGHT_SIDE_PANEL_CHANGE', { type: 'register' })
},
/**
* Remove a right side panel.
* @param name Panel name
*/
removePanel (name: string) {
ioc.removeWhen('RIGHT_SIDE_PANEL', item => item.name === name)
// if the current panel is removed, switch to another one or hide
if (store.state.currentRightSidePanel === name) {
const panels = ContentRightSide.getAllPanels()
if (panels.length > 0) {
ContentRightSide.switchPanel(panels[0].name)
} else {
store.state.currentRightSidePanel = null
}
}
triggerHook('RIGHT_SIDE_PANEL_CHANGE', { type: 'remove' })
},
/**
* Get all registered panels.
* @returns Panels
*/
getAllPanels (): RightSidePanel[] {
return [...orderBy(ioc.get('RIGHT_SIDE_PANEL'), x => x.order ?? 256, 'asc')]
},
/**
* Switch to a panel by name.
* @param name Panel name
*/
switchPanel (name: string) {
const panel = ContentRightSide.getAllPanels().find(p => p.name === name)
if (!panel) {
throw new Error(`Panel ${name} not found`)
}
store.state.currentRightSidePanel = name
triggerHook('RIGHT_SIDE_PANEL_CHANGE', { type: 'switch' })
getActionHandler('layout.toggle-content-right-side')(true)
},
/**
* Show right side panel with a specific panel.
* @param name Panel name, if not provided, show the current or first panel
*/
show (name?: string) {
const panels = ContentRightSide.getAllPanels()
if (panels.length === 0) {
return
}
if (name) {
ContentRightSide.switchPanel(name)
} else {
const currentPanel = store.state.currentRightSidePanel
if (!currentPanel || !panels.find(p => p.name === currentPanel)) {
store.state.currentRightSidePanel = panels[0].name
}
getActionHandler('layout.toggle-content-right-side')(true)
}
},
/**
* Hide right side panel.
*/
hide () {
getActionHandler('layout.toggle-content-right-side')(false)
},
/**
* Toggle right side panel visibility.
* @param visible
*/
toggle (visible?: boolean) {
// Do nothing if no panels registered
if (ContentRightSide.getAllPanels().length === 0) {
return
}
getActionHandler('layout.toggle-content-right-side')(visible)
},
}
+22 -5
View File
@@ -1,6 +1,7 @@
import { Fragment, h } from 'vue'
import { init } from '@fe/core/plugin'
import { getActionHandler } from '@fe/core/action'
import { Alt } from '@fe/core/keybinding'
import { getActionHandler, registerAction } from '@fe/core/action'
import { registerHook, triggerHook } from '@fe/core/hook'
import store from '@fe/support/store'
import { isElectron, isWindows } from '@fe/support/env'
@@ -26,7 +27,7 @@ import ctx from '@fe/context'
import ga from '@fe/support/ga'
import * as jsonrpc from '@fe/support/jsonrpc'
import { getLogger, sleep } from '@fe/utils'
import { removeOldDatabases } from './others/db'
import { removeOldDatabases } from '@fe/others/db'
const logger = getLogger('startup')
@@ -83,6 +84,7 @@ registerHook('DOC_MOVED', refreshTree)
registerHook('DOC_SWITCH_FAILED', refreshTree)
registerHook('DOC_BEFORE_DELETE', reWatchFsOnWindows)
registerHook('DOC_BEFORE_MOVE', reWatchFsOnWindows)
registerHook('RIGHT_SIDE_PANEL_CHANGE', ctx.statusBar.refreshMenu)
registerHook('INDEXER_FS_CHANGE', async () => {
if (Date.now() - autoRefreshedAt > 3000) {
@@ -276,21 +278,36 @@ whenEditorReady().then(() => {
})
// json-rpc
jsonrpc.init({ ctx }, whenEditorReady())
setTimeout(() => {
removeOldDatabases()
}, 20000)
// google analytics
registerHook('DOC_SWITCHED', () => {
setTimeout(() => {
ga.logEvent('yn_doc_switched')
}, 0)
})
registerHook('RIGHT_SIDE_PANEL_CHANGE', ({ type }) => {
if (type === 'remove' && ctx.workbench.ContentRightSide.getAllPanels().length < 1) {
ctx.layout.toggleContentRightSide(false)
}
})
registerAction({
name: 'layout.toggle-content-right-side',
description: t('command-desc.layout_toggle-content-right-side'),
handler: ctx.layout.toggleContentRightSide,
forUser: true,
when() {
return ctx.workbench.ContentRightSide.getAllPanels().length > 0
},
keys: [Alt, 'b']
})
// google analytics
ga.logEvent('page_view', {
page_title: '--STARTUP--',
page_location: window.location.href,
+1
View File
@@ -23,6 +23,7 @@ export const initState = {
showView: storage.get('showView', true),
showEditor: storage.get('showEditor', true),
showContentRightSide: false,
currentRightSidePanel: null as string | null,
editorPreviewExclusive: storage.get('editorPreviewExclusive', false),
showXterm: false,
showOutline: false,
+25
View File
@@ -202,6 +202,19 @@ export namespace Components {
}
}
export namespace RightSidePanel {
export type ActionBtn = {
type: 'normal',
key?: string | number,
icon: string,
title: string,
order?: number,
hidden?: boolean,
onClick: (e: MouseEvent) => void,
}
| { type: 'separator', order?: number, hidden?: boolean }
}
export namespace FixedFloat {
export interface Props {
disableAutoFocus?: boolean;
@@ -471,6 +484,7 @@ export type BuildInActions = {
'layout.toggle-side': (visible?: boolean) => void,
'layout.toggle-xterm': (visible?: boolean) => void,
'layout.toggle-editor': (visible?: boolean) => void,
'layout.toggle-content-right-side': (visible?: boolean) => void,
'control-center.toggle': (visible?: boolean) => void,
'status-bar.refresh-menu': () => void,
'control-center.refresh': () => void,
@@ -567,6 +581,7 @@ export type BuildInHookTypes = {
EDITOR_READY: { editor: Monaco.editor.IStandaloneCodeEditor, monaco: typeof Monaco },
EDITOR_CUSTOM_EDITOR_CHANGE: { type: 'register' | 'remove' | 'switch' },
EDITOR_CURRENT_EDITOR_CHANGE: { current?: CustomEditor | null },
RIGHT_SIDE_PANEL_CHANGE: { type: 'register' | 'remove' | 'switch' },
EDITOR_CONTENT_CHANGE: { uri: string, value: string },
EDITOR_ATTEMPT_READONLY_EDIT: { doc: Doc | null, readonlyType: 'app-readonly' | 'no-file' | 'file-not-writable' | 'unsupported-file-type' },
DOC_CREATED: { doc: Doc },
@@ -625,6 +640,15 @@ export type CustomEditor = {
getIsDirty?: () => boolean | Promise<boolean>,
}
export type RightSidePanel = {
name: string,
displayName: string,
order?: number,
keepAlive?: boolean,
component: any,
actionBtns?: Components.RightSidePanel.ActionBtn[],
}
export type Renderer = {
name: string,
order?: number,
@@ -684,6 +708,7 @@ export type BuildInIOCTypes = { [key in keyof BuildInHookTypes]: any; } & {
THEME_STYLES: any;
VIEW_PREVIEWER: Previewer;
EDITOR_CUSTOM_EDITOR: CustomEditor,
RIGHT_SIDE_PANEL: RightSidePanel,
RENDERERS: Renderer,
CODE_RUNNER: CodeRunner;
DOC_CATEGORIES: DocCategory;
+1
View File
@@ -241,6 +241,7 @@ const data = {
'preview': 'Show Preview',
'editor': 'Show Editor',
'side-bar': 'Show Side Bar',
'content-right-side': 'Show Right Side Panel',
'word-wrap': 'Word Wrap',
'typewriter-mode': 'Typewriter Mode',
'editor-preview-exclusive': 'Editor/Preview Exclusive',
+1
View File
@@ -242,6 +242,7 @@ const data: BaseLanguage = {
'preview': 'Предварительный просмотр',
'editor': 'Показать редактор',
'side-bar': 'Показать боковую панель',
'content-right-side': 'Показать правую панель',
'word-wrap': 'Перенос снов',
'typewriter-mode': 'Режим печатной машинки',
'editor-preview-exclusive': 'Только режим редактора/просмотра',
+1 -2
View File
@@ -241,8 +241,7 @@ const data: BaseLanguage = {
'xterm': '显示终端',
'preview': '显示预览',
'editor': '显示编辑',
'side-bar': '显示侧栏',
'word-wrap': '文本换行',
'side-bar': '显示侧栏', 'content-right-side': '显示右侧面板', 'word-wrap': '文本换行',
'typewriter-mode': '打字机模式',
'editor-preview-exclusive': '编辑器/预览互斥',
},
+1 -2
View File
@@ -241,8 +241,7 @@ const data: BaseLanguage = {
'xterm': '顯示終端',
'preview': '顯示預覽',
'editor': '顯示編輯',
'side-bar': '顯示側欄',
'word-wrap': '文本換行',
'side-bar': '顯示側欄', 'content-right-side': '顯示右側面板', 'word-wrap': '文本換行',
'typewriter-mode': '打字機模式',
'editor-preview-exclusive': '編輯器/預覽互斥',
},