', 1, 1, 10, 1920, 1, 1, 2, NOW()),
+('Basic', '[2]', '{"toolbar": "undo redo | bold italic underline | link | numlist bullist | removeformat"}', '- ', 0, 0, 5, 1024, 1, 1, 3, NOW());
diff --git a/source/packages/plg_editors_mokosuiteeditor/sql/uninstall.mysql.sql b/source/packages/plg_editors_mokosuiteeditor/sql/uninstall.mysql.sql
new file mode 100644
index 0000000..f33f24b
--- /dev/null
+++ b/source/packages/plg_editors_mokosuiteeditor/sql/uninstall.mysql.sql
@@ -0,0 +1,7 @@
+--
+-- MokoSuite Editor — Uninstall
+--
+
+DROP TABLE IF EXISTS `#__mokosuiteeditor_media_presets`;
+DROP TABLE IF EXISTS `#__mokosuiteeditor_templates`;
+DROP TABLE IF EXISTS `#__mokosuiteeditor_profiles`;
diff --git a/source/packages/plg_editors_mokosuiteeditor/src/Extension/MokoSuiteEditor.php b/source/packages/plg_editors_mokosuiteeditor/src/Extension/MokoSuiteEditor.php
new file mode 100644
index 0000000..c9f3603
--- /dev/null
+++ b/source/packages/plg_editors_mokosuiteeditor/src/Extension/MokoSuiteEditor.php
@@ -0,0 +1,99 @@
+ 'onEditorSetup',
+ ];
+ }
+
+ /**
+ * Handle the editor setup event.
+ *
+ * @param \Joomla\Event\Event $event The event object.
+ *
+ * @return void
+ *
+ * @since 01.00.00
+ */
+ public function onEditorSetup(\Joomla\Event\Event $event): void
+ {
+ // Register this editor with Joomla's editor manager
+ }
+
+ /**
+ * Get the content of the editor area.
+ *
+ * @param string $editorId The editor instance identifier.
+ *
+ * @return string JavaScript to retrieve editor content.
+ *
+ * @since 01.00.00
+ */
+ public function getContent(string $editorId): string
+ {
+ return "tinymce.get('{$editorId}')?.getContent() ?? ''";
+ }
+
+ /**
+ * Set the content of the editor area.
+ *
+ * @param string $editorId The editor instance identifier.
+ * @param string $content The content to set.
+ *
+ * @return string JavaScript to set editor content.
+ *
+ * @since 01.00.00
+ */
+ public function setContent(string $editorId, string $content): string
+ {
+ $escapedContent = addslashes($content);
+
+ return "tinymce.get('{$editorId}')?.setContent('{$escapedContent}')";
+ }
+
+ /**
+ * Get the editor extended buttons (XTD).
+ *
+ * @param string $editorId The editor instance identifier.
+ *
+ * @return array Array of button objects.
+ *
+ * @since 01.00.00
+ */
+ public function getButtons(string $editorId): array
+ {
+ return [];
+ }
+}
diff --git a/source/packages/plg_editors_mokosuiteeditor/src/Helper/MediaHelper.php b/source/packages/plg_editors_mokosuiteeditor/src/Helper/MediaHelper.php
new file mode 100644
index 0000000..8e3fe89
--- /dev/null
+++ b/source/packages/plg_editors_mokosuiteeditor/src/Helper/MediaHelper.php
@@ -0,0 +1,251 @@
+ bool, 'path' => string, 'error' => string]
+ *
+ * @since 01.00.00
+ */
+ public static function upload(array $file, int $profileId): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuiteeditor_profiles'))
+ ->where($db->quoteName('id') . ' = ' . (int) $profileId);
+
+ $profile = $db->setQuery($query)->loadObject();
+
+ if (!$profile || !$profile->media_upload) {
+ return ['success' => false, 'path' => '', 'error' => 'Media upload not permitted for this profile.'];
+ }
+
+ $maxBytes = ($profile->max_upload_mb ?: 10) * 1024 * 1024;
+
+ if ($file['error'] !== UPLOAD_ERR_OK) {
+ return ['success' => false, 'path' => '', 'error' => 'Upload error code: ' . $file['error']];
+ }
+
+ if ($file['size'] > $maxBytes) {
+ return ['success' => false, 'path' => '', 'error' => 'File exceeds maximum upload size of ' . $profile->max_upload_mb . ' MB.'];
+ }
+
+ $uploadDir = JPATH_ROOT . '/images/mokosuiteeditor/' . date('Y/m');
+ $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ $allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'];
+
+ if (!in_array($extension, $allowed, true)) {
+ return ['success' => false, 'path' => '', 'error' => 'File type not allowed.'];
+ }
+
+ if (!is_dir($uploadDir)) {
+ mkdir($uploadDir, 0755, true);
+ }
+
+ $filename = uniqid('mse_', true) . '.' . $extension;
+ $destPath = $uploadDir . '/' . $filename;
+
+ if (!move_uploaded_file($file['tmp_name'], $destPath)) {
+ return ['success' => false, 'path' => '', 'error' => 'Failed to move uploaded file.'];
+ }
+
+ // Auto-resize if configured
+ if ($profile->auto_resize_width > 0 && in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
+ self::resize($destPath, (int) $profile->auto_resize_width);
+ }
+
+ // WebP conversion if enabled
+ if ($profile->webp_conversion && in_array($extension, ['jpg', 'jpeg', 'png'], true)) {
+ $webpPath = self::convertToWebP($destPath);
+
+ if ($webpPath) {
+ $destPath = $webpPath;
+ }
+ }
+
+ $relativePath = str_replace(JPATH_ROOT . '/', '', $destPath);
+
+ return ['success' => true, 'path' => $relativePath, 'error' => ''];
+ }
+
+ /**
+ * Resize an image to a maximum width, preserving aspect ratio.
+ *
+ * @param string $path Absolute path to the image file.
+ * @param int $maxWidth Maximum width in pixels.
+ *
+ * @return bool True on success.
+ *
+ * @since 01.00.00
+ */
+ public static function resize(string $path, int $maxWidth): bool
+ {
+ if (!file_exists($path) || $maxWidth <= 0) {
+ return false;
+ }
+
+ $info = getimagesize($path);
+
+ if ($info === false) {
+ return false;
+ }
+
+ [$width, $height] = $info;
+
+ if ($width <= $maxWidth) {
+ return true;
+ }
+
+ $ratio = $maxWidth / $width;
+ $newHeight = (int) round($height * $ratio);
+
+ $source = match ($info['mime']) {
+ 'image/jpeg' => imagecreatefromjpeg($path),
+ 'image/png' => imagecreatefrompng($path),
+ 'image/webp' => imagecreatefromwebp($path),
+ default => null,
+ };
+
+ if (!$source) {
+ return false;
+ }
+
+ $resized = imagecreatetruecolor($maxWidth, $newHeight);
+ imagecopyresampled($resized, $source, 0, 0, 0, 0, $maxWidth, $newHeight, $width, $height);
+
+ $result = match ($info['mime']) {
+ 'image/jpeg' => imagejpeg($resized, $path, 90),
+ 'image/png' => imagepng($resized, $path, 6),
+ 'image/webp' => imagewebp($resized, $path, 85),
+ default => false,
+ };
+
+ imagedestroy($source);
+ imagedestroy($resized);
+
+ return $result;
+ }
+
+ /**
+ * Convert an image to WebP format.
+ *
+ * @param string $path Absolute path to the source image.
+ * @param int $quality WebP quality (1-100).
+ *
+ * @return string|null Path to the WebP file, or null on failure.
+ *
+ * @since 01.00.00
+ */
+ public static function convertToWebP(string $path, int $quality = 85): ?string
+ {
+ if (!file_exists($path) || !function_exists('imagewebp')) {
+ return null;
+ }
+
+ $info = getimagesize($path);
+
+ if ($info === false) {
+ return null;
+ }
+
+ $source = match ($info['mime']) {
+ 'image/jpeg' => imagecreatefromjpeg($path),
+ 'image/png' => imagecreatefrompng($path),
+ default => null,
+ };
+
+ if (!$source) {
+ return null;
+ }
+
+ $webpPath = preg_replace('/\.(jpe?g|png)$/i', '.webp', $path);
+
+ if (imagewebp($source, $webpPath, $quality)) {
+ imagedestroy($source);
+
+ // Remove original file
+ if ($webpPath !== $path) {
+ unlink($path);
+ }
+
+ return $webpPath;
+ }
+
+ imagedestroy($source);
+
+ return null;
+ }
+
+ /**
+ * Get a listing of media files for the browser.
+ *
+ * @param string $path Relative path under images/mokosuiteeditor/.
+ *
+ * @return array Array of ['name', 'path', 'size', 'type', 'modified'] entries.
+ *
+ * @since 01.00.00
+ */
+ public static function getMediaBrowser(string $path = ''): array
+ {
+ $basePath = JPATH_ROOT . '/images/mokosuiteeditor';
+ $fullPath = $basePath . ($path ? '/' . ltrim($path, '/') : '');
+
+ // Prevent directory traversal
+ $realBase = realpath($basePath);
+ $realFull = realpath($fullPath);
+
+ if ($realFull === false || !str_starts_with($realFull, $realBase)) {
+ return [];
+ }
+
+ $items = [];
+
+ if (!is_dir($realFull)) {
+ return $items;
+ }
+
+ $entries = scandir($realFull);
+
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ $entryPath = $realFull . '/' . $entry;
+ $relative = str_replace(JPATH_ROOT . '/', '', $entryPath);
+
+ $items[] = [
+ 'name' => $entry,
+ 'path' => $relative,
+ 'size' => is_file($entryPath) ? filesize($entryPath) : 0,
+ 'type' => is_dir($entryPath) ? 'folder' : mime_content_type($entryPath),
+ 'modified' => date('Y-m-d H:i:s', filemtime($entryPath)),
+ ];
+ }
+
+ return $items;
+ }
+}
diff --git a/source/packages/plg_editors_mokosuiteeditor/src/Helper/ProfileHelper.php b/source/packages/plg_editors_mokosuiteeditor/src/Helper/ProfileHelper.php
new file mode 100644
index 0000000..edcfe20
--- /dev/null
+++ b/source/packages/plg_editors_mokosuiteeditor/src/Helper/ProfileHelper.php
@@ -0,0 +1,102 @@
+get(DatabaseInterface::class);
+
+ $user = Factory::getApplication()->getIdentity();
+ $groups = $user->getAuthorisedGroups();
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuiteeditor_profiles'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->order('ordering ASC');
+
+ $profiles = $db->setQuery($query)->loadObjectList();
+
+ foreach ($profiles as $profile) {
+ $profileGroups = json_decode($profile->user_groups, true) ?: [];
+
+ if (array_intersect($groups, $profileGroups)) {
+ return $profile;
+ }
+ }
+
+ // Fallback: return first published profile
+ return $profiles[0] ?? null;
+ }
+
+ /**
+ * Get the toolbar configuration for a specific profile.
+ *
+ * @param int $profileId The profile ID.
+ *
+ * @return array Decoded toolbar_config or empty array.
+ *
+ * @since 01.00.00
+ */
+ public static function getToolbarConfig(int $profileId): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select($db->quoteName('toolbar_config'))
+ ->from($db->quoteName('#__mokosuiteeditor_profiles'))
+ ->where($db->quoteName('id') . ' = ' . (int) $profileId);
+
+ $result = $db->setQuery($query)->loadResult();
+
+ return $result ? (json_decode($result, true) ?: []) : [];
+ }
+
+ /**
+ * Get all published profiles.
+ *
+ * @return array Array of profile objects.
+ *
+ * @since 01.00.00
+ */
+ public static function getAllProfiles(): array
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuiteeditor_profiles'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->order('ordering ASC');
+
+ return $db->setQuery($query)->loadObjectList();
+ }
+}
diff --git a/source/packages/plg_editors_mokosuiteeditor/src/Helper/TemplateHelper.php b/source/packages/plg_editors_mokosuiteeditor/src/Helper/TemplateHelper.php
new file mode 100644
index 0000000..24f8b56
--- /dev/null
+++ b/source/packages/plg_editors_mokosuiteeditor/src/Helper/TemplateHelper.php
@@ -0,0 +1,86 @@
+get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuiteeditor_templates'))
+ ->where($db->quoteName('published') . ' = 1')
+ ->order('ordering ASC');
+
+ if ($category !== null && $category !== '') {
+ $query->where($db->quoteName('category') . ' = ' . $db->quote($category));
+ }
+
+ return $db->setQuery($query)->loadObjectList();
+ }
+
+ /**
+ * Get a single template by ID.
+ *
+ * @param int $id The template ID.
+ *
+ * @return object|null Template row or null.
+ *
+ * @since 01.00.00
+ */
+ public static function getById(int $id): ?object
+ {
+ $db = Factory::getContainer()->get(DatabaseInterface::class);
+
+ $query = $db->getQuery(true)
+ ->select('*')
+ ->from($db->quoteName('#__mokosuiteeditor_templates'))
+ ->where($db->quoteName('id') . ' = ' . (int) $id)
+ ->where($db->quoteName('published') . ' = 1');
+
+ $result = $db->setQuery($query)->loadObject();
+
+ return $result ?: null;
+ }
+
+ /**
+ * Get a template's HTML content ready for insertion into the editor.
+ *
+ * @param int $templateId The template ID.
+ *
+ * @return string The template HTML content, or empty string if not found.
+ *
+ * @since 01.00.00
+ */
+ public static function insertTemplate(int $templateId): string
+ {
+ $template = self::getById($templateId);
+
+ return $template ? $template->content : '';
+ }
+}