diff --git a/CHANGELOG.md b/CHANGELOG.md index 6954f0f..251f3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## 2.8.3 - 2026-08-17 + +### Added + +- Added API, RSS, Timetable, and Time / Date placeholder help panels with shared transform and date-token documentation. +- Added render-time API and RSS image placeholders with proportional sizing and preview-only bounding boxes. + +### Changed + +- API and RSS regions now preserve authored content when no data source is selected and remain blank when a selected source has no authored content. +- Normal WYSIWYG image insertion remains upload-backed and separate from image placeholder transforms. + ## 2.8.2 - 2026-08-16 ### Changed diff --git a/build/package.player.json b/build/package.player.json index 010c931..e4396e6 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.8.2", + "version": "2.8.3", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 5dfe47f..44113cd 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.8.2", + "version": "2.8.3", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index ade72c7..45bcf7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.8.2", + "version": "2.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.8.2", + "version": "2.8.3", "dependencies": { "@sparticuz/chromium": "^149.0.0", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 4333a83..67ee60d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.8.2", + "version": "2.8.3", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/slides.js b/src/data/slides.js index e0efc6b..a06bff4 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -418,7 +418,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing content[region.region_key] = { type: 'rss', value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), - feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId), + feed_id: feedId === undefined || feedId === null ? (current.feed_id || null) : (feedId === '' ? null : Number(feedId)), item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1), variable_name: 'item', font_family: style.font_family, @@ -436,7 +436,7 @@ async function buildTemplateContent(pool, template, body, filesByField, existing content[region.region_key] = { type: 'api', value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))), - source_id: sourceId === undefined || sourceId === null || sourceId === '' ? (current.source_id || null) : Number(sourceId), + source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(), variable_name: 'item', diff --git a/src/player/regions/api.js b/src/player/regions/api.js index 9331ec5..daa593c 100644 --- a/src/player/regions/api.js +++ b/src/player/regions/api.js @@ -67,7 +67,23 @@ function substituteApiVariables(html, item) { return ''; } - return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression))); + var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); + if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { + var imageSource = placeholderUtils.formatPlaceholderValue(resolved); + if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { + return ''; + } + var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {}; + var hasImageBounds = imageConfig.width && imageConfig.height; + var imageStyle = hasImageBounds + ? 'display:block;width:100%;height:100%;object-fit:contain;' + : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); + var image = ''; + return hasImageBounds + ? '' + image + '' + : image; + } + return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); }); } @@ -120,10 +136,8 @@ function renderApiRegion(region, regionContent) { var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var item = getApiItem(sourceId, itemNumber, itemsPath); - var body = item ? substituteApiVariables(content, item) : ''; - if (!body && item) { - body = getApiPreviewFallback(item); - } + var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== ''; + var body = hasSource ? (item ? substituteApiVariables(content, item) : '') : content; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; if (!body) { return ''; diff --git a/src/player/regions/rss.js b/src/player/regions/rss.js index d8e54f9..a36c76f 100644 --- a/src/player/regions/rss.js +++ b/src/player/regions/rss.js @@ -1,6 +1,7 @@ // RSS region helpers for resolving feeds, items, and nested values. var registry = window.pulsePlayerRegionTypes; +var placeholderUtils = window.placeholderUtils || {}; function getDefaultStyle() { return { @@ -65,12 +66,30 @@ function resolveRssPath(value, path) { function substituteRssVariables(html, item) { var source = String(html || ''); - return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { + return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!item || typeof item !== 'object') { return ''; } - var key = tokenPath ? tokenName + '.' + tokenPath : tokenName; - return escapeHtml(resolveRssPath(item, key || '')); + if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') { + return ''; + } + var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); + if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { + var imageSource = placeholderUtils.formatPlaceholderValue(resolved); + if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { + return ''; + } + var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {}; + var hasImageBounds = imageConfig.width && imageConfig.height; + var imageStyle = hasImageBounds + ? 'display:block;width:100%;height:100%;object-fit:contain;' + : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); + var image = ''; + return hasImageBounds + ? '' + image + '' + : image; + } + return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); }); } @@ -83,17 +102,8 @@ function renderRssRegion(region, regionContent) { var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color); var item = getRssFeedItem(feedId, itemNumber); - var body = item ? substituteRssVariables(content, item) : ''; - if (!body && item) { - var summaryParts = []; - if (item.title) { - summaryParts.push('

