diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a3936f..cc6f727 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.3 - 2026-08-15
+
+### Changed
+
+- The slide image cropper now warns that SVG and GIF files will be rasterized if they are edited, and it keeps the original file only when the full image remains selected.
+- The slide image upload flow now accepts PNG, JPG, GIF, WebP, and SVG images, while the WYSIWYG image uploader now matches that same allowlist.
+- TIFF is no longer accepted by the WYSIWYG image uploader.
+
+### Fixed
+
+- The player now preserves quoted custom font-family values from rich text content, so fonts with spaces such as Old London render correctly on screens.
+
## 2.7.2 - 2026-08-14
### Fixed
diff --git a/build/package.player.json b/build/package.player.json
index b23fefb..d8a9526 100644
--- a/build/package.player.json
+++ b/build/package.player.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-player",
- "version": "2.7.2",
+ "version": "2.7.3",
"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 7499c24..186aa83 100644
--- a/build/package.web.json
+++ b/build/package.web.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage-web",
- "version": "2.7.2",
+ "version": "2.7.3",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"main": "src/common.js",
diff --git a/package-lock.json b/package-lock.json
index bed175e..5e901a1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
- "version": "2.6.15",
+ "version": "2.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "2.6.15",
+ "version": "2.7.3",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
diff --git a/package.json b/package.json
index 4f4cc2e..b86ef25 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "2.7.2",
+ "version": "2.7.3",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
diff --git a/src/player/public/js/player-page-rendering.js b/src/player/public/js/player-page-rendering.js
index b662bdf..594b3e8 100644
--- a/src/player/public/js/player-page-rendering.js
+++ b/src/player/public/js/player-page-rendering.js
@@ -4,6 +4,14 @@ function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
}
+function normalizeStyleAttributeValue(value) {
+ return String(value || '')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+}
+
// Clamp font size to the supported range.
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
@@ -401,7 +409,7 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return '';
}
}
- attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
+ attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
return '';
});
diff --git a/src/player/render-helpers.js b/src/player/render-helpers.js
index 4eb6dde..7fba5a7 100644
--- a/src/player/render-helpers.js
+++ b/src/player/render-helpers.js
@@ -25,6 +25,14 @@ function escapeHtml(value) {
.replace(/'/g, ''');
}
+function normalizeStyleAttributeValue(value) {
+ return String(value || '')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+}
+
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
@@ -106,7 +114,7 @@ function sanitizeRichTextAttributes(tagName, attrText) {
return '';
}
}
- attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
+ attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
return '';
});
diff --git a/src/web/lib/media/font-library.js b/src/web/lib/media/font-library.js
index 37c94b8..020431b 100644
--- a/src/web/lib/media/font-library.js
+++ b/src/web/lib/media/font-library.js
@@ -30,6 +30,43 @@ function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
+function quoteFontFamilyToken(token) {
+ const normalizedToken = normalizeText(token);
+ if (!normalizedToken) {
+ return '';
+ }
+
+ if (normalizedToken === 'inherit' || /^[a-zA-Z0-9_-]+$/.test(normalizedToken)) {
+ return normalizedToken;
+ }
+
+ return `'${normalizedToken.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
+}
+
+function normalizeFontFamilyFormat(format) {
+ return String(format || '')
+ .split(',')
+ .map(quoteFontFamilyToken)
+ .filter(Boolean)
+ .join(',');
+}
+
+function normalizeFontFamilyFormatEntry(entry) {
+ const value = String(entry || '').trim();
+ if (!value) {
+ return '';
+ }
+
+ const separatorIndex = value.indexOf('=');
+ if (separatorIndex === -1) {
+ return `${value}=${normalizeFontFamilyFormat(value)}`;
+ }
+
+ const label = value.slice(0, separatorIndex).trim();
+ const format = value.slice(separatorIndex + 1).trim();
+ return `${label}=${normalizeFontFamilyFormat(format || label)}`;
+}
+
function resolveMediaRoot(mediaRootOrUploadDir) {
const resolved = path.resolve(String(mediaRootOrUploadDir || '').trim());
return path.basename(resolved) === 'uploads' ? path.dirname(resolved) : resolved;
@@ -119,22 +156,19 @@ function buildFontFaceRule(entry) {
function buildFontStylesheet(fonts) {
const rules = (Array.isArray(fonts) ? fonts : [])
- .filter(function (font) {
- return font && font.enabled !== false;
- })
.map(buildFontFaceRule)
.filter(Boolean);
return rules.length ? `/* Managed fonts */\n\n${rules.join('\n\n')}\n` : '/* Managed fonts */\n';
}
function buildFontFamilyFormats(fonts) {
- const formatEntries = Array.from(new Set(DEFAULT_FONT_FAMILY_FORMATS.concat((Array.isArray(fonts) ? fonts : [])
+ const formatEntries = Array.from(new Set(DEFAULT_FONT_FAMILY_FORMATS.map(normalizeFontFamilyFormatEntry).concat((Array.isArray(fonts) ? fonts : [])
.filter(function (font) {
return font && font.enabled !== false && normalizeText(font.family || font.name);
})
.map(function (font) {
const family = normalizeText(font.family || font.name);
- return `${family}=${family}`;
+ return `${family}=${normalizeFontFamilyFormat(family)}`;
}))))
.sort(function (left, right) {
const leftLabel = String(left || '').split('=')[0];
@@ -344,7 +378,7 @@ function collectFontLibrarySyncOperations(mediaRootOrUploadDir) {
}
operations.push({
- type: font.enabled === false ? 'delete' : 'put',
+ type: 'put',
uploadPath: `/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}`
});
});
@@ -363,7 +397,10 @@ module.exports = {
collectFontLibraryDirectoryUploadPaths: collectFontLibraryDirectoryUploadPaths,
collectFontLibrarySyncOperations: collectFontLibrarySyncOperations,
getFontStylesheetHref: getFontStylesheetHref,
+ buildFontStylesheet: buildFontStylesheet,
buildFontFamilyFormats: buildFontFamilyFormats,
+ normalizeFontFamilyFormat: normalizeFontFamilyFormat,
+ normalizeFontFamilyFormatEntry: normalizeFontFamilyFormatEntry,
isSupportedFontUpload: isSupportedFontUpload,
getFontLibraryDir: getFontLibraryDir,
getFontManifestPath: getFontManifestPath,
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index 692aad5..868f0b1 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -1652,6 +1652,31 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
background: var(--bs-body-bg);
}
+.slide-image-cropper-frame.is-loading > :not(.slide-image-cropper-loading-overlay) {
+ opacity: 0.22;
+ filter: saturate(0.8);
+}
+
+.slide-image-cropper-loading-overlay {
+ position: absolute;
+ inset: 0;
+ z-index: 3;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(var(--bs-body-bg-rgb, 255, 255, 255), 0.72);
+ backdrop-filter: blur(1px);
+}
+
+.slide-image-cropper-frame.is-loading .slide-image-cropper-loading-overlay {
+ display: flex;
+}
+
+.slide-image-cropper-loading-spinner {
+ width: 2.5rem;
+ height: 2.5rem;
+}
+
.slide-image-cropper-frame img {
display: block;
max-width: 100%;
@@ -1689,6 +1714,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
white-space: nowrap;
}
+#slide-image-cropper-status:empty {
+ display: none;
+}
+
.slide-image-region-preview-box {
display: grid;
gap: 0;
@@ -2139,10 +2168,21 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.template-field-actions {
display: inline-flex;
align-items: center;
+ flex-wrap: wrap;
+ justify-content: flex-end;
gap: 0.5rem;
margin-left: auto;
}
+.template-editor-size-controls {
+ flex-shrink: 0;
+}
+
+.template-editor-size-controls .btn {
+ min-width: 2rem;
+ padding-inline: 0.45rem;
+}
+
.template-field-head strong {
font-size: 0.95rem;
min-width: 0;
@@ -2221,6 +2261,14 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
min-width: 0;
}
+.template-field-card .editor-holder[data-editor-height] {
+ overflow: hidden;
+}
+
+.template-field-card .editor-holder .tox.tox-tinymce {
+ height: 100% !important;
+}
+
.announcement-color-preview {
display: flex;
align-items: center;
diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js
index 7f8347a..5e2c9c5 100644
--- a/src/web/public/js/admin/admin-page.js
+++ b/src/web/public/js/admin/admin-page.js
@@ -539,6 +539,39 @@
return true;
}
+ function updateFontToggleRow(form) {
+ if (!form) {
+ return false;
+ }
+
+ var row = form.closest ? form.closest('tr[data-font-toggle-row]') : null;
+ if (!row) {
+ return false;
+ }
+
+ var enabledInput = form.querySelector('input[name="enabled"]');
+ var toggleButton = form.querySelector('button[type="submit"]');
+ var statusBadge = row.querySelector('[data-font-status-badge]');
+ var isCurrentlyEnabled = String(row.getAttribute('data-font-enabled') || '').trim() === 'true';
+ var willEnable = !isCurrentlyEnabled;
+
+ if (enabledInput) {
+ enabledInput.value = isCurrentlyEnabled ? '0' : '1';
+ }
+
+ if (toggleButton) {
+ toggleButton.textContent = willEnable ? 'Disable' : 'Enable';
+ }
+
+ if (statusBadge) {
+ statusBadge.className = statusBadge.className.replace(/text-bg-(success|secondary)/g, willEnable ? 'text-bg-success' : 'text-bg-secondary');
+ statusBadge.textContent = willEnable ? 'Enabled' : 'Disabled';
+ }
+
+ row.setAttribute('data-font-enabled', willEnable ? 'true' : 'false');
+ return true;
+ }
+
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
@@ -584,6 +617,15 @@
body: body.toString(),
credentials: 'same-origin'
}).then(function (response) {
+ if (!response || Number(response.status) >= 400) {
+ return;
+ }
+
+ if (/^\/settings\/fonts\/[^/]+\/toggle$/.test(actionPath)) {
+ updateFontToggleRow(form);
+ return;
+ }
+
if (shouldReloadAfterSuccess && response && response.ok) {
window.location.reload();
return;
diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js
index 0a2c9e8..db457ef 100644
--- a/src/web/public/js/regions/type/api.js
+++ b/src/web/public/js/regions/type/api.js
@@ -207,6 +207,12 @@
'' +
'' +
'
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
'
' +
'' +
'
' +
diff --git a/src/web/public/js/regions/type/image.js b/src/web/public/js/regions/type/image.js
index 1403744..e776a3e 100644
--- a/src/web/public/js/regions/type/image.js
+++ b/src/web/public/js/regions/type/image.js
@@ -32,8 +32,8 @@
var regionRatio = String(context.regionRatio || '1:1');
var uploadConfig = context.uploadConfig || {};
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
- var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp');
- var uploadHelpText = String(uploadConfig.helpText || 'PNG, JPG, GIF, or WebP');
+ var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp, image/svg+xml');
+ var uploadHelpText = String(uploadConfig.helpText || 'PNG, JPG, GIF, WebP, or SVG');
return registry.renderEditorCardShell({
region: region,
headerActions: '
Image',
@@ -81,10 +81,10 @@
current: String(context.current || ''),
regionRatio: String(context.regionRatio || '1:1'),
uploadConfig: {
- accept: Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
+ accept: Array.isArray(defaultConfig.accept) ? defaultConfig.accept : ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'],
limitBytes: defaultConfig.limitBytes,
limitLabel: defaultConfig.limitLabel || context.uploadMaxLabel || '100 MB',
- helpText: defaultConfig.helpText || 'PNG, JPG, GIF, or WebP'
+ helpText: defaultConfig.helpText || 'PNG, JPG, GIF, WebP, or SVG'
},
uploadMaxLabel: context.uploadMaxLabel || '100 MB'
};
@@ -107,11 +107,11 @@
}
var uploadConfig = context.uploadConfig || {};
- var accept = uploadConfig.accept || ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
+ var accept = uploadConfig.accept || ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
var limitBytes = Number(uploadConfig.limitBytes || 100 * 1024 * 1024);
if (!utils.fileMatchesAccept || !utils.fileMatchesAccept(context.file, accept)) {
- showUploadWarning('This image region accepts PNG, JPG, GIF, or WebP files.');
+ showUploadWarning('This image region accepts PNG, JPG, GIF, WebP, or SVG files.');
return false;
}
@@ -132,10 +132,10 @@
var defaultConfig = context && context.defaultConfig ? context.defaultConfig : {};
return {
- accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
+ accept: ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'],
limitBytes: defaultConfig.limitBytes,
limitLabel: defaultConfig.limitLabel,
- helpText: 'PNG, JPG, GIF, or WebP'
+ helpText: 'PNG, JPG, GIF, WebP, or SVG'
};
},
renderPreview: renderPreview,
diff --git a/src/web/public/js/regions/type/rss.js b/src/web/public/js/regions/type/rss.js
index 4241846..05ec44d 100644
--- a/src/web/public/js/regions/type/rss.js
+++ b/src/web/public/js/regions/type/rss.js
@@ -222,6 +222,12 @@
'
' +
'' +
'' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
'
' +
'' +
'
' +
diff --git a/src/web/public/js/regions/type/text.js b/src/web/public/js/regions/type/text.js
index 245e5b2..db3a981 100644
--- a/src/web/public/js/regions/type/text.js
+++ b/src/web/public/js/regions/type/text.js
@@ -54,7 +54,13 @@
'
Text' +
'
' +
'' +
- '' +
+ '
' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
'
' +
'' +
'
' +
diff --git a/src/web/public/js/regions/type/time-date.js b/src/web/public/js/regions/type/time-date.js
index 9d782de..9919a2a 100644
--- a/src/web/public/js/regions/type/time-date.js
+++ b/src/web/public/js/regions/type/time-date.js
@@ -302,6 +302,12 @@
'
' +
'
' +
'' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
'
' +
'' +
'
' +
diff --git a/src/web/public/js/regions/type/timetable.js b/src/web/public/js/regions/type/timetable.js
index 3630e2e..b9a64ef 100644
--- a/src/web/public/js/regions/type/timetable.js
+++ b/src/web/public/js/regions/type/timetable.js
@@ -265,6 +265,12 @@
'
Timetable
' +
'
' +
'' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
'
' +
'' +
'' +
diff --git a/src/web/public/js/slides/slide-form-editor.js b/src/web/public/js/slides/slide-form-editor.js
index a90711f..70f5ebe 100644
--- a/src/web/public/js/slides/slide-form-editor.js
+++ b/src/web/public/js/slides/slide-form-editor.js
@@ -8,6 +8,8 @@ export function createSlideFormEditorController(options) {
var fontStylesheetHref = String(settings.fontStylesheetHref || '').trim();
var defaultEditorFontFamily = 'Arial, Helvetica, sans-serif';
var editorInstances = new Map();
+ var editorHeights = new Map();
+ var editorSizeControlsBound = false;
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () {
return null;
};
@@ -15,9 +17,13 @@ export function createSlideFormEditorController(options) {
var imageUploadMaxBytes = Math.max(1, Number(settings.imageUploadMaxBytes || 2 * 1024 * 1024));
var imageUploadLimitLabel = String(settings.imageUploadLimitLabel || '').trim() || Math.max(1, Math.round(imageUploadMaxBytes / (1024 * 1024))) + ' MB';
var imageUploadContext = String(settings.imageUploadContext || 'wysiwyg').trim() || 'wysiwyg';
- var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff'];
- var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/avif', 'image/tiff'];
+ var imageUploadAllowedExtensions = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'];
+ var imageUploadAllowedMimeTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
var imageUploadFileTypes = imageUploadAllowedExtensions.join(',');
+ var wysiwygEditorHeightStep = Math.max(1, Number(settings.wysiwygEditorHeightStep || 80));
+ var wysiwygEditorHeightMin = Math.max(1, Number(settings.wysiwygEditorHeightMin || 240));
+ var wysiwygEditorHeightMax = Math.max(wysiwygEditorHeightMin, Number(settings.wysiwygEditorHeightMax || 960));
+ var wysiwygEditorHeightDefault = Math.max(wysiwygEditorHeightMin, Number(settings.wysiwygEditorHeightDefault || 360));
var editorImageUploadPaths = new Set();
var committedEditorImageUploadPaths = new Set();
var pendingEditorImageUploadCleanupPaths = new Set();
@@ -64,6 +70,99 @@ export function createSlideFormEditorController(options) {
return '';
}
+ function normalizeEditorHeightValue(value) {
+ var numericValue = Number(value);
+ if (!Number.isFinite(numericValue) || numericValue <= 0) {
+ numericValue = wysiwygEditorHeightDefault;
+ }
+
+ return Math.min(wysiwygEditorHeightMax, Math.max(wysiwygEditorHeightMin, Math.round(numericValue)));
+ }
+
+ function getEditorHolder(regionId) {
+ if (!templateFields) {
+ return null;
+ }
+
+ return templateFields.querySelector('.editor-holder[data-region-id="' + regionId + '"]');
+ }
+
+ function getEditorHeight(regionId) {
+ var key = String(regionId || '');
+ var storedHeight = editorHeights.get(key);
+ if (storedHeight !== undefined && storedHeight !== null) {
+ return normalizeEditorHeightValue(storedHeight);
+ }
+
+ var holder = getEditorHolder(key);
+ var holderHeight = holder && holder.dataset ? holder.dataset.editorHeight : '';
+ var normalizedHeight = normalizeEditorHeightValue(holderHeight || wysiwygEditorHeightDefault);
+ editorHeights.set(key, normalizedHeight);
+ return normalizedHeight;
+ }
+
+ function applyEditorHeight(regionId, height, options) {
+ var key = String(regionId || '');
+ var normalizedHeight = normalizeEditorHeightValue(height);
+ var holder = getEditorHolder(key);
+ var editor = editorInstances.get(key);
+ var container = editor && typeof editor.getContainer === 'function' ? editor.getContainer() : null;
+ var shouldRequestPreview = !(options && options.silent);
+
+ editorHeights.set(key, normalizedHeight);
+
+ if (holder) {
+ holder.dataset.editorHeight = String(normalizedHeight);
+ holder.style.height = normalizedHeight + 'px';
+ holder.style.minHeight = normalizedHeight + 'px';
+ }
+
+ if (container) {
+ container.style.height = normalizedHeight + 'px';
+ container.style.minHeight = normalizedHeight + 'px';
+ }
+
+ if (editor && typeof editor.dispatch === 'function') {
+ editor.dispatch('ResizeEditor');
+ }
+
+ if (shouldRequestPreview) {
+ requestPreviewRender();
+ }
+ }
+
+ function adjustEditorHeight(regionId, delta) {
+ applyEditorHeight(regionId, getEditorHeight(regionId) + Number(delta || 0));
+ }
+
+ function bindEditorSizeControls() {
+ if (editorSizeControlsBound || !templateFields) {
+ return;
+ }
+
+ editorSizeControlsBound = true;
+ templateFields.addEventListener('click', function (event) {
+ var button = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-editor-size-action]') : null;
+ if (!button) {
+ return;
+ }
+
+ var action = String(button.getAttribute('data-editor-size-action') || '').trim();
+ if (action !== 'increase' && action !== 'decrease') {
+ return;
+ }
+
+ var card = typeof button.closest === 'function' ? button.closest('[data-region-id]') : null;
+ var regionId = card ? card.getAttribute('data-region-id') : '';
+ if (!regionId) {
+ return;
+ }
+
+ event.preventDefault();
+ adjustEditorHeight(regionId, action === 'increase' ? wysiwygEditorHeightStep : -wysiwygEditorHeightStep);
+ });
+ }
+
function getEditorHiddenInput(regionId) {
if (!templateFields) {
return null;
@@ -314,15 +413,15 @@ export function createSlideFormEditorController(options) {
}
if (mimeType && imageUploadAllowedMimeTypes.indexOf(mimeType) === -1) {
- return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
+ return 'This editor accepts PNG, JPG, GIF, WebP, or SVG images.';
}
if (!mimeType && extension && imageUploadAllowedExtensions.indexOf(extension) === -1) {
- return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
+ return 'This editor accepts PNG, JPG, GIF, WebP, or SVG images.';
}
if (!mimeType && !extension) {
- return 'This editor accepts PNG, JPG, GIF, WebP, BMP, AVIF, or TIFF images.';
+ return 'This editor accepts PNG, JPG, GIF, WebP, or SVG images.';
}
return '';
@@ -470,6 +569,8 @@ export function createSlideFormEditorController(options) {
source.id = 'slide-editor-region-' + regionId;
}
+ applyEditorHeight(regionId, getEditorHeight(regionId), { silent: true });
+
function registerInlineFormat(editor, formatName, styles) {
editor.formatter.register(formatName, {
inline: 'span',
@@ -515,6 +616,8 @@ export function createSlideFormEditorController(options) {
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table image chip | fullscreen',
toolbar_mode: 'sliding',
license_key: 'gpl',
+ height: getEditorHeight(regionId),
+ min_height: wysiwygEditorHeightMin,
table_default_attributes: {
border: '1',
cellpadding: '0',
@@ -601,6 +704,7 @@ export function createSlideFormEditorController(options) {
}
editorInstances.set(regionId, editor);
+ applyEditorHeight(regionId, getEditorHeight(regionId), { silent: true });
if (editor.targetElm) {
editor.targetElm.value = editor.getContent({ format: 'html' });
}
@@ -617,6 +721,7 @@ export function createSlideFormEditorController(options) {
return Promise.resolve([]);
}
+ bindEditorSizeControls();
watchThemeChanges();
var holders = Array.prototype.slice.call(templateFields.querySelectorAll('.editor-holder'));
diff --git a/src/web/public/js/slides/slide-image-cropper.js b/src/web/public/js/slides/slide-image-cropper.js
index 21cbed5..cd79f2b 100644
--- a/src/web/public/js/slides/slide-image-cropper.js
+++ b/src/web/public/js/slides/slide-image-cropper.js
@@ -14,6 +14,7 @@
var currentInput = null;
var currentFile = null;
var currentObjectUrl = '';
+ var cropperFrame = modal.querySelector('.slide-image-cropper-frame');
var flipX = 1;
var flipY = 1;
var currentAspectRatio = NaN;
@@ -40,6 +41,12 @@
status.textContent = String(message || '');
}
+ function setCropperLoading(loading) {
+ if (cropperFrame) {
+ cropperFrame.classList.toggle('is-loading', Boolean(loading));
+ }
+ }
+
function showModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal);
@@ -79,6 +86,7 @@
function resetModalState() {
destroyCropper();
revokeObjectUrl();
+ setCropperLoading(false);
currentInput = null;
currentFile = null;
flipX = 1;
@@ -118,6 +126,75 @@
return width / height;
}
+ function isSvgFile(file) {
+ if (!file) {
+ return false;
+ }
+
+ return String(file.type || '').toLowerCase() === 'image/svg+xml' || /\.svg$/i.test(String(file.name || ''));
+ }
+
+ function isGifFile(file) {
+ if (!file) {
+ return false;
+ }
+
+ return String(file.type || '').toLowerCase() === 'image/gif' || /\.gif$/i.test(String(file.name || ''));
+ }
+
+ function isSpecialRasterizationFile(file) {
+ return isSvgFile(file) || isGifFile(file);
+ }
+
+ function getSpecialFileWarning(file) {
+ if (isGifFile(file)) {
+ return 'GIF selected. If you crop, rotate, or flip this image, it will be rasterized and the animation will be lost. Leave the full image selected to keep the original GIF.';
+ }
+
+ return 'SVG selected. If you crop, rotate, or flip this image, it will be rasterized. Leave the full image selected to keep the original SVG.';
+ }
+
+ function isOriginalSpecialSelection() {
+ if (!cropper || !currentFile || !isSpecialRasterizationFile(currentFile) || typeof cropper.getData !== 'function' || typeof cropper.getImageData !== 'function') {
+ return false;
+ }
+
+ var cropData = cropper.getData(true) || {};
+ var imageData = cropper.getImageData() || {};
+ var width = Number(imageData.naturalWidth || 0);
+ var height = Number(imageData.naturalHeight || 0);
+
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
+ return false;
+ }
+
+ return Math.abs(Number(cropData.x || 0)) < 0.5 &&
+ Math.abs(Number(cropData.y || 0)) < 0.5 &&
+ Math.abs(Number(cropData.width || 0) - width) < 0.5 &&
+ Math.abs(Number(cropData.height || 0) - height) < 0.5 &&
+ Math.abs(Number(cropData.rotate || 0)) < 0.5 &&
+ Number(cropData.scaleX || 1) === 1 &&
+ Number(cropData.scaleY || 1) === 1;
+ }
+
+ function getRasterizedFileName(sourceName) {
+ var name = String(sourceName || '').trim();
+
+ if (!name) {
+ return 'slide-region-image.png';
+ }
+
+ if (/\.svg$/i.test(name)) {
+ return name.replace(/\.svg$/i, '.png');
+ }
+
+ if (/\.[a-z0-9]+$/i.test(name)) {
+ return name.replace(/\.[a-z0-9]+$/i, '.png');
+ }
+
+ return name + '.png';
+ }
+
function setActiveRatioButton(value) {
var targetValue = ratioLabelForValue(value);
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
@@ -183,6 +260,8 @@
zoomOnTouch: true,
zoomOnWheel: true,
ready: function () {
+ setCropperLoading(false);
+
if (cropper && cropper.container) {
cropper.container.style.width = '100%';
cropper.container.style.height = '560px';
@@ -209,7 +288,10 @@
setButtonState(false);
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
- setStatus('Use the toolbar to crop, rotate, or flip the image before applying it.');
+
+ if (isSpecialRasterizationFile(currentFile)) {
+ setStatus(getSpecialFileWarning(currentFile));
+ }
}
function openEditor(input, file) {
@@ -220,8 +302,9 @@
currentAspectRatio = 'free';
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
+ setCropperLoading(true);
setButtonState(true);
- setStatus('Loading image editor...');
+ setStatus(isSpecialRasterizationFile(file) ? getSpecialFileWarning(file) : '');
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
@@ -286,11 +369,62 @@
hideModal();
}
+ function finalizeCurrentSvgSelection() {
+ if (!currentFile) {
+ return;
+ }
+
+ if (isOriginalSpecialSelection()) {
+ finalizeCropFile(currentFile);
+ return;
+ }
+
+ setButtonState(true);
+ setStatus('Creating a rasterized image from the crop...');
+
+ var canvas = cropper && typeof cropper.getCroppedCanvas === 'function' ? cropper.getCroppedCanvas({
+ fillColor: 'transparent',
+ imageSmoothingEnabled: true,
+ imageSmoothingQuality: 'high'
+ }) : null;
+
+ if (!canvas) {
+ setStatus('Unable to create the cropped image.');
+ setButtonState(false);
+ return;
+ }
+
+ canvas.toBlob(function (blob) {
+ if (!blob) {
+ setStatus('Unable to create the cropped image.');
+ setButtonState(false);
+ return;
+ }
+
+ var finalFile = new File([blob], getRasterizedFileName(currentFile.name), {
+ lastModified: Date.now(),
+ type: blob.type || 'image/png'
+ });
+
+ finalizeCropFile(finalFile);
+ }, 'image/png');
+ }
+
function commitCrop() {
if (!currentInput || !currentFile) {
return;
}
+ if (isSpecialRasterizationFile(currentFile)) {
+ if (!cropper) {
+ finalizeCropFile(currentFile);
+ return;
+ }
+
+ finalizeCurrentSvgSelection();
+ return;
+ }
+
if (!cropper) {
finalizeCropFile(currentFile);
return;
@@ -381,7 +515,7 @@
return;
}
- if (!/^image\//i.test(file.type || '')) {
+ if (!/^image\//i.test(file.type || '') && !/\.svg$/i.test(String(file.name || ''))) {
input.value = '';
return;
}
diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js
index b4407ff..f2ee112 100644
--- a/src/web/routes/admin/content.js
+++ b/src/web/routes/admin/content.js
@@ -110,14 +110,14 @@ module.exports = function registerContentRoutes(app, deps) {
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
const isWysiwyg = String(uploadContext || '').trim().toLowerCase() === 'wysiwyg';
- if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) === -1) {
+ if (isWysiwyg && mimeType.indexOf('image/') !== 0 && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].indexOf(extension) === -1) {
return null;
}
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
return 'video';
}
- if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) !== -1) {
+ if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].indexOf(extension) !== -1) {
return 'image';
}
return null;
diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js
index 4abfc07..de9cc47 100644
--- a/src/web/routes/settings/fonts.js
+++ b/src/web/routes/settings/fonts.js
@@ -3,6 +3,10 @@
const fs = require('fs');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const fontLibrary = require('#src/web/lib/media/font-library');
+const { buildPagination } = require('../../lib/pagination');
+const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('../../lib/list-query');
+
+const LIST_PAGE_SIZE = 25;
function normalizeFontFamilyName(value) {
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
@@ -43,6 +47,48 @@ async function isFontUsed(pool, family) {
return Number(rows && rows[0] && rows[0].match_count) > 0;
}
+async function buildFontsPageData(pool, uploadDir, req, common) {
+ const library = fontLibrary.loadFontLibrary(uploadDir);
+ const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
+ const search = common && typeof common.getSearchQuery === 'function' ? common.getSearchQuery(req) : getSearchQuery(req);
+ const sort = common && typeof common.getSortQuery === 'function' ? common.getSortQuery(req) : getSortQuery(req);
+ const direction = common && typeof common.getSortDirectionQuery === 'function' ? common.getSortDirectionQuery(req) : getSortDirectionQuery(req);
+ const searchableFonts = sortRows(library.fonts.map(function (font) {
+ const family = normalizeFontFamilyName(font.family || font.name);
+ return Object.assign({}, font, {
+ inUse: usedFamilies.has(family)
+ });
+ }), function (font) {
+ if (sort === 'file') {
+ return String(font && font.fileName || '').trim();
+ }
+ if (sort === 'format') {
+ return String(font && font.format || '').trim();
+ }
+ if (sort === 'status') {
+ return font && font.enabled ? 'Enabled' : 'Disabled';
+ }
+
+ return String(font && font.family || font && font.name || '').trim();
+ }, direction).filter(createSearchMatcher(search, [
+ 'family',
+ 'name',
+ 'fileName',
+ 'format',
+ function (font) {
+ return font && font.enabled ? 'enabled' : 'disabled';
+ }
+ ]));
+ const pagination = buildPagination(searchableFonts.length, parsePageNumber(req && req.query && req.query.page), 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'fonts', 'Font pages');
+ const startIndex = (pagination.currentPage - 1) * LIST_PAGE_SIZE;
+
+ return {
+ fonts: searchableFonts.slice(startIndex, startIndex + LIST_PAGE_SIZE),
+ pagination: pagination,
+ stylesheetHref: library.stylesheetHref
+ };
+}
+
function redirectToLogin(res, setAuthMessageCookie) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
@@ -71,6 +117,7 @@ function requireFontsAccess(setAuthMessageCookie) {
module.exports = function registerFontRoutes(app, deps) {
const pool = deps.pool;
+ const common = deps.common || {};
const pages = deps.pages;
const upload = deps.upload;
const uploadDir = deps.uploadDir;
@@ -85,18 +132,8 @@ module.exports = function registerFontRoutes(app, deps) {
app.get('/settings/fonts', requireFontsAccess(setAuthMessageCookie), async function (req, res, next) {
try {
- const library = fontLibrary.loadFontLibrary(uploadDir);
- const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
- const fonts = library.fonts.map(function (font) {
- const family = normalizeFontFamilyName(font.family || font.name);
- return Object.assign({}, font, {
- inUse: usedFamilies.has(family)
- });
- });
- res.send(pages.renderFontsPage({
- fonts: fonts,
- stylesheetHref: library.stylesheetHref
- }, req.query.message ? String(req.query.message) : '', req.currentUser));
+ const data = await buildFontsPageData(pool, uploadDir, req, common);
+ res.send(pages.renderFontsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
@@ -132,19 +169,9 @@ module.exports = function registerFontRoutes(app, deps) {
}
if (error && /already exists/i.test(String(error.message || ''))) {
- const library = fontLibrary.loadFontLibrary(uploadDir);
- const usedFamilies = await fetchUsedFontFamilies(pool, library.fonts);
- const fonts = library.fonts.map(function (font) {
- const family = normalizeFontFamilyName(font.family || font.name);
- return Object.assign({}, font, {
- inUse: usedFamilies.has(family)
- });
- });
+ const data = await buildFontsPageData(pool, uploadDir, req, common);
- return res.status(400).send(pages.renderFontsPage({
- fonts: fonts,
- stylesheetHref: library.stylesheetHref
- }, String(error.message || 'Font already exists.'), req.currentUser));
+ return res.status(400).send(pages.renderFontsPage(data, String(error.message || 'Font already exists.'), req.currentUser));
}
next(error);
diff --git a/src/web/routes/settings/fonts/list.js b/src/web/routes/settings/fonts/list.js
index ddb2428..430f314 100644
--- a/src/web/routes/settings/fonts/list.js
+++ b/src/web/routes/settings/fonts/list.js
@@ -9,6 +9,7 @@ module.exports = function renderFontsPage(data, message, currentUser) {
message: message,
currentUser: currentUser || null,
fonts: data.fonts || [],
+ pagination: data.pagination || null,
stylesheetHref: data.stylesheetHref || ''
});
};
\ No newline at end of file
diff --git a/src/web/views/settings/fonts/list.hbs b/src/web/views/settings/fonts/list.hbs
index 1475bc9..8e5c920 100644
--- a/src/web/views/settings/fonts/list.hbs
+++ b/src/web/views/settings/fonts/list.hbs
@@ -5,82 +5,91 @@
-