From 38ec8116dfa4140de1fe17ea88c7abe7fdfc404a Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sat, 11 Jul 2026 00:12:22 -0400 Subject: [PATCH 1/2] Improve editor performance and responsiveness --- app/components/Canvas.vue | 57 +++++++-- app/components/Editor.vue | 22 +--- app/components/Monaco.vue | 86 +++++++------ app/components/Preview.vue | 95 +++++++++++++-- app/components/TabBackgrounds.vue | 14 ++- app/components/TabThemes.vue | 155 +++++++++++++++++++++--- app/composables/useMonaco.js | 90 ++++++++++++++ app/composables/useShiki.js | 74 +++-------- app/plugins/queue.js | 12 -- app/plugins/shiki.client.js | 148 ++++++---------------- app/workers/shiki.worker.js | 94 ++++++++++++++ nuxt.config.ts | 46 ++++++- package-lock.json | 8 -- package.json | 1 - tests/components/TabBackgrounds.test.js | 57 +++++++++ tests/components/TabThemes.test.js | 39 ++++++ 16 files changed, 706 insertions(+), 292 deletions(-) create mode 100644 app/composables/useMonaco.js delete mode 100644 app/plugins/queue.js create mode 100644 app/workers/shiki.worker.js create mode 100644 tests/components/TabBackgrounds.test.js create mode 100644 tests/components/TabThemes.test.js diff --git a/app/components/Canvas.vue b/app/components/Canvas.vue index 396187e5..8a3a37ac 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 b6a10d88..152c70da 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 8b389195..16593c02 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 fe29ac13..f1591bd8 100644 --- a/app/components/TabBackgrounds.vue +++ b/app/components/TabBackgrounds.vue @@ -78,12 +78,14 @@ const { background, backgrounds } = toRefs(props); const { scrollRefIntoView } = useScrollRefIntoView(); onMounted(() => { - setTimeout(() => { - scrollRefIntoView(`button-background-${background.value}`); - }, 100); -}); - -watch(backgrounds, () => { scrollRefIntoView(`button-background-${background.value}`); }); + +watch( + backgrounds, + () => { + scrollRefIntoView(`button-background-${background.value}`); + }, + { flush: 'post' } +); diff --git a/app/components/TabThemes.vue b/app/components/TabThemes.vue index d887d351..919f2e73 100644 --- a/app/components/TabThemes.vue +++ b/app/components/TabThemes.vue @@ -1,25 +1,35 @@ diff --git a/app/composables/useMonaco.js b/app/composables/useMonaco.js new file mode 100644 index 00000000..36a71917 --- /dev/null +++ b/app/composables/useMonaco.js @@ -0,0 +1,90 @@ +import themeList from '@/data/monaco-themes/themelist.json'; + +const builtInThemes = new Set(['vs', 'vs-light', 'vs-dark', 'hc-black']); +const loadedThemes = new Set(builtInThemes); + +const themeLoaders = import.meta.glob( + ['../data/monaco-themes/*.json', '!../data/monaco-themes/themelist.json'], + { import: 'default' } +); + +const languageLoaders = import.meta.glob( + '../../node_modules/monaco-editor/esm/vs/basic-languages/*/*.contribution.js' +); + +let monacoPromise = null; + +function loadMonaco() { + if (monacoPromise) { + return monacoPromise; + } + + self.MonacoEnvironment = { + getWorker: () => + new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker.js', import.meta.url), { + type: 'module', + }), + }; + + monacoPromise = import('monaco-editor/esm/vs/editor/editor.api.js').then(async (monaco) => { + await Promise.all([ + import('monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching.js'), + import('monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js'), + import('monaco-editor/esm/vs/editor/contrib/comment/browser/comment.js'), + import('monaco-editor/esm/vs/editor/contrib/contextmenu/browser/contextmenu.js'), + import('monaco-editor/esm/vs/editor/contrib/find/browser/findController.js'), + import('monaco-editor/esm/vs/editor/contrib/hover/browser/hoverContribution.js'), + import('monaco-editor/esm/vs/editor/contrib/indentation/browser/indentation.js'), + import('monaco-editor/esm/vs/editor/contrib/linesOperations/browser/linesOperations.js'), + import('monaco-editor/esm/vs/editor/contrib/multicursor/browser/multicursor.js'), + import('monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations.js'), + ]); + + return monaco; + }); + + return monacoPromise; +} + +async function loadMonacoLanguage(monaco, language) { + if (monaco.languages.getLanguages().some(({ id }) => id === language)) { + return; + } + + const suffix = `/basic-languages/${language}/${language}.contribution.js`; + const loader = Object.entries(languageLoaders).find(([path]) => path.endsWith(suffix))?.[1]; + + await loader?.(); +} + +async function loadMonacoTheme(monaco, theme) { + if (loadedThemes.has(theme)) { + return; + } + + const filename = themeList[theme]; + const loader = themeLoaders[`../data/monaco-themes/${filename}.json`]; + + if (!loader) { + return; + } + + monaco.editor.defineTheme(theme, await loader()); + loadedThemes.add(theme); +} + +export default function () { + async function prepare({ language, theme }) { + const monaco = await loadMonaco(); + + await Promise.all([loadMonacoLanguage(monaco, language), loadMonacoTheme(monaco, theme)]); + + return monaco; + } + + return { + prepare, + loadLanguage: async (language) => loadMonacoLanguage(await loadMonaco(), language), + loadTheme: async (theme) => loadMonacoTheme(await loadMonaco(), theme), + }; +} diff --git a/app/composables/useShiki.js b/app/composables/useShiki.js index 9e666a88..ee368fee 100644 --- a/app/composables/useShiki.js +++ b/app/composables/useShiki.js @@ -1,16 +1,8 @@ import hexAlpha from 'hex-alpha'; import { defaults } from 'lodash'; -function yieldToMain() { - if (typeof requestIdleCallback === 'function') { - return new Promise((resolve) => requestIdleCallback(() => resolve(), { timeout: 50 })); - } - - return new Promise((resolve) => setTimeout(resolve, 0)); -} - export default function () { - const { $queue, $shiki } = useNuxtApp(); + const { $shiki } = useNuxtApp(); const { highlightLanguage } = useLanguages(); const themeTypeOverrides = { @@ -29,52 +21,24 @@ export default function () { theme: 'github-dark', }); - return new Promise((resolve, reject) => { - $queue.push(async () => { - try { - await yieldToMain(); - - await $shiki.loadLanguages( - languages.map((lang) => highlightLanguage(lang.name)) - ); - - await yieldToMain(); - - await $shiki.loadTheme(theme); - - const blocks = []; - - for (const block of code) { - await yieldToMain(); - - blocks.push({ - added: block.added, - removed: block.removed, - focused: block.focused, - lines: await $shiki.tokens( - limit - ? block.value?.split('\n').slice(0, limit).join('\n') - : block.value, - findEditorLanguageById(languages, block.id), - theme - ), - }); - } - - const { name, fg, bg, type } = $shiki.getTheme(theme); - - callback({ - blocks: blocks, - themeType: - themeTypeOverrides[name] ?? (name.includes('light') ? 'light' : type), - themeForeground: hexAlpha(fg, parseFloat(opacity)), - themeBackground: hexAlpha(bg, parseFloat(opacity)), - }); - - resolve(); - } catch (e) { - reject(e); - } + const sourceBlocks = code.map((block) => ({ + value: limit ? block.value?.split('\n').slice(0, limit).join('\n') : block.value, + language: findEditorLanguageById(languages, block.id), + })); + + return $shiki.tokenizeBlocks({ blocks: sourceBlocks, theme }).then((result) => { + const { name, fg, bg, type } = result.theme; + + callback({ + blocks: code.map((block, index) => ({ + added: block.added, + removed: block.removed, + focused: block.focused, + lines: result.blocks[index], + })), + themeType: themeTypeOverrides[name] ?? (name.includes('light') ? 'light' : type), + themeForeground: hexAlpha(fg, parseFloat(opacity)), + themeBackground: hexAlpha(bg, parseFloat(opacity)), }); }); } diff --git a/app/plugins/queue.js b/app/plugins/queue.js deleted file mode 100644 index 33a28b87..00000000 --- a/app/plugins/queue.js +++ /dev/null @@ -1,12 +0,0 @@ -import Queue from 'queue'; - -export default defineNuxtPlugin(() => { - return { - provide: { - queue: new Queue({ - autostart: true, - concurrency: 2, - }), - }, - }; -}); diff --git a/app/plugins/shiki.client.js b/app/plugins/shiki.client.js index b56d9b13..8280a449 100644 --- a/app/plugins/shiki.client.js +++ b/app/plugins/shiki.client.js @@ -1,130 +1,58 @@ import { ref } from 'vue'; -import collect from 'collect.js'; export default defineNuxtPlugin(() => { - let highlighter = null; - const allLanguageIds = ref([]); - const allThemeIds = ref([]); - let readyResolve; - - const ready = new Promise((resolve) => { - readyResolve = resolve; + const worker = new Worker(new URL('../workers/shiki.worker.js', import.meta.url), { + type: 'module', }); - // Defer shiki loading until after the app is mounted - if (import.meta.client) { - setTimeout(async () => { - try { - const { createHighlighter, bundledLanguagesInfo, bundledThemesInfo } = - await import('shiki'); + const pending = new Map(); + const allLanguageIds = ref([]); + const allThemeIds = ref([]); + let requestId = 0; - highlighter = await createHighlighter({ - themes: ['github-light'], - langs: ['html', 'xml', 'sql', 'javascript', 'json', 'css', 'php'], - }); + worker.addEventListener('message', ({ data: { id, result, error } }) => { + const request = pending.get(id); - allLanguageIds.value = bundledLanguagesInfo - .map((lang) => lang.id) - .filter((id) => !['php-html', 'html-derivative'].includes(id)); + if (!request) { + return; + } - const bundledIds = collect(bundledThemesInfo.map((t) => t.id)) - .filter((theme) => !['css-variables'].some((t) => theme.includes(t))) - .sort() - .toArray(); + pending.delete(id); - // Load custom theme IDs from the static manifest - let customIds = []; + if (error) { + request.reject(new Error(error)); + return; + } - try { - const manifest = await fetch('/shiki/themes/all.json').then((r) => r.json()); - customIds = manifest.filter((id) => !bundledIds.includes(id)).sort(); - } catch { - // No custom themes manifest found - } + request.resolve(result); + }); - allThemeIds.value = [...bundledIds, ...customIds].sort(); + function send(method, payload = {}) { + const id = ++requestId; - readyResolve(); - } catch (e) { - console.error('Failed to initialize shiki:', e); - readyResolve(); - } - }, 0); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + worker.postMessage({ id, method, payload }); + }); } - const shiki = { - ready, - - async loadLanguage(lang) { - await ready; - if (this.languageIsLoaded(lang)) return; - return await highlighter.loadLanguage(lang); - }, - - async loadLanguages(langs = []) { - return await Promise.all(langs.map(async (lang) => await this.loadLanguage(lang))); - }, - - async loadTheme(theme) { - await ready; - if (this.themeIsLoaded(theme)) return; - - // Try loading as a bundled shiki theme first. - // If that fails, fetch from the static custom themes directory. - try { - return await highlighter.loadTheme(theme); - } catch { - const themeData = await fetch(`/shiki/themes/${theme}.json`).then((r) => { - if (!r.ok) throw new Error(`Theme "${theme}" not found.`); - return r.json(); - }); - - return await highlighter.loadTheme(themeData); - } - }, - - getTheme(theme) { - return highlighter?.getTheme(theme); - }, - - languages() { - return allLanguageIds.value; - }, - - languageIsLoaded(lang) { - return this.loadedLanguages().includes(lang); - }, - - loadedLanguages() { - return highlighter?.getLoadedLanguages() ?? []; - }, - - themes() { - return allThemeIds.value; - }, - - themeIsLoaded(theme) { - return this.loadedThemes().includes(theme); - }, - - loadedThemes() { - return highlighter?.getLoadedThemes() ?? []; - }, - - async tokens(code, lang, theme) { - await ready; - - if (code.includes(' { + allLanguageIds.value = languages; + allThemeIds.value = themes; + }); return { provide: { - shiki, + shiki: { + ready, + languages: () => allLanguageIds.value, + themes: () => allThemeIds.value, + async tokenizeBlocks(payload) { + await ready; + + return send('tokenizeBlocks', payload); + }, + }, }, }; }); diff --git a/app/workers/shiki.worker.js b/app/workers/shiki.worker.js new file mode 100644 index 00000000..7c96043c --- /dev/null +++ b/app/workers/shiki.worker.js @@ -0,0 +1,94 @@ +import { bundledLanguagesInfo, bundledThemesInfo, createHighlighter } from 'shiki'; + +let highlighter = null; +let initialization = null; + +async function initialize() { + if (initialization) { + return initialization; + } + + initialization = (async () => { + highlighter = await createHighlighter({ + themes: ['github-light'], + langs: ['html', 'xml', 'sql', 'javascript', 'json', 'css', 'php'], + }); + + const languages = bundledLanguagesInfo + .map(({ id }) => id) + .filter((id) => !['php-html', 'html-derivative'].includes(id)); + + const bundledThemes = bundledThemesInfo + .map(({ id }) => id) + .filter((theme) => !theme.includes('css-variables')); + + const response = await fetch('/shiki/themes/all.json'); + const customThemes = response.ok ? await response.json() : []; + + return { + languages, + themes: [...new Set([...bundledThemes, ...customThemes])].sort(), + }; + })(); + + return initialization; +} + +async function loadLanguage(language) { + if (!highlighter.getLoadedLanguages().includes(language)) { + await highlighter.loadLanguage(language); + } +} + +async function loadTheme(theme) { + if (highlighter.getLoadedThemes().includes(theme)) { + return; + } + + try { + await highlighter.loadTheme(theme); + } catch { + const response = await fetch(`/shiki/themes/${theme}.json`); + + if (!response.ok) { + throw new Error(`Theme "${theme}" not found.`); + } + + await highlighter.loadTheme(await response.json()); + } +} + +async function tokenizeBlocks({ blocks, theme }) { + await initialize(); + + const languages = [...new Set(blocks.map(({ language }) => language))]; + + await Promise.all([loadTheme(theme), ...languages.map(loadLanguage)]); + + const tokenized = blocks.map(({ value, language }) => + highlighter.codeToTokensBase(value, { lang: language, theme }) + ); + + const { name, fg, bg, type } = highlighter.getTheme(theme); + + return { + blocks: tokenized, + theme: { name, fg, bg, type }, + }; +} + +const handlers = { + initialize, + tokenizeBlocks, +}; + +self.addEventListener('message', async ({ data: { id, method, payload } }) => { + try { + self.postMessage({ id, result: await handlers[method](payload) }); + } catch (error) { + self.postMessage({ + id, + error: error instanceof Error ? error.message : String(error), + }); + } +}); diff --git a/nuxt.config.ts b/nuxt.config.ts index ef3d4254..52e9c09b 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -17,6 +17,9 @@ export default defineNuxtConfig({ vite: { plugins: [tailwindcss()], + worker: { + format: 'es', + }, }, site: { @@ -78,11 +81,47 @@ export default defineNuxtConfig({ workbox: { skipWaiting: true, clientsClaim: true, - navigateFallback: '/', - globPatterns: ['**/*.{js,css,html,png,svg,ico,woff2}'], + cleanupOutdatedCaches: true, + navigateFallback: null, + // Keep installation light. Feature chunks, themes, and artwork are + // cached as they are used instead of downloading the whole app. + globPatterns: ['**/*.{css,html,ico,woff2}'], globIgnores: ['**/*.worker-*.js'], maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, runtimeCaching: [ + { + urlPattern: ({ request }) => request.mode === 'navigate', + handler: 'NetworkFirst', + options: { + cacheName: 'page-cache', + networkTimeoutSeconds: 3, + expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 7 }, + }, + }, + { + urlPattern: /\/_nuxt\//, + handler: 'CacheFirst', + options: { + cacheName: 'asset-cache', + expiration: { maxEntries: 120, maxAgeSeconds: 60 * 60 * 24 * 365 }, + }, + }, + { + urlPattern: /\/background-thumbnails\//, + handler: 'CacheFirst', + options: { + cacheName: 'background-thumbnail-cache', + expiration: { maxEntries: 80, maxAgeSeconds: 60 * 60 * 24 * 90 }, + }, + }, + { + urlPattern: /\/shiki\/themes\//, + handler: 'CacheFirst', + options: { + cacheName: 'theme-cache', + expiration: { maxEntries: 40, maxAgeSeconds: 60 * 60 * 24 * 90 }, + }, + }, { urlPattern: /\.worker.*\.js$/, handler: 'CacheFirst', @@ -139,7 +178,6 @@ export default defineNuxtConfig({ ? [{ rel: 'manifest', href: '/manifest.webmanifest' }] : []), ], - }, }, -}) +}); diff --git a/package-lock.json b/package-lock.json index 62ed9661..b6df7563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,7 +47,6 @@ "pinia-plugin-persistedstate": "^4.7.1", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "queue": "^7.0.0", "radix-vue": "^1.9.17", "reka-ui": "^2.9.2", "satori": "^0.26.0", @@ -14857,13 +14856,6 @@ ], "license": "MIT" }, - "node_modules/queue": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/queue/-/queue-7.0.0.tgz", - "integrity": "sha512-sphwS7HdfQnvrJAXUNAUgpf9H/546IE3p/5Lf2jr71O4udEYlqAhkevykumas2FYuMkX/29JMOgrRdRoYZ/X9w==", - "dev": true, - "license": "MIT" - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 86eebde6..7d2f62bc 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,6 @@ "pinia-plugin-persistedstate": "^4.7.1", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "queue": "^7.0.0", "radix-vue": "^1.9.17", "reka-ui": "^2.9.2", "satori": "^0.26.0", diff --git a/tests/components/TabBackgrounds.test.js b/tests/components/TabBackgrounds.test.js new file mode 100644 index 00000000..5e899b3e --- /dev/null +++ b/tests/components/TabBackgrounds.test.js @@ -0,0 +1,57 @@ +import { mount } from '@vue/test-utils'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import TabBackgrounds from '~/components/TabBackgrounds.vue'; + +const { scrollRefIntoView } = vi.hoisted(() => ({ + scrollRefIntoView: vi.fn(), +})); + +vi.mock('~/composables/useScrollRefIntoView', () => ({ + default: () => ({ scrollRefIntoView }), +})); + +const passthroughStub = { + template: '
', +}; + +describe('TabBackgrounds', () => { + afterEach(() => { + scrollRefIntoView.mockReset(); + }); + + it('positions the selected background without waiting for a timer', async () => { + const wrapper = mount(TabBackgrounds, { + props: { + background: 'background-20', + backgroundColor: null, + backgrounds: Array.from({ length: 30 }, (_, index) => ({ + id: `background-${index}`, + style: { backgroundColor: '#000' }, + })), + }, + global: { + directives: { + tooltip: {}, + }, + stubs: { + Button: passthroughStub, + ButtonBackground: { + props: ['active', 'attributes', 'custom', 'thumbnail', 'title'], + template: '', + }, + 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/TabThemes.test.js b/tests/components/TabThemes.test.js new file mode 100644 index 00000000..89c777ea --- /dev/null +++ b/tests/components/TabThemes.test.js @@ -0,0 +1,39 @@ +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); + }); +}); From 2f9fd3768ba7b5f45f6725577a041e99b77d67e7 Mon Sep 17 00:00:00 2001 From: Steve Bauman Date: Sat, 11 Jul 2026 00:31:05 -0400 Subject: [PATCH 2/2] Preserve selector scroll positions --- app/components/TabBackgrounds.vue | 20 +++++----- app/components/TabScenes.vue | 23 +++++++++-- app/components/TabThemes.vue | 15 ++++--- tests/components/TabBackgrounds.test.js | 2 +- tests/components/TabScenes.test.js | 53 +++++++++++++++++++++++++ tests/components/TabThemes.test.js | 26 ++++++++++++ 6 files changed, 119 insertions(+), 20 deletions(-) create mode 100644 tests/components/TabScenes.test.js diff --git a/app/components/TabBackgrounds.vue b/app/components/TabBackgrounds.vue index f1591bd8..b7edaae4 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 e1deda8b..30e0f419 100644 --- a/app/components/TabScenes.vue +++ b/app/components/TabScenes.vue @@ -4,6 +4,7 @@