' + escapeHtml(item.title) + '

'); - } - if (item.description) { - summaryParts.push('
' + sanitizeRichText(item.description) + '
'); - } - body = summaryParts.join(''); - } + var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== ''; + var body = hasFeed ? (item ? substituteRssVariables(content, item) : '') : content; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; if (!body) { return ''; diff --git a/src/web/lib/region-scripts.js b/src/web/lib/region-scripts.js index b3ff2a1..25495ab 100644 --- a/src/web/lib/region-scripts.js +++ b/src/web/lib/region-scripts.js @@ -4,7 +4,7 @@ const path = require('path'); const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js'); const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions'); const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type'); -const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js']; +const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js']; function withAssetVersion(scriptPath, assetVersion) { if (!assetVersion) { diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 312cad9..f46f8e5 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -2533,6 +2533,13 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { text-transform: uppercase; } +.api-region-placeholder-title-help { + font-size: 0.9rem; + font-weight: 800; + letter-spacing: normal; + text-transform: none; +} + .api-region-sample-accordion { padding: 0.85rem 1rem 1rem; margin-bottom: 0.75rem; diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js index db457ef..8598594 100644 --- a/src/web/public/js/regions/type/api.js +++ b/src/web/public/js/regions/type/api.js @@ -94,7 +94,7 @@ }; } - function substituteVariables(html, item) { + function substituteVariables(html, item, options) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!item || typeof item !== 'object') { @@ -105,7 +105,23 @@ return ''; } - return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(item, expression))); + var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); + if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { + var imageSource = placeholderUtils.formatPlaceholderValue(resolved); + if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { + return ''; + } + var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {}; + var imageStyle = options && options.preview && imageConfig.width && imageConfig.height + ? 'display:block;width:100%;height:100%;object-fit:contain;' + : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); + var image = ''; + if (options && options.preview && imageConfig.width && imageConfig.height) { + return '' + image + ''; + } + return image; + } + return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); }); } @@ -136,6 +152,24 @@ return summary.join(''); } + function renderTransformInfo(regionId) { + var offcanvasId = 'api-placeholder-info-' + regionId; + return '' + + '' + + '
' + + '
' + + '

API placeholder transforms

' + + '' + + '
' + + '
' + + '

Placeholders read values from the selected API item. Nested fields use dots.

' + + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + + (window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : '') + + '
' + + '
'; + } + function normalizeRenderableValue(value) { if (value && typeof value === 'object') { if (value.value !== undefined) { @@ -170,11 +204,8 @@ var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); var item = getItem(sourceId, itemNumber, sources, itemsPath); - var body = item ? substituteVariables(content, item) : ''; - - if (!body && item) { - body = getPreviewFallback(item); - } + var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== ''; + var body = hasSource ? (item ? substituteVariables(content, item, { preview: true }) : '') : content; body = String(body || '') .replace(/]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') @@ -239,7 +270,7 @@ '' + '' + '
' + - '
Available placeholders
' + + '
Available placeholders ' + renderTransformInfo(region.id) + '
' + '
' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || 'No JSON fields available.') + '
' + '
' + '
' + @@ -290,7 +321,7 @@ return fallbackValue; } }())), - source_id: (card.querySelector('select[name="region_api_source_id_' + region.id + '"]') || {}).value || current.source_id, + source_id: card.querySelector('select[name="region_api_source_id_' + region.id + '"]') ? card.querySelector('select[name="region_api_source_id_' + region.id + '"]').value : current.source_id, item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number, items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path } : current; diff --git a/src/web/public/js/regions/type/rss.js b/src/web/public/js/regions/type/rss.js index 05ec44d..c506a31 100644 --- a/src/web/public/js/regions/type/rss.js +++ b/src/web/public/js/regions/type/rss.js @@ -3,6 +3,7 @@ (function () { var registry = window.pulseRegionTypes; var utils = window.pulseRegionUtils || {}; + var placeholderUtils = window.placeholderUtils || {}; var DEFAULT_STYLE = { font_family: 'Arial', font_size: 32, @@ -114,14 +115,32 @@ return current === undefined || current === null ? '' : current; } - function substituteVariables(html, item) { + function substituteVariables(html, item, options) { var source = String(html || ''); - return source.replace(/\{\{\s*([a-zA-Z0-9_]+)(?:\.([a-zA-Z0-9_.]+))?\s*\}\}/g, function (_match, tokenName, tokenPath) { + return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { if (!item || typeof item !== 'object') { return ''; } - var key = tokenPath ? tokenName + '.' + tokenPath : tokenName; - return escapeHtml(resolvePath(item, key || '')); + if (typeof placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof placeholderUtils.formatPlaceholderValue !== 'function') { + return ''; + } + var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); + if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { + var imageSource = placeholderUtils.formatPlaceholderValue(resolved); + if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { + return ''; + } + var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {}; + var imageStyle = options && options.preview && imageConfig.width && imageConfig.height + ? 'display:block;width:100%;height:100%;object-fit:contain;' + : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); + var image = ''; + if (options && options.preview && imageConfig.width && imageConfig.height) { + return '' + image + ''; + } + return image; + } + return escapeHtml(placeholderUtils.formatPlaceholderValue(resolved)); }); } @@ -186,18 +205,8 @@ var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color); var item = getItem(feedId, itemNumber, feeds); - var body = item ? substituteVariables(content, item) : ''; - - if (!body && item) { - var summaryParts = []; - if (item.title) { - summaryParts.push('

