This commit is contained in:
Markos Gogoulos
2026-02-01 13:24:16 +02:00
parent ba53467033
commit 27828d798e
29 changed files with 1859 additions and 956 deletions

View File

@@ -0,0 +1,29 @@
import { getTinyMCE } from 'editor_tiny/loader';
import { component } from './common';
import IframeEmbed from './iframeembed';
import { getString } from 'core/str';
export const getSetup = async () => {
const [tinyMCE, buttonTitle, menuTitle] = await Promise.all([
getTinyMCE(),
getString('insertmedia', component),
getString('mediacms', component),
]);
return (editor) => {
const iframeEmbed = new IframeEmbed(editor);
editor.ui.registry.addButton(component, {
icon: 'embed',
tooltip: buttonTitle,
onAction: () => iframeEmbed.displayDialogue(),
});
editor.ui.registry.addMenuItem(component, {
icon: 'embed',
text: menuTitle,
onAction: () => iframeEmbed.displayDialogue(),
});
};
};

View File

@@ -0,0 +1,3 @@
export const component = 'tiny_mediacms';
export const pluginName = 'mediacms';

View File

@@ -0,0 +1,8 @@
export const configure = (instanceConfig) => {
return {
mediacmsurl: instanceConfig.mediacmsurl,
launchUrl: instanceConfig.launchUrl,
lti: instanceConfig.lti
};
};

View File

