mirror of
https://github.com/purocean/yn.git
synced 2026-10-16 07:26:07 +08:00
refactor: add internal titlebar support
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user