refactor: add internal titlebar support

This commit is contained in:
purocean
2026-09-19 12:38:11 +08:00
parent d9ef2d1eaf
commit e568f7d913
6 changed files with 256 additions and 2 deletions
+8 -2
View File
@@ -32,8 +32,8 @@
</div>
<template v-for="(btn, i) in [...actionBtns].filter(x => !x.hidden).sort((a: any, b: any) => ((a.order || 0) - (b.order || 0)))">
<div v-if="btn.type === 'separator'" class="action-btn-separator" :key="i" />
<div v-else-if="btn.type === 'normal'" :key="btn.key || `${i}`" class="action-btn" @click="btn.onClick" :title="btn.title" :style="btn.style">
<svg-icon :name="btn.icon" width="12px" />
<div v-else-if="btn.type === 'normal'" :key="btn.key || `${i}`" :class="{'action-btn': true, checked: btn.checked}" @click="btn.onClick" :title="btn.title" :style="btn.style">
<svg-icon :name="btn.icon" :width="btn.iconWidth || '12px'" />
</div>
<component v-else-if="btn.type === 'custom'" :key="btn.key || `custom-${i}`" :is="btn.component" />
</template>
@@ -305,6 +305,12 @@ export default defineComponent({
background-color: var(--g-color-75);
border-radius: 50%;
}
&.checked {
color: var(--g-color-0);
background-color: var(--g-color-75);
border-radius: 50%;
}
}
}
@@ -348,6 +348,27 @@ describe('Tabs', () => {
expect(changedList[0]).toMatchObject({ key: 'b', fixed: false, temporary: false })
})
test('renders checked action buttons with a custom icon width', async () => {
const actionClick = vi.fn()
const wrapper = mountTabs({
actionBtns: [{
type: 'normal',
icon: 'thumbtack-solid',
iconWidth: '9px',
title: 'Pin',
checked: true,
onClick: actionClick,
}],
})
const pin = wrapper.find('[title="Pin"]')
expect(pin.classes()).toContain('checked')
expect(pin.findComponent(SvgIcon).props('width')).toBe('9px')
await pin.trigger('click')
expect(actionClick).toHaveBeenCalled()
})
test('shows context menu, quick filter, action buttons, and cleans up hooks', async () => {
const actionClick = vi.fn()
const hookContextMenu = vi.fn((item, items) => {
@@ -0,0 +1,108 @@
import { defineComponent, h, nextTick, reactive, ref } from 'vue'
import { mount } from '@vue/test-utils'
const mocks = vi.hoisted(() => ({
storeState: undefined as any,
isSaved: undefined as any,
win: undefined as any,
listeners: new Map<string, Function>(),
isElectron: true,
isMacOS: true,
}))
vi.mock('@fe/support/store', () => ({
default: {
get state () { return mocks.storeState },
getters: {
get isSaved () { return mocks.isSaved },
},
},
}))
vi.mock('@fe/support/env', () => ({
get isElectron () { return mocks.isElectron },
get isMacOS () { return mocks.isMacOS },
get nodeRequire () { return mocks.isElectron },
getElectronRemote: () => ({
getCurrentWindow: () => mocks.win,
}),
}))
vi.mock('@fe/services/i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
import { isWindowAlwaysOnTop, toggleWindowAlwaysOnTop, useWindowState } from '../window-state'
const TestComponent = defineComponent({
setup () {
useWindowState()
return () => h('div')
},
})
beforeEach(() => {
mocks.storeState = reactive({
currentFile: { repo: 'repo', path: '/docs/a.md', name: 'a.md', status: 'saved' },
isFullscreen: false,
})
mocks.isSaved = ref(false)
mocks.listeners.clear()
mocks.isElectron = true
mocks.isMacOS = true
mocks.win = {
isAlwaysOnTop: vi.fn(() => false),
setAlwaysOnTop: vi.fn(),
setDocumentEdited: vi.fn(),
on: vi.fn((name: string, handler: Function) => mocks.listeners.set(name, handler)),
removeListener: vi.fn((name: string) => mocks.listeners.delete(name)),
}
isWindowAlwaysOnTop.value = false
window.onbeforeunload = null
})
describe('window state', () => {
test('tracks document and Electron window state without rendering a title bar', async () => {
const wrapper = mount(TestComponent)
expect(document.title).toBe('a.md')
expect(window.documentSaved).toBe(false)
expect(mocks.win.setDocumentEdited).toHaveBeenCalledWith(true)
toggleWindowAlwaysOnTop()
expect(mocks.win.setAlwaysOnTop).toHaveBeenCalledWith(true)
mocks.win.isAlwaysOnTop.mockReturnValue(true)
mocks.listeners.get('always-on-top-changed')?.()
expect(isWindowAlwaysOnTop.value).toBe(true)
mocks.listeners.get('maximize')?.()
expect(mocks.win.setAlwaysOnTop).toHaveBeenLastCalledWith(false)
mocks.listeners.get('enter-full-screen')?.()
expect(mocks.storeState.isFullscreen).toBe(true)
mocks.listeners.get('leave-full-screen')?.()
expect(mocks.storeState.isFullscreen).toBe(false)
mocks.isSaved.value = true
await nextTick()
expect(window.documentSaved).toBe(true)
expect(mocks.win.setDocumentEdited).toHaveBeenLastCalledWith(false)
wrapper.unmount()
expect(mocks.win.removeListener).toHaveBeenCalledWith('maximize', expect.any(Function))
expect(mocks.win.removeListener).toHaveBeenCalledWith('leave-full-screen', expect.any(Function))
})
test('keeps the browser unsaved-document prompt', () => {
mocks.isElectron = false
const wrapper = mount(TestComponent)
expect(window.onbeforeunload?.({} as BeforeUnloadEvent)).toBe(true)
mocks.isSaved.value = true
expect(window.onbeforeunload?.({} as BeforeUnloadEvent)).toBeNull()
wrapper.unmount()
expect(window.onbeforeunload).toBeNull()
})
})
+22
View File
@@ -0,0 +1,22 @@
import { shallowRef } from 'vue'
export const titleBarTabsContainer = shallowRef<HTMLElement | null>(null)
let tabsLeft = 0
function applyTabsLeft () {
titleBarTabsContainer.value?.style.setProperty('--tabs-left', `${tabsLeft}px`)
}
export function setTitleBarTabsContainer (container: HTMLElement | null) {
titleBarTabsContainer.value = container
applyTabsLeft()
}
export function setTitleBarTabsLeft (left: number) {
if (!Number.isFinite(left)) {
return
}
tabsLeft = Math.max(0, left)
applyTabsLeft()
}
+95
View File
@@ -0,0 +1,95 @@
import { computed, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue'
import { getElectronRemote, isElectron, isMacOS, nodeRequire } from '@fe/support/env'
import store from '@fe/support/store'
import { useI18n } from '@fe/services/i18n'
let win: ReturnType<ReturnType<typeof getElectronRemote>['getCurrentWindow']> | null = null
export const isWindowAlwaysOnTop = ref(false)
function updateAlwaysOnTop () {
isWindowAlwaysOnTop.value = !!win?.isAlwaysOnTop()
}
export function toggleWindowAlwaysOnTop () {
if (!win) {
return
}
win.setAlwaysOnTop(!win.isAlwaysOnTop())
updateAlwaysOnTop()
}
export function useWindowState () {
const { t } = useI18n()
const { currentFile } = toRefs(store.state)
const isSaved = store.getters.isSaved
function handleFullscreenEnter () {
store.state.isFullscreen = true
}
function handleFullscreenLeave () {
store.state.isFullscreen = false
}
function handleMaximize () {
if (win?.isAlwaysOnTop()) {
win.setAlwaysOnTop(false)
}
updateAlwaysOnTop()
}
function clean () {
if (!isElectron) {
window.onbeforeunload = null
}
window.removeEventListener('beforeunload', clean)
if (win) {
win.removeListener('maximize', handleMaximize)
win.removeListener('always-on-top-changed', updateAlwaysOnTop)
win.removeListener('enter-full-screen', handleFullscreenEnter)
win.removeListener('leave-full-screen', handleFullscreenLeave)
}
win = null
isWindowAlwaysOnTop.value = false
}
onMounted(() => {
if (!isElectron) {
window.onbeforeunload = () => !isSaved.value || null
return
}
if (nodeRequire) {
win = getElectronRemote().getCurrentWindow()
updateAlwaysOnTop()
win.on('maximize', handleMaximize)
win.on('always-on-top-changed', updateAlwaysOnTop)
win.on('enter-full-screen', handleFullscreenEnter)
win.on('leave-full-screen', handleFullscreenLeave)
window.addEventListener('beforeunload', clean)
if (isMacOS) {
win.setDocumentEdited(!isSaved.value)
}
}
})
onBeforeUnmount(clean)
const documentTitle = computed(() => currentFile.value ? (currentFile.value.name || 'Yank Note') : t('file-status.no-file'))
watch(documentTitle, title => {
document.title = title
}, { immediate: true })
watch(isSaved, (val: boolean) => {
window.documentSaved = val
if (win && isMacOS) {
win.setDocumentEdited(!val)
}
}, { immediate: true })
}
+2
View File
@@ -179,9 +179,11 @@ export namespace Components {
type: 'normal',
key?: string | number,
icon: string,
iconWidth?: string,
title: string,
order?: number,
hidden?: boolean,
checked?: boolean,
style?: string,
onClick: (e: MouseEvent) => void,
}