@@ -0,0 +1,368 @@
import Templates from 'core/templates';
import { getString } from 'core/str';
import * as ModalEvents from 'core/modal_events';
import { component } from './common';
import IframeModal from './iframemodal';
import Selectors from './selectors';
import { getLti, getLaunchUrl, getMediaCMSUrl } from './options';
export default class IframeEmbed {
editor = null;
currentModal = null;
isUpdating = false;
selectedIframe = null;
debounceTimer = null;
iframeLibraryLoaded = false;
selectedLibraryVideo = null;
constructor(editor) {
this.editor = editor;
}
parseInput(input) {
if (!input || !input.trim()) {
return null;
}
input = input.trim();
// Check for iframe
const iframeMatch = input.match(/<iframe[^>]*src=["']([^"']+)["'][^>]*>/i);
if (iframeMatch) {
return this.parseEmbedUrl(iframeMatch[1]);
}
// Check URL
if (input.startsWith('http://') || input.startsWith('https://')) {
return this.parseVideoUrl(input);
}
return null;
}
parseVideoUrl(url) {
try {
const urlObj = new URL(url);
const baseUrl = `${urlObj.protocol}//${urlObj.host}`;
// Check if it matches configured MediaCMS URL (if strictly required)
// For now we accept any valid MediaCMS-like structure
// /view?m=ID
if (urlObj.pathname.includes('/view') && urlObj.searchParams.has('m')) {
return {
baseUrl: baseUrl,
videoId: urlObj.searchParams.get('m'),
isEmbed: false
};
}
// /embed?m=ID
if (urlObj.pathname.includes('/embed') && urlObj.searchParams.has('m')) {
return {
baseUrl: baseUrl,
videoId: urlObj.searchParams.get('m'),
isEmbed: true,
// Parse options
showTitle: urlObj.searchParams.get('showTitle') === '1',
linkTitle: urlObj.searchParams.get('linkTitle') === '1',
showRelated: urlObj.searchParams.get('showRelated') === '1',
showUserAvatar: urlObj.searchParams.get('showUserAvatar') === '1',
};
}
// Check if it's already a launch.php URL
if (urlObj.pathname.includes('/filter/mediacms/launch.php') && urlObj.searchParams.has('token')) {
return {
baseUrl: baseUrl,
videoId: urlObj.searchParams.get('token'),
isEmbed: true,
isLaunchUrl: true
};
}
return {
baseUrl: baseUrl,
rawUrl: url,
isGeneric: true
};
} catch (e) {
return null;
}
}
parseEmbedUrl(url) {
return this.parseVideoUrl(url);
}
buildEmbedUrl(parsed, options) {
if (parsed.isGeneric) {
return parsed.rawUrl;
}
const launchUrl = getLaunchUrl(this.editor);
if (launchUrl && parsed.videoId) {
const url = new URL(launchUrl);
url.searchParams.set('token', parsed.videoId);
return url.toString();
}
// Fallback to direct embed if launchUrl missing
const url = new URL(`${parsed.baseUrl}/embed`);
url.searchParams.set('m', parsed.videoId);
return url.toString();
}
async getTemplateContext(data = {}) {
return {
elementid: this.editor.getElement().id,
isupdating: this.isUpdating,
url: data.url || '',
showTitle: data.showTitle !== false,
linkTitle: data.linkTitle !== false,
showRelated: data.showRelated !== false,
showUserAvatar: data.showUserAvatar !== false,
responsive: data.responsive !== false,
startAtEnabled: data.startAtEnabled || false,
startAt: data.startAt || '0:00',
width: data.width || 560,
height: data.height || 315,
is16_9: !data.aspectRatio || data.aspectRatio === '16:9',
is4_3: data.aspectRatio === '4:3',
is1_1: data.aspectRatio === '1:1',
isCustom: data.aspectRatio === 'custom',
};
}
async displayDialogue() {
this.selectedIframe = this.getSelectedIframe();
const data = this.getCurrentIframeData();
this.isUpdating = data !== null;
this.iframeLibraryLoaded = false;
this.currentModal = await IframeModal.create({
title: await getString('iframemodaltitle', component),
templateContext: await this.getTemplateContext(data || {}),
});
await this.registerEventListeners(this.currentModal);
}
getSelectedIframe() {
const node = this.editor.selection.getNode();
if (node.nodeName.toLowerCase() === 'iframe') return node;
return node.querySelector('iframe') || null;
}
getCurrentIframeData() {
if (!this.selectedIframe) return null;
const src = this.selectedIframe.getAttribute('src');
const parsed = this.parseInput(src);
return {
url: src,
width: this.selectedIframe.getAttribute('width') || 560,
height: this.selectedIframe.getAttribute('height') || 315,
showTitle: parsed?.showTitle ?? true,
// ... other defaults
};
}
getFormValues(root) {
const form = root.querySelector(Selectors.IFRAME.elements.form);
// Helper to safely get value or checked state
const getVal = (sel) => form.querySelector(sel)?.value;
const getCheck = (sel) => form.querySelector(sel)?.checked;
return {
url: getVal(Selectors.IFRAME.elements.url).trim(),
showTitle: getCheck(Selectors.IFRAME.elements.showTitle),
linkTitle: getCheck(Selectors.IFRAME.elements.linkTitle),
showRelated: getCheck(Selectors.IFRAME.elements.showRelated),
showUserAvatar: getCheck(Selectors.IFRAME.elements.showUserAvatar),
responsive: getCheck(Selectors.IFRAME.elements.responsive),
aspectRatio: getVal(Selectors.IFRAME.elements.aspectRatio),
width: parseInt(getVal(Selectors.IFRAME.elements.width)) || 560,
height: parseInt(getVal(Selectors.IFRAME.elements.height)) || 315,
};
}
async generateIframeHtml(values) {
const parsed = this.parseInput(values.url);
if (!parsed) return '';
const embedUrl = this.buildEmbedUrl(parsed, values);
const aspectRatioCalcs = {
'16:9': '16 / 9',
'4:3': '4 / 3',
'1:1': '1 / 1',
'custom': `${values.width} / ${values.height}`
};
const context = {
src: embedUrl,
width: values.width,
height: values.height,
responsive: values.responsive,
aspectRatioValue: aspectRatioCalcs[values.aspectRatio] || '16 / 9',
};
const { html } = await Templates.renderForPromise(
`${component}/iframe_embed_output`,
context
);
return html;
}
async updatePreview(root) {
const values = this.getFormValues(root);
const previewContainer = root.querySelector(Selectors.IFRAME.elements.preview);
if (!values.url) {
previewContainer.innerHTML = '<span>Enter URL</span>';
return;
}
const parsed = this.parseInput(values.url);
if (!parsed) {
previewContainer.innerHTML = '<span class="text-danger">Invalid URL</span>';
return;
}
const embedUrl = this.buildEmbedUrl(parsed, values);
// Simple preview
previewContainer.innerHTML = `<iframe src="${embedUrl}" width="100%" height="200" frameborder="0"></iframe>`;
}
// ... Event listeners and Modal handling ...
// Simplified for brevity, assuming standard handlers
async registerEventListeners(modal) {
const root = modal.getRoot()[0];
const form = root.querySelector(Selectors.IFRAME.elements.form);
// Input changes update preview
form.addEventListener('change', () => this.updatePreview(root));
form.querySelector(Selectors.IFRAME.elements.url).addEventListener('input', () => this.updatePreview(root));
// Tab switching
const tabUrl = form.querySelector(Selectors.IFRAME.elements.tabUrlBtn);
const tabLib = form.querySelector(Selectors.IFRAME.elements.tabIframeLibraryBtn);
if (tabLib) {
tabLib.addEventListener('click', (e) => {
e.preventDefault();
this.switchToTab(root, 'library');
this.loadIframeLibrary(root);
});
}
if (tabUrl) {
tabUrl.addEventListener('click', (e) => {
e.preventDefault();
this.switchToTab(root, 'url');
});
}
modal.getRoot().on(ModalEvents.save, () => this.handleDialogueSubmission(modal));
// Listen for messages
window.addEventListener('message', (e) => this.handleIframeLibraryMessage(root, e));
}
switchToTab(root, tab) {
const form = root.querySelector(Selectors.IFRAME.elements.form);
const urlPane = form.querySelector(Selectors.IFRAME.elements.paneUrl);
const libPane = form.querySelector(Selectors.IFRAME.elements.paneIframeLibrary);
const urlBtn = form.querySelector(Selectors.IFRAME.elements.tabUrlBtn);
const libBtn = form.querySelector(Selectors.IFRAME.elements.tabIframeLibraryBtn);
if (tab === 'url') {
urlPane.classList.add('show', 'active');
libPane.classList.remove('show', 'active');
urlBtn.classList.add('active');
libBtn.classList.remove('active');
} else {
urlPane.classList.remove('show', 'active');
libPane.classList.add('show', 'active');
urlBtn.classList.remove('active');
libBtn.classList.add('active');
}
}
loadIframeLibrary(root) {
const ltiConfig = getLti(this.editor);
if (ltiConfig && ltiConfig.contentItemUrl) {
const iframe = root.querySelector(Selectors.IFRAME.elements.iframeLibraryFrame);
const loading = root.querySelector(Selectors.IFRAME.elements.iframeLibraryLoading);
if (iframe && !iframe.src) {
loading.classList.remove('d-none');
iframe.classList.add('d-none');
iframe.onload = () => {
loading.classList.add('d-none');
iframe.classList.remove('d-none');
};
iframe.src = ltiConfig.contentItemUrl;
}
}
}
handleIframeLibraryMessage(root, event) {
const data = event.data;
if (!data) return;
let embedUrl = null;
let videoId = null;
// LTI Deep Linking Response
if (data.type === 'ltiDeepLinkingResponse' || data.messageType === 'LtiDeepLinkingResponse') {
const items = data.content_items || data.contentItems || [];
if (items.length) {
embedUrl = items[0].url || items[0].embed_url;
// Try to extract ID from URL if not provided
// But actually we just need the ID to build the launch URL
// If the response gives us a full embed URL, we can parse it
if (embedUrl) {
const parsed = this.parseInput(embedUrl);
if (parsed) videoId = parsed.videoId;
}
}
}
// MediaCMS custom message
if (data.action === 'selectMedia' || data.type === 'videoSelected') {
embedUrl = data.embedUrl || data.url;
videoId = data.mediaId || data.videoId || data.id;
}
if (videoId) {
// Populate URL field with the clean embed URL (launch URL) if possible,
// or just the direct URL which parseInput will handle
// Actually, best to set the raw MediaCMS URL and let parseInput -> buildEmbedUrl handle the conversion
// But if we have the ID, we can construct a view URL
const mediaCMSUrl = getMediaCMSUrl(this.editor);
if (mediaCMSUrl) {
const viewUrl = `${mediaCMSUrl}/view?m=${videoId}`;
const urlInput = root.querySelector(Selectors.IFRAME.elements.url);
urlInput.value = viewUrl;
this.updatePreview(root);
this.switchToTab(root, 'url');
}
}
}
async handleDialogueSubmission(modal) {
const root = modal.getRoot()[0];
const values = this.getFormValues(root);
const html = await this.generateIframeHtml(values);
if (html) {
this.editor.insertContent(html);
}
modal.hide();
}
}

