diff --git a/src/renderer/components/Tabs.vue b/src/renderer/components/Tabs.vue index 2eeab04c..76bead06 100644 --- a/src/renderer/components/Tabs.vue +++ b/src/renderer/components/Tabs.vue @@ -32,8 +32,8 @@ @@ -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%; + } } } diff --git a/src/renderer/components/__tests__/basic-components.ts b/src/renderer/components/__tests__/basic-components.ts index a271f550..e08783c8 100644 --- a/src/renderer/components/__tests__/basic-components.ts +++ b/src/renderer/components/__tests__/basic-components.ts @@ -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) => { diff --git a/src/renderer/support/__tests__/window-state.ts b/src/renderer/support/__tests__/window-state.ts new file mode 100644 index 00000000..dbcaab42 --- /dev/null +++ b/src/renderer/support/__tests__/window-state.ts @@ -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(), + 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() + }) +}) diff --git a/src/renderer/support/title-bar.ts b/src/renderer/support/title-bar.ts new file mode 100644 index 00000000..8f4a21d3 --- /dev/null +++ b/src/renderer/support/title-bar.ts @@ -0,0 +1,22 @@ +import { shallowRef } from 'vue' + +export const titleBarTabsContainer = shallowRef(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() +} diff --git a/src/renderer/support/window-state.ts b/src/renderer/support/window-state.ts new file mode 100644 index 00000000..4d1cbdf7 --- /dev/null +++ b/src/renderer/support/window-state.ts @@ -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['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 }) +} diff --git a/src/renderer/types.ts b/src/renderer/types.ts index 8aa9e647..5bd4d59c 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -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, }