diff --git a/app/components/Canvas.vue b/app/components/Canvas.vue index 396187e..8a3a37a 100644 --- a/app/components/Canvas.vue +++ b/app/components/Canvas.vue @@ -131,7 +131,7 @@ const props = defineProps({ themeType: { type: String, default: 'dark' }, }); -const emit = defineEmits(['update:width', 'update:height']); +const emit = defineEmits(['update:width', 'update:height', 'resize-start', 'resize-end']); const x = ref(null); const y = ref(null); @@ -155,6 +155,10 @@ const { zoom, aspectRatio } = toRefs(props); let resizeObserver = null; let observedSceneWindow = null; let sceneMetricsRequest = null; +let resizeRequest = null; +let pendingResize = null; +let resizeScaleX = 1; +let resizeScaleY = 1; const ratio = computed(() => { if (aspectRatio.value) { @@ -182,22 +186,55 @@ function onResizeStart(event) { resizingWidth.value = hasWidth || !!ratio.value; resizingHeight.value = hasHeight || !!ratio.value; + + const container = event.target.parentNode; + const containerRect = container.getBoundingClientRect(); + + resizeScaleX = containerRect.width / container.offsetWidth || 1; + resizeScaleY = containerRect.height / container.offsetHeight || 1; + + emit('resize-start'); } function onResizeEnd() { + flushResize(); + resizingWidth.value = false; resizingHeight.value = false; + + emit('resize-end'); } function onResize(event) { - const container = event.target.parentNode; - if (event.rect.width) { - const scaleX = container.getBoundingClientRect().width / container.offsetWidth; - emit('update:width', event.rect.width / scaleX); + pendingResize = { + width: event.rect.width, + height: event.rect.height, + }; + + if (!resizeRequest) { + resizeRequest = requestAnimationFrame(flushResize); + } +} + +function flushResize() { + if (resizeRequest) { + cancelAnimationFrame(resizeRequest); + resizeRequest = null; + } + + if (!pendingResize) { + return; + } + + const { width, height } = pendingResize; + pendingResize = null; + + if (width) { + emit('update:width', width / resizeScaleX); } - if (event.rect.height) { - const scaleY = container.getBoundingClientRect().height / container.offsetHeight; - emit('update:height', event.rect.height / scaleY); + + if (height) { + emit('update:height', height / resizeScaleY); } } @@ -271,6 +308,10 @@ onBeforeUnmount(() => { cancelAnimationFrame(sceneMetricsRequest); } + if (resizeRequest) { + cancelAnimationFrame(resizeRequest); + } + resizeObserver?.disconnect(); }); diff --git a/app/components/Editor.vue b/app/components/Editor.vue index b6a10d8..152c70d 100644 --- a/app/components/Editor.vue +++ b/app/components/Editor.vue @@ -147,8 +147,6 @@ { - updateMonacoDimensions(); - watch([sizes, orientation], updateMonacoDimensions); - $bus.$on('editors:refresh', updateMonacoDimensions); -}); - onUnmounted(() => $bus.$emit('editors:refresh')); diff --git a/app/components/Monaco.vue b/app/components/Monaco.vue index 8b38919..16593c0 100644 --- a/app/components/Monaco.vue +++ b/app/components/Monaco.vue @@ -1,5 +1,5 @@ diff --git a/app/components/TabBackgrounds.vue b/app/components/TabBackgrounds.vue index fe29ac1..b7edaae 100644 --- a/app/components/TabBackgrounds.vue +++ b/app/components/TabBackgrounds.vue @@ -52,7 +52,8 @@ :data-ref="`button-background-${id}`" :active="background === id && !backgroundColor" @delete="$emit('delete', id)" - @click="$emit('select', id)" + @mousedown.prevent + @click="selectBackground($event, id)" /> @@ -62,7 +63,7 @@ diff --git a/app/components/TabScenes.vue b/app/components/TabScenes.vue index e1deda8..30e0f41 100644 --- a/app/components/TabScenes.vue +++ b/app/components/TabScenes.vue @@ -4,6 +4,7 @@ ', + }, + ColorPicker: passthroughStub, + ScrollArea: passthroughStub, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + expect(scrollRefIntoView).toHaveBeenCalledWith('button-background-background-20'); + + await wrapper.setProps({ background: 'background-21' }); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/TabScenes.test.js b/tests/components/TabScenes.test.js new file mode 100644 index 0000000..1680e68 --- /dev/null +++ b/tests/components/TabScenes.test.js @@ -0,0 +1,53 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import TabScenes from '~/components/TabScenes.vue'; + +const { scrollRefIntoView } = vi.hoisted(() => ({ + scrollRefIntoView: vi.fn(), +})); + +vi.mock('~/composables/useScrollRefIntoView', () => ({ + default: () => ({ scrollRefIntoView }), +})); + +describe('TabScenes', () => { + afterEach(() => { + scrollRefIntoView.mockReset(); + }); + + it('positions the selected scene only when the tab mounts', async () => { + const wrapper = mount(TabScenes, { + props: { + activeScene: 'scene-2', + scenes: [ + { + id: 'scene-1', + title: 'Scene 1', + preview: { accent: '#fff', background: '#000', window: '#111' }, + }, + { + id: 'scene-2', + title: 'Scene 2', + preview: { accent: '#fff', background: '#000', window: '#111' }, + }, + ], + }, + global: { + stubs: { + ScrollArea: { + template: '
', + }, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + expect(scrollRefIntoView).toHaveBeenCalledWith('button-scene-scene-2'); + + await wrapper.setProps({ activeScene: 'scene-1' }); + + expect(scrollRefIntoView).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/components/TabThemes.test.js b/tests/components/TabThemes.test.js new file mode 100644 index 0000000..5625fce --- /dev/null +++ b/tests/components/TabThemes.test.js @@ -0,0 +1,65 @@ +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import TabThemes from '~/components/TabThemes.vue'; + +const ButtonThemeStub = { + name: 'ButtonTheme', + props: ['theme'], + template: '', +}; + +describe('TabThemes', () => { + it('only mounts theme cards near the visible viewport', async () => { + const themes = Array.from({ length: 80 }, (_, index) => `theme-${index}`); + + const wrapper = mount(TabThemes, { + props: { + code: [], + theme: 'theme-40', + themes, + settings: {}, + background: {}, + languages: [], + }, + global: { + stubs: { + ButtonTheme: ButtonThemeStub, + }, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const renderedThemes = wrapper.findAll('.theme-card').map((card) => card.text()); + + expect(renderedThemes).toContain('theme-40'); + expect(renderedThemes.length).toBeGreaterThan(0); + expect(renderedThemes.length).toBeLessThan(12); + }); + + it('does not reposition the viewport when a theme is selected', async () => { + const themes = Array.from({ length: 80 }, (_, index) => `theme-${index}`); + const wrapper = mount(TabThemes, { + props: { + code: [], + theme: 'theme-40', + themes, + settings: {}, + background: {}, + languages: [], + }, + global: { + stubs: { + ButtonTheme: ButtonThemeStub, + }, + }, + }); + + await wrapper.vm.$nextTick(); + wrapper.element.scrollLeft = 240; + + await wrapper.setProps({ theme: 'theme-42' }); + + expect(wrapper.element.scrollLeft).toBe(240); + }); +});