diff --git a/CHANGELOG.md b/CHANGELOG.md
index 72487a8..5670fa3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file.
+## 2.7.0 - 2026-08-14
+
+### Added
+
+- The WYSIWYG editor now supports adding small images.
+
+### Changed
+
+- The WYSIWYG image insertion flow also received a small code cleanup to simplify the related helper logic.
+- The default table formatting has been applied.
+- Timetable regions now use the renamed helpers end to end in the editor and player, including timezone-aware rendering for timetable entry placeholders.
+
## 2.6.27 - 2026-08-14
### Fixed
diff --git a/build/package.player.json b/build/package.player.json
index b8fb850..86c102e 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
- "version": "2.6.27",
+ "version": "2.7.0",
"private": false,
"description": "Pulse Signage player application bundle",
"main": "src/common.js",
diff --git a/build/package.web.json b/build/package.web.json
index 24ad31e..bc13912 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
- "version": "2.6.27",
+ "version": "2.7.0",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js",
diff --git a/package.json b/package.json
index 5e4cf15..ddba884 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "2.6.27",
+ "version": "2.7.0",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
diff --git a/src/data/slides.js b/src/data/slides.js
index 8a687c8..6b5bccf 100644
--- a/src/data/slides.js
+++ b/src/data/slides.js
@@ -6,27 +6,90 @@ const { parseJsonSafe, validateMaxLength } = require('./utils');
const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('./qr-code');
-const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
const DEFAULT_FONT_SIZE = 32;
+const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
+
+function sanitizeRichTextAttributes(tagName, attrText) {
+ const allowedAttributes = {
+ a: ['href', 'title', 'target', 'rel', 'class', 'style'],
+ blockquote: ['class', 'style'],
+ div: ['class', 'style'],
+ figure: ['class', 'style'],
+ figcaption: ['class', 'style'],
+ h1: ['class', 'style'],
+ h2: ['class', 'style'],
+ h3: ['class', 'style'],
+ h4: ['class', 'style'],
+ h5: ['class', 'style'],
+ h6: ['class', 'style'],
+ img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
+ li: ['class', 'style'],
+ ol: ['class', 'style', 'start'],
+ p: ['class', 'style'],
+ pre: ['class', 'style'],
+ span: ['class', 'style'],
+ table: ['class', 'style'],
+ td: ['class', 'style', 'colspan', 'rowspan'],
+ th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
+ tr: ['class', 'style'],
+ ul: ['class', 'style']
+ };
+ const allowed = allowedAttributes[tagName] || [];
+ if (!allowed.length) {
+ return '';
+ }
+
+ const attrs = [];
+ String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
+ const lowerKey = String(key || '').toLowerCase();
+ if (!allowed.includes(lowerKey)) {
+ return '';
+ }
+
+ const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
+ if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
+ return '';
+ }
+ if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
+ return '';
+ }
+ if (lowerKey === 'target') {
+ const targetValue = String(value || '').trim();
+ if (targetValue === '_blank') {
+ attrs.push(' target="_blank"');
+ if (!attrs.includes(' rel="noreferrer noopener"')) {
+ attrs.push(' rel="noreferrer noopener"');
+ }
+ return '';
+ }
+ }
+ attrs.push(' ' + lowerKey + '="' + String(value || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + '"');
+ return '';
+ });
+
+ return attrs.join('');
+}
+
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/Text';
+ const html = '
badok
plain text
'); +}); + +test('timetable region registers the timetable type', () => { + assert.ok(timetableRegionSource.includes("registry.register('timetable'")); + assert.ok(timetableRegionSource.includes("sanitizeRichText(substituteTimetableVariables(value")); }); \ No newline at end of file diff --git a/test/player-time-date-region.test.js b/test/player-time-date-region.test.js new file mode 100644 index 0000000..5afe250 --- /dev/null +++ b/test/player-time-date-region.test.js @@ -0,0 +1,65 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function loadTimeDateModule() { + const webUiHelpersScript = fs.readFileSync(require.resolve('../src/web/public/js/web-ui-helpers.js'), 'utf8'); + const renderingScript = fs.readFileSync(require.resolve('../src/player/public/js/player-page-rendering.js'), 'utf8'); + const placeholderScript = fs.readFileSync(require.resolve('../src/web/public/js/shared/placeholder-utils.js'), 'utf8'); + const timeDateScript = fs.readFileSync(require.resolve('../src/player/regions/time-date.js'), 'utf8'); + const registry = new Map(); + const sandbox = { + document: { + addEventListener() {} + }, + window: { + pulsePlayerRegionTypes: { + register(type, module) { + registry.set(type, module); + } + }, + innerWidth: 1280, + innerHeight: 720, + Intl: Intl, + Date: Date, + Object: Object, + Array: Array, + Number: Number, + String: String, + Boolean: Boolean, + Math: Math, + JSON: JSON, + RegExp: RegExp, + console: console + } + }; + + sandbox.window = Object.assign({}, sandbox.window); + + vm.runInNewContext(webUiHelpersScript, sandbox, { filename: 'web-ui-helpers.js' }); + sandbox.escapeHtml = sandbox.window.escapeHtml; + vm.runInNewContext(renderingScript, sandbox, { filename: 'player-page-rendering.js' }); + vm.runInNewContext(placeholderScript, sandbox, { filename: 'placeholder-utils.js' }); + vm.runInNewContext(timeDateScript, sandbox, { filename: 'time-date.js' }); + + return registry.get('time-date'); +} + +test('time/date region renders placeholder tokens on the player side', () => { + const module = loadTimeDateModule(); + const markup = module.renderRegion( + { + pixelWidth: 320, + pixelHeight: 180, + canvasScale: 1, + baseStyle: 'position:absolute;' + }, + { + value: '{{hh}}:{{mm}}', + timezone: 'UTC' + } + ); + + assert.match(markup, /\d{2}:\d{2}<\/p>/);
+});
\ No newline at end of file
diff --git a/test/slide-form-editor.test.js b/test/slide-form-editor.test.js
index 06ea893..72ffcb0 100644
--- a/test/slide-form-editor.test.js
+++ b/test/slide-form-editor.test.js
@@ -3,7 +3,57 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
+const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
+const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
+const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
test('slide editor disables pasted data images in TinyMCE', () => {
assert.ok(slideFormEditorSource.includes('paste_data_images: false'));
+});
+
+test('slide editor enables server-backed image uploads', () => {
+ assert.ok(slideFormEditorSource.includes("plugins: 'lists code advlist fullscreen table image'"));
+ assert.ok(slideFormEditorSource.includes('automatic_uploads: true'));
+ assert.ok(slideFormEditorSource.includes('images_file_types: imageUploadFileTypes'));
+ assert.ok(slideFormEditorSource.includes('images_upload_handler: uploadEditorImage'));
+ assert.ok(slideFormEditorSource.includes('table image chip | fullscreen'));
+ assert.ok(slideFormEditorSource.includes('relative_urls: false'));
+ assert.ok(slideFormEditorSource.includes('remove_script_host: false'));
+});
+
+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'"));
+ assert.ok(slideFormEditorSource.includes("cellspacing: '0'"));
+ assert.ok(slideFormEditorSource.includes('td, th { border: 1px solid currentColor; padding: 0; vertical-align: top; }'));
+});
+
+test('slide editor uses a smaller wysiwyg image limit', () => {
+ assert.ok(slideFormEditorSource.includes('var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));'));
+ assert.ok(slideFormEditorSource.includes('Image must be '));
+ assert.ok(slideFormEditorSource.includes('Larger images should use the dedicated Image region.'));
+ assert.ok(slideFormEditorSource.includes("xhr.setRequestHeader('X-Upload-Context', imageUploadContext);"));
+});
+
+test('slide editor tracks uploaded image paths for cleanup', () => {
+ assert.ok(slideFormEditorSource.includes('var editorImageUploadPaths = new Set();'));
+ assert.ok(slideFormEditorSource.includes('getImageUploadCleanupPaths'));
+ assert.ok(slideFormEditorSource.includes('getCommittedImageUploadCleanupPaths'));
+ assert.ok(slideFormEditorSource.includes('getPendingImageUploadPaths'));
+ assert.ok(slideFormEditorSource.includes('clearImageUploadPaths'));
+});
+
+test('slide editor keeps image-only rich text from being treated as empty', () => {
+ assert.ok(slideFormEditorSource.includes('/ {
+ assert.ok(slideFormSource.includes('getImageUploadCleanupPaths'));
+ assert.ok(slideFormSource.includes('getCommittedImageUploadCleanupPaths'));
+ assert.ok(slideFormSource.includes("regionMediaController.queueUploadCleanup(slideFormEditorController.getImageUploadCleanupPaths())"));
+});
+
+test('slide thumbnail previews treat image-only text as visible content', () => {
+ assert.ok(slideThumbnailPreviewSource.includes('/
{
assert.equal(content.timetable.max_items, '7');
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_display_mode'), false);
assert.equal(Object.prototype.hasOwnProperty.call(content.timetable, 'timetable_max_items'), false);
+});
+
+test('buildSlidePayload preserves safe image markup in text regions', async () => {
+ const pool = {
+ async query(sql) {
+ if (sql.includes('FROM c_templates st')) {
+ return [[{ id: 9, name: 'Template 9', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
+ }
+
+ if (sql.includes('FROM c_template_regions')) {
+ return [[{ id: 47, template_id: 9, region_key: 'body', region_type: 'text', label: 'Body' }]];
+ }
+
+ return [[]];
+ }
+ };
+
+ const payload = await buildSlidePayload(pool, {
+ body: {
+ title: 'Text slide',
+ template_id: '9',
+ region_text_47: '


Hello
' + }, + files: [] + }, null); + + const content = JSON.parse(payload.contentJson); + assert.equal(content.body.type, 'text'); + assert.equal(content.body.value, 'Hello
'); }); \ No newline at end of file