View File

@@ -0,0 +1,11 @@
import Modal from 'core/modal';
import ModalRegistry from 'core/modal_registry';
import { component } from './common';
export default class IframeModal extends Modal {
static TYPE = `${component}/iframe_embed_modal`;
static TEMPLATE = `${component}/iframe_embed_modal`;
}
ModalRegistry.register(IframeModal.TYPE, IframeModal, IframeModal.TEMPLATE);

View File

@@ -0,0 +1,25 @@
import { component } from './common';
export const register = (editor) => {
const registerOption = editor.options.register;
registerOption(`${component}:mediacmsurl`, {
processor: 'string',
default: ''
});
registerOption(`${component}:launchUrl`, {
processor: 'string',
default: ''
});
registerOption(`${component}:lti`, {
processor: 'object',
default: {}
});
};
export const getMediaCMSUrl = (editor) => editor.options.get(`${component}:mediacmsurl`);
export const getLaunchUrl = (editor) => editor.options.get(`${component}:launchUrl`);
export const getLti = (editor) => editor.options.get(`${component}:lti`);

View File

@@ -0,0 +1,23 @@
import { getTinyMCE } from 'editor_tiny/loader';
import { getPluginMetadata } from 'editor_tiny/utils';
import { component, pluginName } from './common';
import * as Commands from './commands';
import * as Options from './options';
import * as Configuration from './configuration';
export default new Promise(async (resolve) => {
const [tinyMCE, setupCommands, pluginMetadata] = await Promise.all([
getTinyMCE(),
Commands.getSetup(),
getPluginMetadata(component, pluginName),
]);
tinyMCE.PluginManager.add(`${component}/plugin`, (editor) => {
Options.register(editor);
setupCommands(editor);
return pluginMetadata;
});
resolve([`${component}/plugin`, Configuration]);
});