' + escapeHtml(item.title) + '

'); - } - if (item.description) { - summaryParts.push('
' + sanitizePreviewHtml(item.description) + '
'); - } - body = summaryParts.join(''); - } + var hasFeed = String(feedId === undefined || feedId === null ? '' : feedId).trim() !== ''; + var body = hasFeed ? (item ? substituteVariables(content, item, { preview: true }) : '') : content; if (!body) { return ''; @@ -206,6 +215,21 @@ return renderedBody ? '
' + renderedBody + '
' : ''; } + function renderPlaceholderInfo(regionId) { + if (!window.placeholderInfo) { + return ''; + } + return window.placeholderInfo.render(regionId, 'RSS placeholder transforms', + '

Placeholders read values from the selected RSS entry. Use the available field chips as the starting point for your message.

' + + '

Placeholder paths

' + + '

Nested values use dots. Missing values render as empty text.

' + + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + + '

Date formatting

' + + '

Use format("MMM D, YYYY") with date fields. Use tz() for a short timezone name and tz_long() for the full timezone name.

' + + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + + (window.placeholderInfo ? window.placeholderInfo.renderImageTransform() : '')); + } + function renderEditorCard(context) { var region = context.region; var current = context.current || {}; @@ -221,7 +245,7 @@ 'RSS' + '' + '' + - '
' + + '
' + '
' + '
' + '' + @@ -247,9 +271,8 @@ '' + '' + '
' + - '
Available placeholders
' + + '
Available placeholders ' + renderPlaceholderInfo(region.id) + '
' + '
' + (placeholderChips || 'No RSS fields available.') + '
' + - '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, {{title.lower()}}, or {{publishedAt.format("MMM D, YYYY")}}.
' + '
' + '
' + '
'; @@ -291,7 +314,7 @@ return fallbackValue; } }())), - feed_id: (card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') || {}).value || current.feed_id, + feed_id: card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]') ? card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]').value : current.feed_id, item_number: (card.querySelector('input[name="region_rss_item_number_' + region.id + '"]') || {}).value || current.item_number } : current; diff --git a/src/web/public/js/regions/type/text.js b/src/web/public/js/regions/type/text.js index db3a981..c50697c 100644 --- a/src/web/public/js/regions/type/text.js +++ b/src/web/public/js/regions/type/text.js @@ -33,9 +33,9 @@ } var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {}; - var fontFamily = style && style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; - var color = style && style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; - var fontSize = style && style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; + var fontFamily = 'font-family:' + escapeHtml(style && style.font_family || DEFAULT_STYLE.font_family) + ';'; + var color = 'color:' + escapeHtml(style && style.font_color || DEFAULT_STYLE.font_color) + ';'; + var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style && style.font_size || DEFAULT_STYLE.font_size))) + 'px;'; var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;line-height:1.5;' + fontFamily + fontSize + color; diff --git a/src/web/public/js/regions/type/time-date.js b/src/web/public/js/regions/type/time-date.js index 9919a2a..28bcdfe 100644 --- a/src/web/public/js/regions/type/time-date.js +++ b/src/web/public/js/regions/type/time-date.js @@ -286,6 +286,19 @@ return renderTimeDatePlaceholderChips(); } + function renderPlaceholderInfo(regionId) { + if (!window.placeholderInfo) { + return ''; + } + var placeholders = window.timeDatePlaceholders && typeof window.timeDatePlaceholders.getTokens === 'function' + ? window.timeDatePlaceholders.getTokens() + : []; + var table = '
' + placeholders.map(function (item) { + return ''; + }).join('') + '
PlaceholderOutputDescription
{{' + escapeHtml(item.token) + '}}' + escapeHtml(item.output) + '' + escapeHtml(item.description) + '
'; + return window.placeholderInfo.render(regionId, 'Time and date placeholders', table); + } + function renderEditorCard(context) { var region = context.region; var current = context.current || {}; @@ -317,9 +330,8 @@ '' + buildTimezoneOptionsMarkup(timeZone) + '' + '
' + '
' + - '
Available placeholders
' + + '
Available placeholders ' + renderPlaceholderInfo(region.id) + '
' + '
' + renderPlaceholderChips() + '
' + - '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, or {{title.lower()}}.
' + '
' + '
' + ''; diff --git a/src/web/public/js/regions/type/timetable.js b/src/web/public/js/regions/type/timetable.js index b9a64ef..595162a 100644 --- a/src/web/public/js/regions/type/timetable.js +++ b/src/web/public/js/regions/type/timetable.js @@ -240,6 +240,21 @@ }; } + function renderPlaceholderInfo(regionId) { + if (!window.placeholderInfo) { + return ''; + } + var body = '

