diff --git a/CHANGELOG.md b/CHANGELOG.md index 77dd8cb..f73b01e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file. ## 2.21.6 - 2026-10-27 +### Added + +- Template and slide region editors list regions alphabetically, including the layout and animation selectors. + ### Fixed - List searches no longer issue duplicate requests when the search field loses focus, avoiding intermittent missed clicks on refreshed row buttons. diff --git a/src/web/public/js/slides/slide-animation-editor.js b/src/web/public/js/slides/slide-animation-editor.js index 4b9489c..2422ffa 100644 --- a/src/web/public/js/slides/slide-animation-editor.js +++ b/src/web/public/js/slides/slide-animation-editor.js @@ -223,7 +223,9 @@ export function createSlideAnimationEditor(options) { openButton.addEventListener('click', function () { if (!allRegions().length || !window.pulseModal) return; regionSelect.replaceChildren(); - regions().forEach(function (region) { + window.templateDesignerUtils.sortByName(regions(), function (region) { + return region.label || region.region_key; + }).forEach(function (region) { var option = document.createElement('option'); option.value = region.region_key; option.textContent = region.label || region.region_key; diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 7c757de..9f6d014 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -951,7 +951,11 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed return; } - var editableRegions = template.regions.filter(function (region) { return !(region.read_only === true || Number(region.read_only) === 1); }); + var editableRegions = window.templateDesignerUtils.sortByName(template.regions.filter(function (region) { + return !(region.read_only === true || Number(region.read_only) === 1); + }), function (region) { + return region.label || region.region_key; + }); templateFields.innerHTML = editableRegions.length ? editableRegions.map(function (region) { return getRegionEditorCardHtml(region) || '
Region editor unavailable.
'; }).join('') : '
This template has no editable regions.
'; diff --git a/src/web/public/js/slides/slide-layout-editor.js b/src/web/public/js/slides/slide-layout-editor.js index bc61425..9db4915 100644 --- a/src/web/public/js/slides/slide-layout-editor.js +++ b/src/web/public/js/slides/slide-layout-editor.js @@ -150,7 +150,10 @@ export function createSlideLayoutEditor(options) { openButton.disabled = active || !regions().length; animationButton.disabled = active || !allRegions().length; if (!active || sharedSaving) return; - if (!selected()) selectedKey = regions().length ? regions()[0].region_key : ''; + var orderedRegions = window.templateDesignerUtils.sortByName(regions(), function (region) { + return region.label || region.region_key; + }); + if (!selected()) selectedKey = orderedRegions.length ? orderedRegions[0].region_key : ''; var region = selected(); var box = region && effective(region); Object.keys(fields).forEach(function (field) { @@ -216,7 +219,9 @@ export function createSlideLayoutEditor(options) { home = stage.parentNode; nextSibling = stage.nextSibling; regionSelect.replaceChildren(); - regions().forEach(function (region) { + window.templateDesignerUtils.sortByName(regions(), function (region) { + return region.label || region.region_key; + }).forEach(function (region) { var option = document.createElement('option'); option.value = region.region_key; option.textContent = region.label || region.region_key; diff --git a/src/web/public/js/templates/template-designer-utils.js b/src/web/public/js/templates/template-designer-utils.js index 82b45d9..e3c1001 100644 --- a/src/web/public/js/templates/template-designer-utils.js +++ b/src/web/public/js/templates/template-designer-utils.js @@ -84,6 +84,16 @@ return String(card.querySelector('[name="region_name[]"]').value || '').trim(); } + function sortByName(items, getName) { + return Array.prototype.map.call(items || [], function (item, index) { + return { item: item, index: index, name: String(getName(item) || '').trim() }; + }).sort(function (left, right) { + return left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }) || left.index - right.index; + }).map(function (entry) { + return entry.item; + }); + } + function getAnimationConfig(card) { var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]'); var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}'); @@ -354,6 +364,7 @@ valueOrDefault: valueOrDefault, clamp: clamp, getRegionName: getRegionName, + sortByName: sortByName, syncRegionIdentity: syncRegionIdentity, readCard: readCard, writeCard: writeCard, diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js index 7a52474..3d51184 100644 --- a/src/web/public/js/templates/template-designer.js +++ b/src/web/public/js/templates/template-designer.js @@ -1237,8 +1237,11 @@ if (title) { title.textContent = label; } - if (index >= 0 && regionSelect.options[index]) { - regionSelect.options[index].textContent = label; + var option = Array.prototype.find.call(regionSelect.options, function (item) { + return item.value === String(index); + }); + if (option) { + option.textContent = label; } } @@ -1351,11 +1354,13 @@ updateCanvasSizeLock(); return; } + var orderedCards = utils.sortByName(cards, getRegionName); regionSelect.disabled = false; if (selectedIndex < 0 || selectedIndex >= cards.length) { - selectedIndex = 0; + selectedIndex = cards.indexOf(orderedCards[0]); } - cards.forEach(function (card, index) { + orderedCards.forEach(function (card) { + var index = cards.indexOf(card); var option = document.createElement('option'); option.value = String(index); option.textContent = getRegionName(card) || ('Region ' + (index + 1)); diff --git a/test/slide-form-editor.test.js b/test/slide-form-editor.test.js index 45b493f..7514c0b 100644 --- a/test/slide-form-editor.test.js +++ b/test/slide-form-editor.test.js @@ -75,6 +75,15 @@ test('slide form queues editor image cleanup on save and close', () => { assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())")); }); +test('slide region cards and layout and animation selectors use name ordering', () => { + const layoutEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-layout-editor.js'), 'utf8'); + const animationEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-animation-editor.js'), 'utf8'); + + assert.match(slideFormSource, /window\.templateDesignerUtils\.sortByName\(template\.regions\.filter/); + assert.match(layoutEditorSource, /window\.templateDesignerUtils\.sortByName\(regions\(\)/); + assert.match(animationEditorSource, /window\.templateDesignerUtils\.sortByName\(regions\(\)/); +}); + test('media library selections keep image and video remove controls', () => { assert.ok(mediaPickerSource.includes("var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';")); assert.ok(mediaPickerSource.includes('data-remove-\' + removeAction')); diff --git a/test/templates.test.js b/test/templates.test.js index 968732c..9c4dc8a 100644 --- a/test/templates.test.js +++ b/test/templates.test.js @@ -381,6 +381,71 @@ test('template designer generates default region names per type', () => { assert.ok(script.includes('Math.max(count, suffix)')); }); +test('region name sorting is case-insensitive, stable, and used by the template editor', () => { + const context = vm.createContext({ window: {} }); + const utilsScript = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer-utils.js'), 'utf8'); + vm.runInContext(utilsScript, context); + const items = [ + { name: 'Zulu' }, + { name: 'alpha' }, + { name: 'Alpha' }, + { name: 'bravo' } + ]; + const sorted = Array.from(context.window.templateDesignerUtils.sortByName(items, item => item.name)); + + assert.deepEqual(sorted, [items[1], items[2], items[3], items[0]]); + const designerSource = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8'); + assert.match(designerSource, /utils\.sortByName\(cards, getRegionName\)/); +}); + +test('template region selector sorts options and preserves the selected card index', () => { + const designerSource = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8'); + const match = designerSource.match(/ function renderRegionSidebar\(\) \{[^]*?\n \}/); + assert.ok(match); + + const cards = [ + { name: 'Zulu', hidden: false }, + { name: 'alpha', hidden: false }, + { name: 'Bravo', hidden: false } + ]; + const regionSelect = { + options: [], + disabled: false, + value: '', + set innerHTML(_value) { this.options = []; }, + appendChild(option) { this.options.push(option); } + }; + const regionList = { innerHTML: '' }; + const context = vm.createContext({ + cards, + regionSelect, + regionList, + selectedIndex: -1, + utils: { sortByName(items, getName) { return Array.from(items).sort((left, right) => getName(left).localeCompare(getName(right), undefined, { sensitivity: 'base' })); } }, + getCards: () => cards, + getRegionName: card => card.name, + document: { createElement: () => ({}) }, + updateCanvasSizeLock() {}, + validateRegionNames() {}, + updatePreviewCardVisibility() {} + }); + + vm.runInContext(match[0], context); + context.renderRegionSidebar(); + assert.deepEqual(regionSelect.options.map(option => [option.textContent, option.value]), [ + ['alpha', '1'], + ['Bravo', '2'], + ['Zulu', '0'] + ]); + assert.equal(regionSelect.value, '1'); + assert.deepEqual(cards.map(card => card.hidden), [true, false, true]); + + context.selectedIndex = 0; + context.renderRegionSidebar(); + assert.equal(regionSelect.value, '0'); + assert.deepEqual(cards.map(card => card.hidden), [false, true, true]); +}); + test('template designer warns when invalid region names block save', () => { const script = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');