Refresh player and admin UI

This commit is contained in:
2026-07-14 20:52:39 +01:00
parent 4e0e87c86c
commit 31b10e6fd1
247 changed files with 2849 additions and 1842 deletions
+323 -129
View File
@@ -1,3 +1,27 @@
import {
Alignment,
BlockQuote,
Bold,
ClassicEditor,
Essentials,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
Heading,
HorizontalLine,
Indent,
IndentBlock,
Italic,
InputTextView,
Plugin,
Paragraph,
Strikethrough,
Subscript,
Superscript,
Underline
} from '/assets/js/ckeditor5/ckeditor5.js';
(function () {
var dataElement = document.getElementById('slide-editor-data');
if (!dataElement) {
@@ -25,6 +49,99 @@
var editorInstances = new Map();
var submitting = false;
class FontSizeInputUI extends Plugin {
static get pluginName() {
return 'FontSizeInputUI';
}
init() {
var editor = this.editor;
var fontSizeCommand = editor.commands.get('fontSize');
var plugin = this;
if (!fontSizeCommand) {
return;
}
editor.ui.componentFactory.add('fontSizeInput', function (locale) {
var view = new InputTextView(locale);
view.extendTemplate({
attributes: {
class: ['ck-font-size-input']
}
});
view.set({
placeholder: 'Font size',
ariaLabel: 'Font size'
});
function getCurrentFontSizeValue() {
var value = fontSizeCommand.value;
if (value === null || value === undefined || value === '') {
return '';
}
var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : '';
}
function syncValue() {
if (view.element && document.activeElement === view.element) {
return;
}
view.value = getCurrentFontSizeValue();
}
view.on('render', function () {
if (!view.element) {
return;
}
view.element.type = 'text';
view.element.inputMode = 'numeric';
view.element.autocomplete = 'off';
view.element.spellcheck = false;
syncValue();
});
function applyValue() {
if (!view.element) {
return;
}
var size = Math.max(1, Math.round(Number(view.element.value || 0)));
if (!size) {
return;
}
editor.execute('fontSize', { value: size + 'px' });
}
view.on('input', applyValue);
view.on('blur', applyValue);
view.on('keydown', function (_eventInfo, domEvent) {
if (domEvent.key === 'Enter') {
domEvent.preventDefault();
if (view.element) {
view.element.blur();
}
}
});
plugin.listenTo(editor.model.document.selection, 'change:range', syncValue);
plugin.listenTo(fontSizeCommand, 'change:value', syncValue);
syncValue();
return view;
});
}
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
@@ -44,25 +161,7 @@
}
function normalizeEditorData(value) {
var raw = String(value || '');
if (!raw) {
return { time: Date.now(), blocks: [] };
}
try {
var parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed;
}
} catch (_error) {
// fall through to legacy HTML/plain text
}
return {
time: Date.now(),
blocks: [{
type: 'paragraph',
data: { text: raw }
}]
};
return String(value || '');
}
function sanitizePreviewHtml(html) {
@@ -70,23 +169,87 @@
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code', 'table', 'thead', 'tbody', 'tr', 'th', 'td'];
var attrText = String(match[3] || '');
var selfClosing = Boolean(match[4]) || name === 'br' || name === 'hr';
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (name === 'br') {
return '<br>';
if (closing) {
return '</' + name + '>';
}
return closing ? '</' + name + '>' : '<' + name + '>';
if (selfClosing) {
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
}
return '<' + name + sanitizeTagAttributes(name, attrText) + '>';
});
}
function sanitizeTagAttributes(tagName, attrText) {
var 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'],
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']
};
var allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
var attrs = [];
attrText.replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
var lowerKey = String(key || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return '';
}
var 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') {
var targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function renderPreviewListItem(item, tag) {
if (item && typeof item === 'object') {
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
@@ -147,22 +310,17 @@
if (!raw) {
return '<div class="slide-preview-placeholder">Empty text</div>';
}
try {
var parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed.blocks.map(renderPreviewBlock).join('');
}
} catch (_error) {
// fall through to raw text
}
return '<p>' + sanitizePreviewHtml(raw) + '</p>';
return sanitizePreviewHtml(raw);
}
function renderPreviewTextRegion(region, value, style, scale) {
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size) * scale)) + 'px;' : '';
var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
return '<div class="slide-preview-text-content" style="' + fontFamily + fontSize + color + '">' + renderPreviewText(value) + '</div>';
var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(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;' + fontFamily + fontSize + color;
return '<div class="slide-preview-text-content" style="' + wrapperStyle + '">' + renderPreviewText(value) + '</div>';
}
function renderPreviewWebpageRegion(value) {
@@ -195,6 +353,16 @@
return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null;
}
function getEditorFontSizeHiddenInput(regionId) {
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null;
}
function normalizeFontSizeValue(value) {
var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, ''))));
return size ? String(size) : '';
}
function getCurrentTextStyle(region) {
var current = existingContent[region.region_key] || {};
return {
@@ -204,16 +372,8 @@
};
}
function getTextStyleFromCard(card, region) {
var familyInput = card.querySelector('[name="region_font_family_' + region.id + '"]');
var sizeInput = card.querySelector('[name="region_font_size_' + region.id + '"]');
var colorInput = card.querySelector('[name="region_font_color_' + region.id + '"]');
var current = getCurrentTextStyle(region);
return {
font_family: String(familyInput ? familyInput.value : current.font_family).trim() || current.font_family,
font_size: Math.max(8, Number(sizeInput ? sizeInput.value : current.font_size)),
font_color: String(colorInput ? colorInput.value : current.font_color).trim() || current.font_color
};
function getTextStyleFromCard(_card, region) {
return getCurrentTextStyle(region);
}
function getSelectedTemplate() {
@@ -229,12 +389,12 @@
});
}
function getRegionBox(region, stageRect, canvasWidth, canvasHeight) {
function getRegionBox(region, canvasWidth, canvasHeight) {
return {
left: (Number(region.x || 0) / canvasWidth) * stageRect.width,
top: (Number(region.y || 0) / canvasHeight) * stageRect.height,
width: (Number(region.width || 0) / canvasWidth) * stageRect.width,
height: (Number(region.height || 0) / canvasHeight) * stageRect.height
left: Math.max(0, Math.round(Number(region.x || 0))),
top: Math.max(0, Math.round(Number(region.y || 0))),
width: Math.max(1, Math.round(Number(region.width || 0) || 1)),
height: Math.max(1, Math.round(Number(region.height || 0) || 1))
};
}
@@ -246,6 +406,9 @@
slidePreviewEmpty.style.display = 'flex';
slidePreviewEmpty.textContent = 'Select a template to preview the slide.';
slidePreviewOverlay.innerHTML = '';
slidePreviewOverlay.style.width = '';
slidePreviewOverlay.style.height = '';
slidePreviewOverlay.style.transform = '';
slidePreviewStage.style.aspectRatio = '16 / 9';
return;
}
@@ -260,6 +423,9 @@
slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none';
slidePreviewBackground.src = template.background_image_path || '';
slidePreviewOverlay.innerHTML = '';
slidePreviewOverlay.style.width = '';
slidePreviewOverlay.style.height = '';
slidePreviewOverlay.style.transform = '';
return;
}
@@ -269,9 +435,13 @@
var stageRect = slidePreviewStage.getBoundingClientRect();
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
slidePreviewOverlay.style.width = canvasWidth + 'px';
slidePreviewOverlay.style.height = canvasHeight + 'px';
slidePreviewOverlay.style.transform = 'scale(' + scale + ')';
slidePreviewOverlay.style.transformOrigin = 'top left';
var regions = template.regions || [];
slidePreviewOverlay.innerHTML = regions.map(function (region) {
var box = getRegionBox(region, stageRect, canvasWidth, canvasHeight);
var box = getRegionBox(region, canvasWidth, canvasHeight);
var card = templateFields.querySelector('[data-region-id="' + region.id + '"]');
var hiddenInput = card && card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]');
var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
@@ -312,41 +482,16 @@
function renderTextRegion(region) {
var current = getCurrentRegionValue(region);
var style = getCurrentTextStyle(region);
var fontChoices = [
'Arial',
'Courier New',
'Garamond',
'Georgia',
'Helvetica',
'Impact',
'Lucida Console',
'Palatino',
'Tahoma',
'Times New Roman',
'Trebuchet MS',
'Verdana'
];
return '' +
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
'<div class="template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Text</span>' +
'</div>' +
'<div class="row template-text-style-row">' +
'<label style="flex: 1;">Font style' +
'<select name="region_font_family_' + region.id + '">' + fontChoices.map(function (font) {
return '<option value="' + escapeHtml(font) + '"' + (style.font_family === font ? ' selected' : '') + '>' + escapeHtml(font) + '</option>';
}).join('') + '</select>' +
'</label>' +
'<label style="width: 120px;">Size' +
'<input type="number" name="region_font_size_' + region.id + '" min="8" max="180" step="1" value="' + escapeHtml(style.font_size) + '" />' +
'</label>' +
'<label style="width: 140px;">Color' +
'<input type="color" name="region_font_color_' + region.id + '" value="' + escapeHtml(style.font_color) + '" />' +
'</label>' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
'</div>' +
'<div class="editorjs-holder" id="editorjs-' + region.id + '" data-region-id="' + region.id + '"></div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current) + '" />' +
'</div>';
}
@@ -422,59 +567,109 @@
return renderTextRegion(region);
}).join('') : '<div class="muted">This template has no regions.</div>';
templateFields.querySelectorAll('.editorjs-holder').forEach(function (holder) {
templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) {
var regionId = holder.getAttribute('data-region-id');
var source = holder.querySelector('.ckeditor-source');
var hidden = getEditorHiddenInput(regionId);
var editor = new EditorJS({
holder: holder.id,
minHeight: 120,
var editorConfig = {
licenseKey: 'GPL',
placeholder: 'Write content...',
inlineToolbar: true,
tools: {
header: {
class: Header,
config: {
placeholder: 'Enter a header',
levels: [1, 2, 3, 4],
defaultLevel: 3
}
},
list: {
class: EditorjsList,
inlineToolbar: false,
config: {
defaultStyle: 'unordered'
}
},
table: {
class: Table,
inlineToolbar: true,
config: {
rows: 2,
cols: 3,
maxRows: 5,
maxCols: 5
}
},
marker: {
class: Marker
}
toolbar: {
items: [
'undo',
'redo',
'|',
'heading',
'|',
'fontSizeInput',
'fontFamily',
'fontColor',
'fontBackgroundColor',
'|',
'bold',
'italic',
'underline',
'strikethrough',
'subscript',
'superscript',
'|',
'horizontalLine',
'blockQuote',
'|',
'alignment',
'|',
'outdent',
'indent'
],
shouldNotGroupWhenFull: false
},
data: normalizeEditorData(hidden ? hidden.value : ''),
onChange: function () {
var instance = editorInstances.get(regionId);
if (!instance) {
return;
}
instance.save().then(function (output) {
if (hidden) {
hidden.value = JSON.stringify(output);
}
renderPreview();
plugins: [
Alignment,
BlockQuote,
Bold,
Essentials,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
FontSizeInputUI,
Heading,
HorizontalLine,
Indent,
IndentBlock,
Italic,
Paragraph,
Strikethrough,
Subscript,
Superscript,
Underline
],
fontFamily: {
supportAllValues: true
},
fontSize: {
options: [20, 24, 28, 32, 36, 40, 44],
supportAllValues: true
},
heading: {
options: [
{ model: 'paragraph', title: 'Paragraph', class: 'ck-heading_paragraph' },
{ model: 'heading1', view: 'h1', title: 'Heading 1', class: 'ck-heading_heading1' },
{ model: 'heading2', view: 'h2', title: 'Heading 2', class: 'ck-heading_heading2' },
{ model: 'heading3', view: 'h3', title: 'Heading 3', class: 'ck-heading_heading3' },
{ model: 'heading4', view: 'h4', title: 'Heading 4', class: 'ck-heading_heading4' }
]
},
initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
};
ClassicEditor.create(source || holder, editorConfig).then(function (editor) {
editorInstances.set(regionId, editor);
var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
var fontSizeCommand = editor.commands.get('fontSize');
if (hidden) {
hidden.value = editor.getData();
}
if (fontSizeHidden && fontSizeCommand) {
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
fontSizeCommand.on('change:value', function () {
fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
});
}
editor.model.document.on('change:data', function () {
var instance = editorInstances.get(regionId);
if (instance !== editor) {
return;
}
if (hidden) {
hidden.value = editor.getData();
}
renderPreview();
});
renderPreview();
}).catch(function (error) {
console.error('Failed to initialize CKEditor.', error);
});
editorInstances.set(regionId, editor);
});
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
@@ -497,16 +692,15 @@
return;
}
event.preventDefault();
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.editorjs-holder'), function (holder) {
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) {
var regionId = holder.getAttribute('data-region-id');
var editor = editorInstances.get(regionId);
var hidden = getEditorHiddenInput(regionId);
if (!editor || !hidden) {
return Promise.resolve();
}
return editor.save().then(function (output) {
hidden.value = JSON.stringify(output);
});
hidden.value = editor.getData();
return Promise.resolve();
});
submitting = true;
try {