Placeholders read fields from the selected timetable entries. The selected group and display mode determine which entries and fields are available.

' + + '

Placeholder paths

' + + '

Use the available field chips. Nested values use dots, and missing values render as empty text.

' + + window.placeholderInfo.renderTextTransforms() + + '

Date and time formatting

' + + '

Use format("MMM D, YYYY h:mm A") with date fields. Use tz() for a short timezone name and tz_long() for the full timezone name.

' + + window.placeholderInfo.renderDateFormatTokens() + + ''; + return window.placeholderInfo.render(regionId, 'Timetable placeholder transforms', body); + } + function renderEditorCard(context) { var region = context.region; var current = context.current || {}; @@ -298,14 +313,8 @@ '' + '
Use the selected group and display mode to choose which timetable entry fields are available.
' + '
' + - '
Available placeholders
' + + '
Available placeholders ' + renderPlaceholderInfo(region.id) + '
' + '
' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '
' + - '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, {{title.lower()}}, {{start.format("MMM D, YYYY h:mm A")}}, {{start.tz()}} for the short zone name, or {{start.tz_long()}} for the full IANA zone name.
' + - '
' + - 'Supported date format tokens' + - '
' + renderScheduleFormatTokenTable() + '
' + - '
Use these tokens inside .format(...); for example {{start.format("MMM D, YYYY h:mm A")}}.
' + - '
' + '
' + '' + '' + diff --git a/src/web/public/js/shared/placeholder-info.js b/src/web/public/js/shared/placeholder-info.js new file mode 100644 index 0000000..600be6d --- /dev/null +++ b/src/web/public/js/shared/placeholder-info.js @@ -0,0 +1,67 @@ +// Shared offcanvas renderer for region placeholder documentation. + +(function () { + var root = window; + + function escapeHtml(value) { + return String(value === undefined || value === null ? '' : value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function render(regionId, title, body) { + var offcanvasId = String(regionId || 'region') + '-placeholder-info'; + return '' + + '' + + '
' + + '
' + + '

' + escapeHtml(title) + '