View File

@@ -0,0 +1,19 @@
export default {
IFRAME: {
elements: {
form: '.tiny-mediacms-iframe-form',
url: 'input[name="mediacms_url"]',
width: 'input[name="mediacms_width"]',
height: 'input[name="mediacms_height"]',
preview: '.tiny-mediacms-preview',
tabUrlBtn: '#tiny-mediacms-tab-url',
tabIframeLibraryBtn: '#tiny-mediacms-tab-library',
paneUrl: '#tiny-mediacms-pane-url',
paneIframeLibrary: '#tiny-mediacms-pane-library',
iframeLibraryFrame: '.tiny-mediacms-library-frame',
iframeLibraryLoading: '.tiny-mediacms-library-loading',
iframeLibraryPlaceholder: '.tiny-mediacms-library-placeholder'
}
}
};

View File

@@ -0,0 +1,42 @@
<?php
namespace tiny_mediacms;
use context;
use moodle_url;
defined('MOODLE_INTERNAL') || die();
class plugininfo extends \editor_tiny\plugin {
/**
* Get the plugin configuration for the editor.
*/
protected static function get_plugin_configuration_for_context(context $context, array $options = []): array {
global $CFG;
// Read settings from the FILTER plugin
$mediacmsurl = get_config('filter_mediacms', 'mediacmsurl');
$ltitoolid = get_config('filter_mediacms', 'ltitoolid');
// Construct launch URL for the filter
$launchurl = new moodle_url('/filter/mediacms/launch.php');
// Content Item URL for LTI Deep Linking (if using the picker)
// Usually: /mod/lti/contentitem.php?id=LTI_TYPE_ID
$contentitemurl = '';
if ($ltitoolid) {
$contentitemurl = new moodle_url('/mod/lti/contentitem.php');
}
return [
'mediacmsurl' => $mediacmsurl,
'launchUrl' => $launchurl->out(false),
'lti' => [
'toolId' => (int) $ltitoolid,
'courseId' => $context->get_course_context(false)->instanceid ?? 0,
'contentItemUrl' => $contentitemurl ? $contentitemurl->out(false) : '',
]
];
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace tiny_mediacms\privacy;
defined('MOODLE_INTERNAL') || die();
class provider implements \core_privacy\local\metadata\null_provider {
public static function get_reason(): string {
return 'privacy:metadata';
}
}

View File

@@ -0,0 +1,13 @@
<?php
defined('MOODLE_INTERNAL') || die();
$string['pluginname'] = 'MediaCMS';
$string['mediacms:view'] = 'View MediaCMS';
$string['mediacms'] = 'MediaCMS';
$string['insertmedia'] = 'Insert MediaCMS Video';
$string['iframemodaltitle'] = 'Insert MediaCMS Video';
$string['urltab'] = 'URL';
$string['iframelibrarytab'] = 'Video Library';
$string['enterurl'] = 'Enter Video URL';
$string['invalidurl'] = 'Invalid MediaCMS URL';
$string['removeiframeconfirm'] = 'Are you sure you want to remove this video?';

View File

@@ -0,0 +1,40 @@
{{!
@template tiny_mediacms/iframe_embed_modal
}}
{{< core/modal }}
{{$body}}
<form class="tiny_iframecms_form" id="{{elementid}}_tiny_iframecms_form">
<ul class="nav nav-tabs mb-3" role="tablist">
<li class="nav-item">
<button class="nav-link active" id="{{elementid}}_tab_url" data-bs-toggle="tab" data-bs-target="#{{elementid}}_pane_url" type="button" role="tab">{{#str}} urltab, tiny_mediacms {{/str}}</button>
</li>
<li class="nav-item">
<button class="nav-link" id="{{elementid}}_tab_iframe_library" data-bs-toggle="tab" data-bs-target="#{{elementid}}_pane_iframe_library" type="button" role="tab">{{#str}} iframelibrarytab, tiny_mediacms {{/str}}</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="{{elementid}}_pane_url" role="tabpanel">
<div class="mb-3">
<label class="form-label">{{#str}} enterurl, tiny_mediacms {{/str}}</label>
<input type="text" class="form-control tiny_iframecms_url" name="mediacms_url" value="{{url}}">
</div>
{{> tiny_mediacms/iframe_embed_options }}
<div class="tiny_mediacms_preview border p-2 text-center bg-light" style="min-height:200px">
Preview
</div>
</div>
<div class="tab-pane fade" id="{{elementid}}_pane_iframe_library" role="tabpanel">
<div class="tiny_mediacms_library_container text-center" style="min-height:400px">
<div class="tiny_mediacms_library_loading d-none">Loading...</div>
<iframe class="tiny_mediacms_library_frame d-none" style="width:100%;height:400px;border:0;"></iframe>
<div class="tiny_mediacms_library_placeholder p-5">Library will load here</div>
</div>
</div>
</div>
</form>
{{/body}}
{{$footer}}
<button type="button" class="btn btn-primary" data-action="save">{{#str}} insertmedia, tiny_mediacms {{/str}}</button>
<button type="button" class="btn btn-secondary" data-action="cancel">{{#str}} cancel, moodle {{/str}}</button>
{{/footer}}
{{/ core/modal }}

View File

@@ -0,0 +1,47 @@
{{!
@template tiny_mediacms/iframe_embed_options
}}
<div class="mb-3">
<label class="form-label font-weight-bold">{{#str}} embedoptions, tiny_mediacms {{/str}}</label>
<div class="row">
<div class="col-6">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input tiny_iframecms_showtitle" id="{{elementid}}_showtitle" {{#showTitle}}checked{{/showTitle}}>
<label class="form-check-label" for="{{elementid}}_showtitle">{{#str}} showtitle, tiny_mediacms {{/str}}</label>
</div>
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input tiny_iframecms_linktitle" id="{{elementid}}_linktitle" {{#linkTitle}}checked{{/linkTitle}}>
<label class="form-check-label" for="{{elementid}}_linktitle">{{#str}} linktitle, tiny_mediacms {{/str}}</label>
</div>
</div>
<div class="col-6">
<div class="form-check mb-2">
<input type="checkbox" class="form-check-input tiny_iframecms_responsive" id="{{elementid}}_responsive" {{#responsive}}checked{{/responsive}}>
<label class="form-check-label" for="{{elementid}}_responsive">{{#str}} responsive, tiny_mediacms {{/str}}</label>
</div>
<div class="form-check mb-2">
<label class="form-check-label">{{#str}} aspectratio, tiny_mediacms {{/str}}</label>
<select class="form-control form-control-sm tiny_iframecms_aspectratio" id="{{elementid}}_aspectratio">
<option value="16:9" {{#is16_9}}selected{{/is16_9}}>16:9</option>
<option value="4:3" {{#is4_3}}selected{{/is4_3}}>4:3</option>
<option value="1:1" {{#is1_1}}selected{{/is1_1}}>1:1</option>
<option value="custom" {{#isCustom}}selected{{/isCustom}}>Custom</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col-6">
<div class="input-group input-group-sm">
<span class="input-group-text">{{#str}} width, tiny_mediacms {{/str}}</span>
<input type="number" class="form-control tiny_iframecms_width" id="{{elementid}}_width" value="{{width}}">
</div>
</div>
<div class="col-6">
<div class="input-group input-group-sm">
<span class="input-group-text">{{#str}} height, tiny_mediacms {{/str}}</span>
<input type="number" class="form-control tiny_iframecms_height" id="{{elementid}}_height" value="{{height}}">
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,9 @@
{{!
@template tiny_mediacms/iframe_embed_output
}}
{{#responsive}}
<iframe src="{{src}}" style="width:100%;aspect-ratio:{{aspectRatioValue}};display:block;border:0;" allowFullScreen></iframe>
{{/responsive}}
{{^responsive}}
<iframe width="{{width}}" height="{{height}}" src="{{src}}" frameBorder="0" allowFullScreen></iframe>
{{/responsive}}

View File

@@ -0,0 +1,9 @@
<?php
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2026020100;
$plugin->requires = 2024100700;
$plugin->component = 'tiny_mediacms';
$plugin->maturity = MATURITY_STABLE;
$plugin->release = 'v1.0.0';
$plugin->dependencies = ['filter_mediacms' => 2026020100]; // Requires the filter