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('
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() : '') + + ']*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 || '') + '' +
'' +
'' +
@@ -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 ? '' : '';
}
+ 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 || '') + '' +
- '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 = 'Placeholder Output Description ' + placeholders.map(function (item) {
+ return '{{' + escapeHtml(item.token) + '}}' + escapeHtml(item.output) + ' ' + escapeHtml(item.description) + ' ';
+ }).join('') + '
';
+ 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 @@
'' +
'' +
'' +
- '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.
' +
+ 'Token Meaning Example ' +
+ 'YYYYFour-digit year 2026YYTwo-digit year 26 ' +
+ 'MMMMFull month name AugustMMMShort month name Aug ' +
+ 'MMTwo-digit month 08MMonth number 8 ' +
+ 'DDTwo-digit day 16DDay number 16 ' +
+ 'ddddFull weekday name SundaydddShort weekday name Sun ' +
+ 'HH / H24-hour time 19 / 7hh / h12-hour time 07 / 7 ' +
+ 'mm / mMinutes 05 / 5ss / sSeconds 09 / 9 ' +
+ 'A / aAM or PM PM / pm ' +
+ '
';
+ }
+
+ function renderImageTransform() {
+ return 'Image transform
' +
+ 'Use image(width, height) to render an image URL inside a proportional bounding box.
' +
+ '- The preview shows the width and height boundary.
- The player has no boundary.
- The image keeps its original aspect ratio.
- Both dimensions are required for a 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'"));