' + + '' + + '
' + + '
' + body + '
' + + '
'; + } + + function renderTextTransforms() { + return '

Text transforms

' + + '
' + + '
upper()
Converts text to uppercase.
' + + '
lower()
Converts text to lowercase.
' + + '
title()
Capitalizes each word.
' + + '
tz()
Returns the short timezone name for a date.
' + + '
tz_long()
Returns the full timezone name for a date.
' + + '
'; + } + + function renderDateFormatTokens() { + return '

Date format tokens

' + + '

Use the format("...") transform with these tokens. Text inside square brackets is treated as a literal.

' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
TokenMeaningExample
YYYYFour-digit year2026
YYTwo-digit year26
MMMMFull month nameAugust
MMMShort month nameAug
MMTwo-digit month08
MMonth number8
DDTwo-digit day16
DDay number16
ddddFull weekday nameSunday
dddShort weekday nameSun
HH / H24-hour time19 / 7
hh / h12-hour time07 / 7
mm / mMinutes05 / 5
ss / sSeconds09 / 9
A / aAM or PMPM / pm
'; + } + + function renderImageTransform() { + return '

Image transform

' + + '

Use image(width, height) to render an image URL inside a proportional bounding box.

' + + ''; + } + + root.placeholderInfo = { + escapeHtml: escapeHtml, + render: render, + renderTextTransforms: renderTextTransforms, + renderDateFormatTokens: renderDateFormatTokens, + renderImageTransform: renderImageTransform + }; +}()); \ No newline at end of file diff --git a/src/web/public/js/shared/placeholder-utils.js b/src/web/public/js/shared/placeholder-utils.js index 7e8272d..cdfb714 100644 --- a/src/web/public/js/shared/placeholder-utils.js +++ b/src/web/public/js/shared/placeholder-utils.js @@ -298,6 +298,27 @@ return resolved; } + function isImagePlaceholderExpression(expression) { + var parsed = parsePlaceholderExpression(expression); + return parsed.transforms.some(function (transform) { + return transform && transform.name === 'image'; + }); + } + + function getImagePlaceholderConfig(expression) { + var parsed = parsePlaceholderExpression(expression); + var imageTransform = parsed.transforms.find(function (transform) { + return transform && transform.name === 'image'; + }); + var args = imageTransform && Array.isArray(imageTransform.args) ? imageTransform.args : []; + var width = Number(args[0]); + var height = Number(args[1]); + return { + width: Number.isFinite(width) && width > 0 ? Math.round(width) : 0, + height: Number.isFinite(height) && height > 0 ? Math.round(height) : 0 + }; + } + function formatPlaceholderValue(value) { if (value === undefined || value === null) { return ''; @@ -349,6 +370,8 @@ resolvePath: resolvePath, parsePlaceholderExpression: parsePlaceholderExpression, resolvePlaceholderExpression: resolvePlaceholderExpression, + isImagePlaceholderExpression: isImagePlaceholderExpression, + getImagePlaceholderConfig: getImagePlaceholderConfig, formatPlaceholderValue: formatPlaceholderValue, collectPlaceholderFieldPaths: collectPlaceholderFieldPaths }; diff --git a/src/web/public/js/slides/slide-form-editor.js b/src/web/public/js/slides/slide-form-editor.js index bcb94c8..39c920b 100644 --- a/src/web/public/js/slides/slide-form-editor.js +++ b/src/web/public/js/slides/slide-form-editor.js @@ -44,10 +44,6 @@ export function createSlideFormEditorController(options) { return String(value || ''); } - function normalizeEditorMarkup(value) { - return String(value === undefined || value === null ? '' : value).trim(); - } - function isEmptyRichTextValue(value) { var raw = String(value === undefined || value === null ? '' : value).trim(); if (!raw) { @@ -575,7 +571,7 @@ export function createSlideFormEditorController(options) { return Promise.resolve(null); } - source.value = normalizeEditorMarkup(normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : ''))); + source.value = normalizeEditorData(source && source.value !== undefined && source.value !== null ? source.value : (hidden ? hidden.value : '')); if (!source.id) { source.id = 'slide-editor-region-' + regionId; diff --git a/src/web/public/js/slides/slide-form-preview.js b/src/web/public/js/slides/slide-form-preview.js index 3afda72..43ab716 100644 --- a/src/web/public/js/slides/slide-form-preview.js +++ b/src/web/public/js/slides/slide-form-preview.js @@ -198,9 +198,9 @@ export function createSlideFormPreviewHelpers(options) { return module.renderPreview(region, value, style, scale); } - var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; - var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; - var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; + var fontFamily = 'font-family:' + escapeHtml(style.font_family || 'Arial') + ';'; + var color = 'color:' + escapeHtml(style.font_color || '#000000') + ';'; + var fontSize = 'font-size:' + Math.max(1, Math.round(Number(style.font_size || 32))) + 'px;'; var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color; diff --git a/src/web/public/js/slides/slide-form-regions.js b/src/web/public/js/slides/slide-form-regions.js index f28627d..3f4273b 100644 --- a/src/web/public/js/slides/slide-form-regions.js +++ b/src/web/public/js/slides/slide-form-regions.js @@ -123,7 +123,7 @@ export function createSlideFormRegionHelpers(options) { return fields; } - function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel) { + function buildLimitedPlaceholderChipMarkup(fieldList, emptyLabel, includeTransformHint) { var fields = Array.isArray(fieldList) ? fieldList : []; var visibleFields = fields.slice(0, 8); var overflowFields = fields.slice(visibleFields.length); @@ -137,7 +137,7 @@ export function createSlideFormRegionHelpers(options) { ? placeholderChips.renderChip(field) : '{{' + escapeHtml(field) + '}}'; }).join(''); - var transformHint = '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, {{title.lower()}}, {{publishedAt.format("MMM D, YYYY")}}.
'; + var transformHint = includeTransformHint === false ? '' : '
Placeholder values support transforms, for example {{title.upper()}}, {{title.title()}}, {{title.lower()}}, {{publishedAt.format("MMM D, YYYY")}}.
'; if (!overflowFields.length) { return visibleMarkup + transformHint; @@ -417,7 +417,7 @@ export function createSlideFormRegionHelpers(options) { return; } - container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.'); + container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.', false); } function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) { @@ -561,7 +561,7 @@ export function createSlideFormRegionHelpers(options) { ? getRssFieldList(getCurrentRssConfig(region).feed_id).map(function (field) { return '{{' + escapeHtml(field) + '}}'; }).join('') - : buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'), + : buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.', false), placeholderFields: region.region_type === 'api' ? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path) : region.region_type === 'rss' @@ -602,7 +602,7 @@ export function createSlideFormRegionHelpers(options) { }).join(''), placeholderChips: region.region_type === 'rss' ? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.') - : buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'), + : buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.', false), placeholderFields: region.region_type === 'api' ? getApiFieldList(apiConfig.source_id, apiItemsPath) : region.region_type === 'rss' diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 454c37a..04a0851 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -654,7 +654,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev var content = typeModule && typeof typeModule.renderPreview === 'function' ? typeModule.renderPreview(region, previewContext, previewContext) : renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale); - if (typeModule && typeof typeModule.renderPreview === 'function') { + if (typeModule && typeof typeModule.renderPreview === 'function' && region.region_type !== 'api' && region.region_type !== 'rss') { content = content || renderPreviewTextRegion(region, previewContext.value, previewContext.style, scale); } var selected = region.region_type === 'image' diff --git a/test/slide-form-editor.test.js b/test/slide-form-editor.test.js index 1229473..a5940ed 100644 --- a/test/slide-form-editor.test.js +++ b/test/slide-form-editor.test.js @@ -23,6 +23,11 @@ test('slide editor enables server-backed image uploads', () => { assert.ok(slideFormEditorSource.includes('remove_script_host: false')); }); +test('slide editor keeps normal TinyMCE image insertion unchanged', () => { + assert.doesNotMatch(slideFormEditorSource, /bindPlaceholderImageSupport|placeholderFallbackSource/); + assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage')); +}); + test('slide editor inserts tables with zero padding and spacing by default', () => { assert.ok(slideFormEditorSource.includes("table_default_attributes: {")); assert.ok(slideFormEditorSource.includes("cellpadding: '0'"));