OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
/**
* Accessibility Controller
* Enhances custom JavaScript input components with proper ARIA attributes and keyboard support
*
* @author Leantime Team
* @copyright 2024 Leantime
*/
leantime.accessibilityController = (function () {
/**
* Enhance Chosen dropdowns with ARIA attributes
*/
var enhanceChosenAccessibility = function() {
jQuery('.chosen-container').each(function() {
var $container = jQuery(this);
// Skip if already enhanced
if ($container.data('a11y-enhanced')) {
return;
}
var $originalSelect = null;
// Try to find the original select element
var containerId = $container.attr('id');
if (containerId && containerId.indexOf('_chosen') > -1) {
// Standard case: container has ID like "status-select_chosen"
var selectId = containerId.replace('_chosen', '');
$originalSelect = jQuery('#' + selectId);
}
// Fallback: look for a hidden select element that precedes this container
if (!$originalSelect || !$originalSelect.length) {
$originalSelect = $container.prev('select[data-placeholder]');
}
// Second fallback: look for any hidden select near this container
if (!$originalSelect || !$originalSelect.length) {
$originalSelect = $container.siblings('select').first();
}
// If we still can't find the select, skip this container
if (!$originalSelect || !$originalSelect.length) {
return;
}
// Get label text
var labelText = '';
var selectId = $originalSelect.attr('id');
if (selectId) {
var $label = jQuery('label[for="' + selectId + '"]');
if ($label.length) {
labelText = $label.text().trim();
}
}
// Set ARIA attributes on Chosen container
var $chosenSingle = $container.find('.chosen-single');
var $chosenChoices = $container.find('.chosen-choices');
if ($chosenSingle.length) {
// Single select
$chosenSingle.attr({
'role': 'combobox',
'aria-haspopup': 'listbox',
'aria-expanded': 'false',
'aria-label': labelText || $originalSelect.attr('data-placeholder') || 'Select option',
'tabindex': '0'
});
}
if ($chosenChoices.length) {
// Multi-select
$chosenChoices.attr({
'role': 'combobox',
'aria-haspopup': 'listbox',
'aria-expanded': 'false',
'aria-label': labelText || $originalSelect.attr('data-placeholder') || 'Select options',
'aria-multiselectable': 'true',
'tabindex': '0'
});
}
// Update aria-expanded on open/close
$container.on('chosen:showing_dropdown', function() {
$chosenSingle.add($chosenChoices).attr('aria-expanded', 'true');
});
$container.on('chosen:hiding_dropdown', function() {
$chosenSingle.add($chosenChoices).attr('aria-expanded', 'false');
});
// Set role on dropdown
$container.find('.chosen-drop').attr('role', 'listbox');
$container.find('.chosen-results li').attr('role', 'option');
// Mark as enhanced
$container.data('a11y-enhanced', true);
});
};
/**
* Enhance SlimSelect with ARIA attributes
*/
var enhanceSlimSelectAccessibility = function() {
jQuery('.ss-main').each(function() {
var $ssMain = jQuery(this);
var $originalSelect = $ssMain.prev('select');
if (!$originalSelect.length) {
return;
}
var $label = jQuery('label[for="' + $originalSelect.attr('id') + '"]');
var labelText = $label.length ? $label.text().trim() : '';
$ssMain.attr({
'role': 'combobox',
'aria-haspopup': 'listbox',
'aria-label': labelText || $originalSelect.attr('data-placeholder') || 'Select option',
'aria-multiselectable': $originalSelect.attr('multiple') ? 'true' : 'false'
});
});
};
/**
* Enhance TagsInput with ARIA attributes
*/
var enhanceTagsInputAccessibility = function() {
jQuery('div.tagsinput').each(function() {
var $tagsInput = jQuery(this);
var $originalInput = $tagsInput.next('input[type="text"]');
if (!$originalInput.length) {
return;
}
var inputId = $originalInput.attr('id');
var $label = jQuery('label[for="' + inputId + '"]');
var labelText = $label.length ? $label.text().trim() : 'Enter tags';
$tagsInput.attr({
'role': 'list',
'aria-label': labelText
});
// Set role on individual tags
$tagsInput.find('span.tag').each(function() {
jQuery(this).attr('role', 'listitem');
});
// Make tag input accessible
var $input = $tagsInput.find('input');
$input.attr({
'aria-label': 'Add new tag',
'aria-describedby': inputId + '-help'
});
// Add help text if doesn't exist
if (inputId && !jQuery('#' + inputId + '-help').length) {
$tagsInput.after(
'<span id="' + inputId + '-help" class="sr-only">' +
'Type and press enter to add tags. Press backspace to remove the last tag.' +
'</span>'
);
}
});
};
/**
* Enhance Datepickers with ARIA attributes
*/
var enhanceDatepickerAccessibility = function() {
jQuery('input.hasDatepicker').each(function() {
var $input = jQuery(this);
var inputId = $input.attr('id');
var $label = jQuery('label[for="' + inputId + '"]');
var labelText = $label.length ? $label.text().trim() : '';
$input.attr({
'role': 'textbox',
'aria-label': labelText || 'Select date',
'aria-describedby': inputId + '-help'
});
// Add help text if doesn't exist
if (inputId && !jQuery('#' + inputId + '-help').length) {
$input.after(
'<span id="' + inputId + '-help" class="sr-only">' +
'Date input. Use arrow keys to navigate calendar. Press enter to select date.' +
'</span>'
);
}
});
// Enhance datepicker widget when it opens
jQuery(document).on('focus', 'input.hasDatepicker', function() {
setTimeout(function() {
var $widget = jQuery('#ui-datepicker-div');
if ($widget.is(':visible')) {
$widget.attr({
'role': 'dialog',
'aria-label': 'Choose date',
'aria-modal': 'true'
});
}
}, 100);
});
};
/**
* Fix time picker label associations
*/
var fixTimepickerLabels = function() {
jQuery('input[type="time"]').each(function() {
var $timeInput = jQuery(this);
var id = $timeInput.attr('id');
if (!id) {
return;
}
// If no label exists, create ARIA label from context
if (!jQuery('label[for="' + id + '"]').length) {
var labelText = 'Time';
// Try to infer from nearby elements
var $prevLabel = $timeInput.closest('.form-group').find('label').first();
if ($prevLabel.length) {
labelText = $prevLabel.text().trim() + ' time';
}
$timeInput.attr('aria-label', labelText);
}
});
};
/**
* Make kanban cards keyboard accessible
*/
var enhanceKanbanCardAccessibility = function() {
jQuery('.ticketBox').each(function() {
var $card = jQuery(this);
if (!$card.attr('tabindex')) {
$card.attr('tabindex', '0');
}
var headline = $card.find('.ticketHeadline').text().trim();
if (headline) {
$card.attr({
'role': 'article',
'aria-label': 'Task: ' + headline
});
}
// Make card clickable with keyboard (only if not already bound)
if (!$card.data('keyboard-bound')) {
$card.on('keydown', function(e) {
// Only handle Enter/Space if the card itself has focus
// Don't intercept events from interactive children (dropdowns, buttons, links, inputs)
var $target = jQuery(e.target);
// Check if the target is an interactive element
var isInteractive = $target.is('a, button, input, select, textarea, [role="button"], [tabindex]') ||
$target.closest('.dropdown-toggle, .ticketDropdown, .inlineDropDownContainer').length > 0;
// Only handle the event if the card itself was focused and not an interactive child
if ((e.key === 'Enter' || e.key === ' ') && !isInteractive && e.target === this) {
e.preventDefault();
var $link = $card.find('a').first();
if ($link.length) {
$link[0].click();
}
}
});
$card.data('keyboard-bound', true);
}
});
};
/**
* Initialize all accessibility enhancements
*/
var init = function() {
// Run immediately on page load
enhanceChosenAccessibility();
enhanceSlimSelectAccessibility();
enhanceTagsInputAccessibility();
enhanceDatepickerAccessibility();
fixTimepickerLabels();
enhanceKanbanCardAccessibility();
// Re-run when new content is loaded (HTMX, modals, etc.)
jQuery(document).on('htmx:afterSwap shown.bs.modal', function() {
setTimeout(function() {
enhanceChosenAccessibility();
enhanceSlimSelectAccessibility();
enhanceTagsInputAccessibility();
enhanceDatepickerAccessibility();
fixTimepickerLabels();
enhanceKanbanCardAccessibility();
}, 100);
});
// Re-run when Chosen is re-initialized
jQuery(document).on('chosen:ready', function() {
// Wait a bit longer to ensure Chosen is fully ready
setTimeout(enhanceChosenAccessibility, 100);
});
// Single retry after initial page load to catch late-initializing dropdowns
setTimeout(enhanceChosenAccessibility, 1000);
};
// Public API
return {
init: init,
enhanceChosenAccessibility: enhanceChosenAccessibility,
enhanceSlimSelectAccessibility: enhanceSlimSelectAccessibility,
enhanceTagsInputAccessibility: enhanceTagsInputAccessibility,
enhanceDatepickerAccessibility: enhanceDatepickerAccessibility,
fixTimepickerLabels: fixTimepickerLabels,
enhanceKanbanCardAccessibility: enhanceKanbanCardAccessibility
};
})();
// Initialize on page load
jQuery(document).ready(function() {
leantime.accessibilityController.init();
});

View File

@@ -0,0 +1,159 @@
leantime.dateHelper = (function () {
//php date format mapper
let phpFormatSeed = {
'A': 'A', // Uppercase Ante meridiem and Post meridiem: AM or PM
'a': 'a', // Lowercase Ante meridiem and Post meridiem: am or pm
'B': 'B', // Swatch Internet Time. There's no JavaScript equivalent
'c': 'c', // ISO 8601 date: 2004-02-12T15:19:21+00:00
'D': 'D', // Textual representation of a day - Mon through Sun
'd': 'd', // Day of the month, 2 digits with leading zeros: 01 to 31
'e': 'e', // Timezone identifier (deprecated in Moment.js)
'F': 'F', // Full month name, e.g. January
'G': 'G', // 24-hour format of an hour without leading zeros: 0 to 23
'g': 'g', // 12-hour format of an hour without leading zeros: 1 to 12
'H': 'H', // 24-hour format of an hour with leading zeros: 00 to 23
'h': 'h', // 12-hour format of an hour with leading zeros: 01 to 12
'I': 'I', // Whether or not the date is in daylight saving time: 1 if Daylight Saving Time, 0 otherwise.
'i': 'i', // Minutes with leading zeros: 00 to 59
'j': 'j', // Day of the month without leading zeros: 1 to 31
'L': 'L', // Whether it's a leap year: 1 if it is a leap year, 0 otherwise.
'l': 'l', // A full textual representation of the day of the week: Sunday through Saturday
'm': 'm', // Numeric representation of a month, with leading zero: 01 through 12
'M': 'M', // A short textual representation of a month, three letters: Jan through Dec
'N': 'N', // ISO-8601 numeric representation of the day of the week: 1 (for Monday) through 7 (for Sunday)
'n': 'n', // Numeric representation of a month, without leading zeros: 1 through 12
'O': 'O', // Difference to Greenwich time (GMT) in hours: Example: +0200
'o': 'o', // ISO-8601 year number
'P': 'P', // Difference to Greenwich time (GMT) with colon between hours and minutes: Example: +02:00
'r': 'r', // » RFC 2822 formatted date: Example: Thu, 21 Dec 2000 16:01:07 +0200
'S': 'S', // English ordinal suffix for the day of the month, 2 characters: st, nd, rd or th. Works well with j
's': 's', // Seconds, with leading zeros: 00 through 59
'T': 'T', // Timezone abbreviation: Examples: EST, MDT, PDT ...
't': 't', // Number of days in the given month: 28 through 31
'U': 'U', // The seconds since the Unix Epoch (January 1 1970 00:00:00 GMT)
'u': 'u', // Microseconds: Example: 654321
'v': 'v', // Milliseconds (added in PHP 7.0.0). Example: 654
'W': 'W', // ISO-8601 week number of year, weeks starting on Monday: Example: 42 (the 42nd week in the year)
'w': 'w', // Numeric representation of the day of the week: 0 (for Sunday) through 6 (for Saturday)
'Z': 'Z', // Timezone offset in seconds. The offset for timezones west of UTC is always negative, and for those east of UTC is always positive.
'z': 'z', // The day of the year (starting from 0): 0 through 365
'Y': 'Y', // A full numeric representation of a year, 4 digits: Examples: 1999 or 2003
};
var target = {
"jquery": {},
"luxon": {}
};
target.jquery = {
'd': 'dd', // Day of month with leading zeroes
'D': 'D', // Short day name
'j': 'd', // Day of month with no leading zeroes
'l': 'DD', // Full day name
'N': '', // jQuery DatePicker does not have an ISO-8601 numeric representation of the day of the week
'S': '', // jQuery DatePicker does not support ordinal suffixes for the day of the month
'w': '', // jQuery DatePicker does not have a numeric day of week,
'z': 'o', // Day of the year
'W': '', // jQuery DatePicker does not have ISO-8601 week number of year
'F': 'MM', // Full month name
'm': 'mm', // Month of the year, leading zero
'M': 'M', // Short month name
'n': 'm', // Month of the year without leading zero
't': '', // jQuery DatePicker does not have number of days in the given month
'L': '', // jQuery DatePicker does not have leap year detection
'o': 'yy', // ISO-8601 year number - can be approximated with four digit year
'Y': 'yy', // Year, four digits
'y': 'y', // Year, two digits
'a': '', // jQuery DatePicker does not have lowercase ante meridiem and post meridiem
'A': '', // jQuery DatePicker does not have uppercase ante meridiem and post meridiem
'B': '', // jQuery DatePicker does not support Swatch Internet Time
'g': '', // jQuery DatePicker does not support 12-hour format without leading zero
'G': '', // jQuery DatePicker does not support 24-hour format without leading zero
'h': '', // jQuery DatePicker does not support 12-hour format with leading zero
'H': '', // jQuery DatePicker does not support 24-hour format with leading zero,
'i': '', // jQuery DatePicker does not support minutes with leading zero
's': '', // jQuery DatePicker does not support seconds with leading zero
'u': '', // jQuery DatePicker does not support microseconds
'e': '', // jQuery DatePicker does not support timezone identifiers
'I': '', // jQuery DatePicker does not support whether or not the date is in daylight saving time
'O': '', // jQuery DatePicker does not support difference to Greenwich time
'P': '', // jQuery DatePicker does not have difference to Greenwich time
'T': '', // jQuery DatePicker does not support timezone abbreviation
'Z': '', // jQuery DatePicker does not support timezone offset in seconds
'c': '', // jQuery DatePicker does not support ISO 8601 dates
'r': '', // jQuery DatePicker does not support RFC 2822 dates
'U': '@' // Unix timestamp - seconds since January 1 1970 00:00:00 GMT
};
target.luxon = {
'd': 'dd', // Day of the month, two digits with leading zeros
'D': 'ccc', // A textual representation of a day, abbreviated (Mon through Sun)
'j': 'd', // Day of the month without leading zeros
'l': 'cccc', // A full textual representation of the day of the week (Sunday through Saturday)
'N': 'c', // ISO-8601 numeric representation of the day of the week (1 for Monday through 7 for Sunday)
'S': '', // English ordinal suffix for the day of the month, 2 characters. No equivalent in Luxon.
'w': 'c', // Numeric representation of the day of the week (0 for Sunday through 6 for Saturday). Converted to ISO (1 for Monday through 7 for Sunday)
'z': 'o', // Day of the year (0 through 365)
'W': 'WW', // ISO-8601 week number of year, weeks starting on Monday
'F': 'MMMM', // A full textual representation of a month (January through December)
'm': 'LL', // Numeric representation of a month, with leading zeros (01 through 12)
'M': 'LLL', // A short textual representation of a month (Jan through Dec)
'n': 'L', // Numeric representation of a month, without leading zeros (1 through 12)
't': '', // Number of days in the given month. No equivalent in Luxon.
'L': '', // Whether it's a leap year (1 if it is a leap year, 0 otherwise). No equivalent in Luxon.
'o': 'kk', // ISO-8601 year number
'Y': 'yyyy', // A full numeric representation of a year, 4 digits
'y': 'yy', // A two digit representation of a year
'a': 'a', // Lowercase Ante meridiem and Post meridiem (am or pm)
'A': 'a', // Uppercase Ante meridiem and Post meridiem (AM or PM)
'B': '', // Swatch Internet time (000 through 999). No equivalent in Luxon.
'g': 'h', // 12-hour format of an hour without leading zeros (1 through 12)
'G': 'H', // 24-hour format of an hour without leading zeros (0 through 23)
'h': 'hh', // 12-hour format of an hour with leading zeros (01 through 12)
'H': 'HH', // 24-hour format of an hour with leading zeros (00 through 23)
'i': 'mm', // Minutes with leading zeros (00 to 59)
's': 'ss', // Seconds with leading zeros (00 through 59)
'u': 'SSS', // Microseconds (up to 999), mapped to milliseconds
'v': 'SSS', // Milliseconds (added in PHP 7.0.0). No equivalent in Luxon, so we map it to the same as 'u'
'e': 'z', // Timezone identifier (e.g., UTC, GMT, Atlantic/Azores)
'I': '', // Whether or not the date is in daylight saving time. No equivalent in Luxon.
'O': 'ZZ', // Difference to Greenwich time (GMT) without colon between hours and minutes (+0200)
'P': 'ZZ', // Difference to Greenwich time (GMT) with colon between hours and minutes (+02:00)
'T': 'ZZZ', // Timezone (e.g., EST, MDT). Mapped to the closest thing in Luxon, display in the user's locale.
'Z': '', // Timezone offset in seconds. The offset for time zones west of UTC is always negative, and for those east of UTC is always positive. No equivalent in Luxon.
'c': '', // ISO 8601 formatted date. No equivalent in Luxon.
'r': '', // RFC2822 formatted date. No equivalent in Luxon.
'U': 'X', // Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT)
};
var mapFormat = function (inputPhpFormat, targetFormat) {
let mapAgainstFormat = target[targetFormat];
let mappedFormat = "";
inputPhpFormat.split('').forEach(function (character) {
if (mapAgainstFormat[character] !== undefined) {
mappedFormat = mappedFormat + "" + mapAgainstFormat[character]
} else {
mappedFormat = mappedFormat + "" + character;
}
});
return mappedFormat;
}
var getFormatFromSettings = function (formattingKey, targetFormat) {
let format = leantime.i18n.__("language."+formattingKey);
return mapFormat(format, targetFormat);
}
return {
mapFormat:mapFormat,
getFormatFromSettings:getFormatFromSettings
};
})();

View File

@@ -0,0 +1,92 @@
leantime.dateController = (function () {
function getBaseDatePickerConfig(callback)
{
return {
numberOfMonths: 1,
dateFormat: leantime.dateHelper.getFormatFromSettings("dateformat", "jquery"),
dayNames: leantime.i18n.__("language.dayNames").split(","),
dayNamesMin: leantime.i18n.__("language.dayNamesMin").split(","),
dayNamesShort: leantime.i18n.__("language.dayNamesShort").split(","),
monthNames: leantime.i18n.__("language.monthNames").split(","),
monthNamesShort: leantime.i18n.__("language.monthNamesShort").split(","),
currentText: leantime.i18n.__("language.currentText"),
closeText: leantime.i18n.__("language.closeText"),
buttonText: leantime.i18n.__("language.buttonText"),
isRTL: leantime.i18n.__("language.isRTL") === "true" ? 1 : 0,
nextText: leantime.i18n.__("language.nextText"),
prevText: leantime.i18n.__("language.prevText"),
weekHeader: leantime.i18n.__("language.weekHeader"),
firstDay: leantime.i18n.__("language.firstDayOfWeek"),
onSelect: callback
};
}
function getDate( element )
{
var dateFormat = leantime.dateHelper.getFormatFromSettings("dateformat", "jquery");
var date;
try {
date = jQuery.datepicker.parseDate(dateFormat, element.value);
} catch ( error ) {
date = null;
console.log(error);
}
return date;
}
var initDateRangePicker = function (fromElement, toElement, minDistance) {
Date.prototype.addDays = function (days) {
this.setDate(this.getDate() + days);
return this;
};
//Check for readonly status and disable datepicker if readonly
jQuery.datepicker.setDefaults({
beforeShow: function (i) {
if (jQuery(i).attr('readonly')) {
return false;
}
}
});
var from = jQuery(fromElement).datepicker(getBaseDatePickerConfig())
.on(
"change",
function (date) {
to.datepicker("option", "minDate", getDate(this));
if (jQuery(toElement).val() == '') {
jQuery(toElement).val(jQuery(fromElement).val());
}
}
);
var to = jQuery(toElement).datepicker(getBaseDatePickerConfig())
.on(
"change",
function () {
from.datepicker("option", "maxDate", getDate(this));
}
);
};
var initDatePicker = function (element, callback) {
jQuery(element).datepicker(
getBaseDatePickerConfig(callback)
);
}
// Make public what you want to have public, everything else is private
return {
initDateRangePicker:initDateRangePicker,
initDatePicker:initDatePicker,
};
})();

View File

@@ -0,0 +1,169 @@
leantime.modals = (function () {
// The URL of the modal currently open. Used to make the hashchange->openModal
// path idempotent: a repeated hashchange pointing at the already-open modal
// must NOT rebuild it. Rebuilding re-fetches and re-inserts the whole
// .nyroModalCont, destroying any field the user is typing into (focus loss).
var currentModalUrl = null;
var setCustomModalCallback = function(callback) {
if(typeof callback === 'function') {
window.globalModalCallback = callback;
}
}
var openModal = function () {
var modalOptions = {
sizes: {
minW: 500,
minH: 200
},
resizable: true,
autoSizable: true,
callbacks: {
beforePostSubmit: function () {
jQuery(".showDialogOnLoad").show();
// Destroy Tiptap editors
if(window.leantime?.tiptapController?.registry) {
var count = window.leantime.tiptapController.registry.destroyAll();
if(count > 0) {
console.log('[Modal] Destroyed', count, 'Tiptap editor(s)');
}
}
},
beforeShowCont: function () {
jQuery(".showDialogOnLoad").show();
// Destroy Tiptap editors
if(window.leantime?.tiptapController?.registry) {
window.leantime.tiptapController.registry.destroyAll();
}
},
afterShowCont: function () {
window.htmx.process('.nyroModalCont');
jQuery(".formModal, .modal").nyroModal(modalOptions);
// Idempotent + scoped to the modal so it doesn't re-instance
// page tooltips (see app.js initTooltips).
window.leantime?.initTooltips?.(document.querySelector('.nyroModalCont'));
// Initialize Tiptap editors in modal (after small delay for DOM settlement)
setTimeout(function() {
if(window.leantime?.tiptapController?.initEditors) {
var modalContent = document.querySelector('.nyroModalCont');
if(modalContent) {
window.leantime.tiptapController.initEditors(modalContent);
}
}
}, 100);
},
beforeClose: function () {
currentModalUrl = null;
try{
history.pushState("", document.title, window.location.pathname + window.location.search);
}catch(error){
//Code to handle error comes here
console.log("Issue pushing history");
}
if(typeof window.globalModalCallback === 'function') {
window.globalModalCallback();
}else{
location.reload();
}
}
},
titleFromIframe: true
};
var url = window.location.hash.substring(1);
if(url.includes("showTicket")
|| url.includes("ideaDialog")
|| url.includes("articleDialog")) {
// These detail modals are intentionally large on desktop. On
// mobile/tablet (<1200px) the 1800px minimum makes them unusable,
// so only apply it on desktop. CSS caps the container to ~95vw. #3088
if (window.innerWidth >= 1200) {
modalOptions.sizes.minW = 1800;
modalOptions.sizes.minH = 1800;
}
}
// Never let any modal's minimum width exceed the viewport on small
// screens, otherwise it forces horizontal overflow. #3088
if (window.innerWidth < 1200) {
modalOptions.sizes.minW = Math.min(modalOptions.sizes.minW, window.innerWidth - 20);
}
//Ensure we have no trailing slash at the end.
var baseUrl = leantime.appUrl.replace(/\/$/, '');
var urlParts = url.split("/");
if(urlParts.length>2 && urlParts[1] !== "tab") {
var targetUrl = baseUrl+""+url;
// Idempotency guard: if the modal for this exact URL is already open, a
// repeated hashchange must NOT rebuild it — rebuilding destroys the DOM
// (and any input the user is typing into), stealing focus.
if (targetUrl === currentModalUrl && jQuery.nmTop()) {
return;
}
currentModalUrl = targetUrl;
// Guard against nyroModal losing its jQuery registration between opens.
// This can happen when the modal close/reinit cycle runs before the
// document-ready wrapper in jquery.nyroModal.custom.js has re-fired.
if (typeof jQuery.nmManual !== 'function') {
console.warn('[Modal] jQuery.nmManual not available, retrying...');
setTimeout(function() {
if (typeof jQuery.nmManual === 'function') {
jQuery.nmManual(targetUrl, modalOptions);
} else {
console.error('[Modal] jQuery.nmManual unavailable after retry — nyroModal may not be loaded.');
}
}, 100);
return;
}
jQuery.nmManual(targetUrl, modalOptions);
}
}
var closeModal = function () {
if( jQuery.nmTop()) {
jQuery.nmTop().close();
}
}
return {
openModal:openModal,
setCustomModalCallback:setCustomModalCallback,
closeModal:closeModal
};
})();
jQuery(document).ready(function() {
leantime.modals.openModal();
});
window.addEventListener("hashchange", function () {
leantime.modals.openModal();
});
// 'lt:ui:modal.close' is the canonical client event. The legacy names ('closeModal',
// 'HTMX.closemodal', 'Htmx.CloseModal') are kept for the migration window and also close a
// pre-existing gap: emitters used three different casings but only 'closeModal' had a listener.
var onCloseModalEvent = function (evt) {
leantime.modals.closeModal();
};
window.addEventListener("lt:ui:modal.close", onCloseModalEvent);
window.addEventListener("closeModal", onCloseModalEvent);
window.addEventListener("HTMX.closemodal", onCloseModalEvent);
window.addEventListener("Htmx.CloseModal", onCloseModalEvent);

View File

@@ -0,0 +1,845 @@
(function($) {
$.fn.nestedSortable = function(options) {
const nestingRules = {
// Define what can be nested under each type
allowedChildren: {
'root': ['section'],
'section': ['milestone', 'task'],
'milestone': ['milestone', 'task'],
'task': ['task', 'subtask'],
'subtask': ['subtask']
},
// Define where each type can be nested
allowedParents: {
'section': ['root'],
'milestone': ['section', 'milestone'],
'task': ['section', 'milestone', 'task'],
'subtask': ['task', 'subtask']
}
};
let dragInProgress = false;
const dragState = {
lastMouseX: 0,
initialMouseX: 0,
lastMouseY: 0,
initialMouseY: 0,
horizontalThreshold: 30, // Pixels to move horizontally before changing level
currentLevel: 0,
startLevel: 0,
targetContainer: null,
currentIndent: 0,
intent: null,
maxIndent: 3, // Maximum indent level
horizontalDirection: 0, // -1 for left, 1 for right
moveToRoot: false, // Flag to indicate if item should move to root
bottomThreshold: 25, // Pixels from bottom of container to trigger root move
rootContainer: null, // Reference to the root container
calendarDrag: false, // Flag to indicate if dragging to calendar
pomodoroTargeted: false, // Flag to indicate if dragging to pomodoro timer
bottomIndicatorVisible: false, // Flag to track if bottom indicator is visible
animationFrame: null,
itemType: null, // Type of the item being dragged
startParent: null, // Original parent of the dragged item
validDropTarget: true, // Flag to track if current drop target is valid
hasErrors: false,
externalDropInProgress: false,
isNewLevel: false,
lastDragMovementTime: 0, // Track last time drag movement was processed
debounceDelay: 50, // Delay in milliseconds for debouncing
};
function debounce(func, wait, immediate) {
let timeout;
return function() {
const context = this, args = arguments;
const later = function() {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}
// Function to check if nesting is allowed
function isNestingAllowed(currentItem, targetItem, ignoreProjectCheck = false) {
const targetContainerType = getContainerType(targetItem);
const currentItemType = getItemType(currentItem);
// Prevent circular references
if (isCircularReference(currentItem, targetItem)) {
dragState.nestingErrorType = 'circular';
return false;
}
if(ignoreProjectCheck === false) {
const currentProject = getItemProject(currentItem);
let targetProject = getItemProject(targetItem);
if(typeof targetProject === 'undefined') {
targetProject = jQuery(targetItem).parent().data("project");
}
if(!isProjectAllowed(currentProject, targetProject)){
dragState.nestingErrorType = 'project';
return false;
}
}
//const targetProject = dragState.targetContainer.data("project");
if(!isHierarchyAllowed(currentItemType, targetContainerType)){
dragState.nestingErrorType = 'types';
return false;
}
return true;
}
function isHierarchyAllowed(itemType, containerType) {
return nestingRules.allowedParents[itemType]?.includes(containerType) === true;
}
function isProjectAllowed(fromProjectId, toProjectId) {
//If the targets container project id is not defined we are most likely in thge root
if(typeof toProjectId === 'undefined') {
return true;
}
return fromProjectId === toProjectId;
}
// New function to check for circular references
function isCircularReference(draggedItem, targetContainer) {
if (!draggedItem || !targetContainer) {
return false;
}
// Check if target is a descendant of dragged item
let current = $(targetContainer);
while (current.length) {
if (current.is(draggedItem)) {
return true;
}
current = current.parent().closest('.sortable-item');
}
return false;
}
// Function to get container type
function getContainerType(container) {
//container can be list or item
// Check if the container itself has a container-type data attribute
const containerType = jQuery(container).data('containerType');
if (containerType) {
return containerType;
}
//Check if the container is an item and has item type
const itemType = jQuery(container).data('itemType');
if (itemType) {
return itemType;
}
// Check the parent of the thing
const parentItem = jQuery(container).closest('.sortable-item');
if (parentItem.length > 0) {
return parentItem.data('itemType');
}
// Fallback to root
return 'root';
}
// Function to get item type
function getItemType(item) {
if (!item || item.length === 0) {
//console.warn("Attempted to get type of non-existent item");
return null;
}
return jQuery(item).data('itemType');
}
function getItemProject(item) {
if (!item || item.length === 0) {
//console.warn("Attempted to get project of non-existent item");
return null;
}
return jQuery(item).data('project');
}
function findPreviousElement(element) {
// Check if there's a previous sibling
var prevSibling = element.prev(".sortable-item");
if (prevSibling.length) {
// If the previous sibling has nested items, get the deepest last one
var deepestNested = findDeepestNestedItem(prevSibling);
return deepestNested || prevSibling;
}
// If no previous sibling, go up to parent and try again
var parentList = element.parent(".sortable-list");
var parentItem = parentList.parent(".sortable-item");
if (parentItem.length) {
return parentItem;
}
return $(); // Empty jQuery object if nothing found
}
// Find the deepest nested item within an element
function findDeepestNestedItem(element) {
var nestedList = element.find("> .sortable-list");
if (nestedList.length) {
var lastNestedItem = nestedList.children(".sortable-item").last();
if (lastNestedItem.length) {
var deeperNested = findDeepestNestedItem(lastNestedItem);
return deeperNested.length ? deeperNested : lastNestedItem;
}
}
return element;
}
// Reset all drag state variables
function resetDragState() {
Object.assign(dragState, {
lastMouseX: 0,
initialMouseX: 0,
lastMouseY: 0,
initialMouseY: 0,
horizontalDirection: 0,
currentIndent: 0,
targetContainer: null,
startLevel: 0,
currentLevel: 0,
lastIndentChange: 0,
intent: null,
item: null,
itemType: null,
itemProject: null,
itemAbove: null,
startParent: null,
moveToRoot: false,
calendarDrag: false,
pomodoroTargeted: false,
item: null,
bottomIndicatorVisible: false,
hasErrors: false,
externalDropInProgress: false,
isNewLevel: false,
nestingErrorType: ''
});
jQuery('.highlight-drop').removeClass('highlight-drop');
jQuery('.highlight-drop-error').removeClass('highlight-drop-error');
jQuery('.pomodoroDrop').removeClass('pomodoro-drop-target');
}
//Initialize drag state with current ui and event object
function initDragState(event, ui) {
dragState.lastMouseX = event.pageX;
dragState.initialMouseX = event.pageX;
dragState.lastMouseY = event.pageY;
dragState.initialMouseY = event.pageY;
dragState.horizontalDirection = 0;
dragState.intent = null;
// ?? figure out why both
dragState.currentIndent = ui.item.parents('.sortable-list').length - 1;
dragState.startLevel = ui.item.parents('.sortable-list').length;
dragState.currentLevel = dragState.startLevel;
//Time elapsed since last event change
dragState.lastIndentChange = 0;
//Current Element
dragState.item = ui.item;
dragState.itemType = getItemType(ui.item);
dragState.itemProject = getItemProject(ui.item);
dragState.itemAbove = findPreviousElement(ui.item);
//Target Container
dragState.targetContainer = event.target;
dragState.rootContainer = jQuery('.sortable-list').first();
dragState.startParent = ui.item.parent();
dragState.moveToRoot = false;
dragState.calendarDrag = false;
dragState.pomodoroTargeted = false;
dragState.externalDropInProgress = false;
dragState.bottomIndicatorVisible = false;
dragState.nestingErrorType = "";
}
function isDragOut(currentMouseX, currentMouseY) {
// Check if we're dragging towards the calendar
const calendarEl = jQuery('.minCalendarWrapper');
if (calendarEl.length) {
const calendarRect = calendarEl[0].getBoundingClientRect();
// If we're moving towards the calendar, flag it
if (currentMouseX > calendarRect.left - 10 && currentMouseY > calendarRect.top - 10 &&
currentMouseX < calendarRect.right + 10 && currentMouseY < calendarRect.bottom + 10) {
dragState.calendarDrag = true;
return true; // Exit early to let calendar handle the drag
}
}
// Check if we're dragging towards the pomodoro timer
const pomodoroEl = jQuery('.pomodoroDrop');
if (pomodoroEl.length) {
const pomodoroRect = pomodoroEl[0].getBoundingClientRect();
// If we're moving towards the pomodoro timer, flag it - use more generous boundaries
if (currentMouseX > pomodoroRect.left +10 && currentMouseY > pomodoroRect.top + 10 &&
currentMouseX < pomodoroRect.right - 10 && currentMouseY < pomodoroRect.bottom -10) {
dragState.pomodoroTargeted = true;
dragState.externalDropInProgress = true;
jQuery(pomodoroEl).addClass('pomodoro-drop-target');
return true; // Exit early to let pomodoro handle the drag
}
jQuery(pomodoroEl).removeClass('pomodoro-drop-target');
}
return false;
}
// Constants for angle thresholds
const INTENT_THRESHOLDS = {
VERTICAL: 10, // Degrees from pure vertical (0°/180°)
DIAGONAL: 70, // Degrees from diagonal (45°/135°/225°/315°)
HORIZONTAL: 10, // Degrees from pure horizontal (90°/270°)
DEAD_ZONE: 0, // Dead zone around boundaries to prevent accidental triggering
MIN_DISTANCE: 5, // Minimum distance (px) before intent is detected
HORIZONTAL_THRESHOLD: 5 // Horizontal distance (px) before diagonal/horizontal is detected
};
// Intent types
const INTENT = {
NEST: 'nest',
REORDER_UP: 'reorder-up',
REORDER_DOWN: 'reorder-down',
NEST_UNDER_PREV: 'nest-under-previous',
NEST_UNDER_NEXT: 'nest-under-next',
UNNEST: 'unnest',
UNNEST_AND_DOWN: 'unnest-and-down',
UNNEST_FROM_PREV: 'unnest-from-previous',
EXPAND_COLLAPSE: 'expand-collapse',
NOT_MOVED: 'not-moved',
NONE: 'none'
};
/**
* Determines user intent based on mouse movement
* @param {number} startX - Starting X coordinate
* @param {number} startY - Starting Y coordinate
* @param {number} currentX - Current X coordinate
* @param {number} currentY - Current Y coordinate
* @returns {Object} Intent object with type and additional metadata
*/
function determineUserIntent(startX, startY, currentX, currentY) {
// Calculate distance and basic vectors
const dx = currentX - startX;
const dy = currentY - startY;
const distance = Math.sqrt(dx * dx + dy * dy);
// If we haven't moved enough, no intent is detected yet
if (distance < INTENT_THRESHOLDS.MIN_DISTANCE) {
return { type: INTENT.NOT_MOVED, angle: 0, distance };
}
// Calculate angle in degrees (0° is up, 90° is right, etc.)
// Math.atan2 returns radians from -π to π, with 0 at "right"
// We convert to degrees and adjust so 0° is "up"
let angle = Math.atan2(dx, -dy) * (180 / Math.PI);
if (angle < 0) angle += 360; // Convert to 0-360° range
// Calculate horizontal distance (absolute)
const horizontalDistance = Math.abs(dx);
// Create result object with metadata
const result = {
type: INTENT.NONE,
angle,
distance,
horizontalDistance,
verticalDistance: Math.abs(dy),
dx,
dy
};
// If horizontal distance is below threshold, only allow vertical intents
if (horizontalDistance < INTENT_THRESHOLDS.HORIZONTAL_THRESHOLD) {
//console.log("not enough horizontal movement ");
if (dy < 0) {
return { ...result, type: INTENT.REORDER_UP };
} else {
return { ...result, type: INTENT.REORDER_DOWN };
}
}
// Check for pure vertical movement (reordering)
if (isWithinRange(angle, 0, INTENT_THRESHOLDS.VERTICAL) ||
isWithinRange(angle, 180, INTENT_THRESHOLDS.VERTICAL)) {
if (dy < 0) {
return { ...result, type: INTENT.REORDER_UP };
} else {
return { ...result, type: INTENT.REORDER_DOWN };
}
}
// Check for diagonal movement (nesting/unnesting)
if (isWithinRange(angle, 45, INTENT_THRESHOLDS.DIAGONAL)) {
return { ...result, type: INTENT.NEST };
}
if (isWithinRange(angle, 90, INTENT_THRESHOLDS.HORIZONTAL)) {
return { ...result, type: INTENT.NEST };
}
if (isWithinRange(angle, 135, INTENT_THRESHOLDS.DIAGONAL)) {
return { ...result, type: INTENT.NEST };
}
if (isWithinRange(angle, 225, INTENT_THRESHOLDS.DIAGONAL)) {
return { ...result, type: INTENT.UNNEST };
}
if (isWithinRange(angle, 270, INTENT_THRESHOLDS.HORIZONTAL)) {
return { ...result, type: INTENT.UNNEST };
}
if (isWithinRange(angle, 315, INTENT_THRESHOLDS.DIAGONAL)) {
return { ...result, type: INTENT.UNNEST };
}
// If we get here, we're in an undefined area
return result;
}
/**
* Checks if an angle is within a range of another angle, accounting for dead zone
* @param {number} angle - The angle to check
* @param {number} target - The target angle
* @param {number} range - The range around the target angle
* @returns {boolean} True if angle is within range
*/
function isWithinRange(angle, target, range) {
const effectiveRange = range - INTENT_THRESHOLDS.DEAD_ZONE;
//console.log("is", Math.abs(((angle - target + 180) % 360) - 180), " less than ", effectiveRange, "of", target, ": ", Math.abs(((angle - target + 180) % 360) - 180) <= effectiveRange);
return Math.abs(((angle - target + 180) % 360) - 180) <= effectiveRange;
}
const debouncedDragMovement = debounce(function(event, ui) {
const now = Date.now();
// Only process if enough time has passed since last update
if (now - dragState.lastDragMovementTime > dragState.debounceDelay) {
dragState.lastDragMovementTime = now;
}
}, 50); // 50ms debounce time - adjust as needed
function handleDragMovement(event, ui) {
// Use the debounced version instead of direct execution
// const intent = determineUserIntent(
// dragState.initialMouseX,
// dragState.initialMouseY,
// event.pageX,
// event.pageY
// );
//console.log("intent", intent.type);
if (isDragOut(event.clientX, event.clientY) === true) {
return;
}
// updateVisualFeedback(intent, ui);
//Taqrget list is
let targetItem = ui.placeholder.parent(".sortable-list");
// if (intent.type === INTENT.NEST) {
// targetItem = findPreviousElement(ui.placeholder);
// dragState.intent = intent.type;
// } else {
// dragState.intent = null;
// }
let targetList = getTargetContainerList(targetItem);
let targetCandidateType = getItemType(targetList);
let targetCandidateProject = getItemProject(targetList);
if (isNestingAllowed(ui.item, targetList) == false) {
jQuery('.highlight-drop').removeClass('highlight-drop');
jQuery('.highlight-drop-error').removeClass('highlight-drop-error');
jQuery(targetList).addClass('highlight-drop-error');
jQuery(targetList).addClass('highlight-drop-error');
//console.log("no nesting allowed");
dragState.targetContainer = null;
return
}
dragState.targetContainer = targetList;
ui.helper.data('droppingTarget', dragState.targetContainer);
jQuery('.highlight-drop').removeClass('highlight-drop');
jQuery('.highlight-drop-error').removeClass('highlight-drop-error');
jQuery(dragState.targetContainer).addClass('highlight-drop');
}
function createNestedContainerIfNeeded(targetItem) {
if (!targetItem.length) return null;
let nestedList = targetItem.find('> .sortable-list');
if (nestedList.length === 0) {
const itemType = getItemType(targetItem);
targetItem.append('<div class="sortable-list"></div>');
nestedList = targetItem.find('> .sortable-list');
nestedList.data('containerType', itemType);
}
return nestedList;
}
function getTargetContainerList(targetItem) {
let nestedList = {};
let targetItemType = getItemType(targetItem);
if(jQuery(targetItem).hasClass("sortable-list")) {
nestedList = jQuery(targetItem);
//dragState.isNewLevel = false;
}else{
nestedList = jQuery(targetItem).find('> .sortable-list').first();
//
//
// if (nestedList.length === 0) {
// targetItem.append('<div class="sortable-list"></div>');
// nestedList = jQuery(targetItem).find('> .sortable-list').first();
// nestedList.data('containerType', targetItemType);
//
// }
// dragState.isNewLevel = true;
}
if (!nestedList.data('containerType')) {
nestedList.data('containerType', targetItemType);
}
return nestedList[0];
}
function updateVisualFeedback(intent, ui) {
jQuery('.highlight-drop').removeClass('highlight-drop');
if (intent.type === INTENT.NEST) {
const prevItem = findPreviousElement(ui.placeholder);
if (prevItem.length) {
prevItem.addClass('highlight-drop');
}
} else if (intent.type === INTENT.UNNEST) {
ui.placeholder.closest('.sortable-list').parent().addClass('highlight-drop');
}
}
// Function to get group key from a sortable item's ancestor group
function getItemGroupKey($item) {
// Find the closest sortable-list that has a data-group-key attribute
const $groupContainer = $item.closest('.sortable-list[data-group-key]');
return $groupContainer.data('group-key') || null;
}
function saveSorting() {
const sortingData = [];
// Get current grouping context from the widget container
const groupBy = jQuery('#yourToDoContainer').data('group-by') || '';
const groupChanges = [];
// Track original group positions before move
function detectGroupChanges() {
if (!groupBy) return;
jQuery('.sortable-item').each(function() {
const $item = jQuery(this);
const itemId = $item.data('id');
const currentGroupKey = getItemGroupKey($item);
const originalGroupKey = $item.data('original-group-key');
// If item moved to a different group
if (originalGroupKey && currentGroupKey && originalGroupKey !== currentGroupKey) {
groupChanges.push({
id: itemId,
fromGroup: originalGroupKey,
toGroup: currentGroupKey,
groupBy: groupBy
});
}
});
}
// Recursively collect all items with their hierarchy
function collectItems(container, parentId = null, level = 0) {
container.children('.sortable-item').each(function (index) {
const $item = jQuery(this);
const itemId = $item.data('id');
const currentGroupKey = getItemGroupKey($item);
// Add this item to the sorting data
sortingData.push({
id: itemId,
parentId: parentId,
parentType: getContainerType($item.parent()),
level: level,
order: index,
groupKey: currentGroupKey
});
// Process children if any
const $childContainer = $item.children('.sortable-list');
if ($childContainer.length) {
collectItems($childContainer, itemId, level + 1);
}
});
}
// Detect any group changes
detectGroupChanges();
// Start collecting from the root containers
jQuery('.sortable-list').not('.sortable-list .sortable-list').each(function () {
collectItems(jQuery(this));
});
// If we're in the middle of a calendar drag, don't save sorting
if (dragState.calendarDrag) {
return;
}
// Send the sorting data to the server
if (sortingData.length > 0) {
//console..log("Saving sorting data:", sortingData);
//console..log("Group changes detected:", groupChanges);
// Convert sorting data to the original indexed format
const requestData = {};
// Add sorting data in original indexed format
sortingData.forEach((item, index) => {
requestData[index] = JSON.stringify(item);
});
// Add group changes and groupBy if present
if (groupChanges.length > 0) {
groupChanges.forEach((change, index) => {
requestData[`groupChanges[${index}]`] = JSON.stringify(change);
});
requestData['groupBy'] = groupBy;
}
htmx.ajax('POST', leantime.appUrl+'/hx/widgets/myToDos/saveSorting', {
target: '#htmx-indicator',
swap: 'none',
values: requestData
});
}
}
sortableInstance = this.sortable({
zIndex: 99999,
appendTo: ".maincontent",
items: '.sortable-item',
connectWith: '.sortable-list',
tolerance: "pointer",
placeholder: "sortable-placeholder",
forcePlaceholderSize: true,
dropOnEmpty: true,
revert: false,
delay: 150, // Increased delay to allow calendar drag to initialize first
distance: 10, // Minimum distance before drag starts
scrollSensitivity: 40,
scrollSpeed: 20,
scroll: false,
helper: "clone",
appendTo: "body", // This ensures the helper
start: function (event, ui) {
// Store initial state
ui.item.data('startPos', ui.item.index());
const startParent = ui.item.parent();
ui.item.data('startParent', startParent);
// Store original group key for group change detection
const originalGroupKey = getItemGroupKey(ui.item);
ui.item.data('original-group-key', originalGroupKey);
// Store original group key for all items (not just the dragged one)
jQuery('.sortable-item').each(function() {
const $item = jQuery(this);
if (!$item.data('original-group-key')) {
$item.data('original-group-key', getItemGroupKey($item));
}
});
// Store the item type
//console..log("Drag started with item:", ui.item);
//console..log("Item parent:", startParent);
dragState.itemType = getItemType(ui.item);
initDragState(event, ui);
dragInProgress = true;
},
sort: function (event, ui) {
handleDragMovement(event, ui);
if (dragState.calendarDrag || dragState.pomodoroTargeted) {
// If we're dragging to the calendar or pomodoro timer, cancel sorting and reset drag state
jQuery(this).sortable('cancel');
resetDragState();
return false;
}
},
stop: function (event, ui) {
//Clean up
// If we were targeting the pomodoro, don't save sorting
if (dragState.pomodoroTargeted) {
resetDragState();
return;
}
if (dragState.hasErrors === false) {
// Save the new order
saveSorting();
}
resetDragState();
dragInProgress = false;
setTimeout(function () {
jQuery('.sortable-list').each(function () {
if (jQuery(this).children('.sortable-item').length === 0 &&
!jQuery(this).is('#yourToDoContainer > .sortable-list')) {
// Don't remove the root list
if (jQuery(this).parent().hasClass('sortable-item')) {
var ticketId = jQuery(this).parent().data("id");
jQuery(this).parent().find(".accordion-toggle").remove();
//jQuery(this).remove();
}
}
});
}, 300);
},
beforeStop: function (event, ui) {
dragInProgress = false;
if (dragState.calendarDrag) {
return;
}
// Get the current container the item is being dropped into
const currentContainer = ui.item.parent();
if (!currentContainer.hasClass('sortable-list')) {
dragState.hasErrors = true;
//console.warn("Current container is not a sortable-list:", currentContainer);
return;
}
const containerType = getContainerType(currentContainer);
let itemType = dragState.itemType;
if (!isNestingAllowed(ui.item, dragState.targetContainer, true)) {
// Cancel the move if not allowed
//console..warn("Root nesting not allowed");
if(ui.item !== ui.item.data('startParent')) {
ui.item.appendTo(ui.item.data('startParent'));
}
dragState.hasErrors = true;
let message = "";
switch(dragState.nestingErrorType) {
case 'project':
message = "Can't nest elements from 2 different projects";
break;
case 'types':
message = "Can't nest these elements underneach each other";
break;
case 'circular':
message = "Can't nest element undneath itself";
break;
default:
message = "Nesting not allowed here";
}
jQuery.growl({message: message, style: "error"});
return;
}
},
change: function (event, ui) {
//console..log("doing the change");
// Update placeholder class based on current indent level
ui.placeholder.removeClass('indent-0 indent-1 indent-2 indent-3');
ui.placeholder.addClass('indent-' + dragState.currentIndent);
}
});
}
}( jQuery ));

View File

@@ -0,0 +1,119 @@
leantime.snippets = (function () {
var copyUrl = function (field) {
// Get the text field
var copyText = document.getElementById(field);
// Select the text field
copyText.select();
copyText.setSelectionRange(0, 99999); // For mobile devices
// Copy the text inside the text field
navigator.clipboard.writeText(copyText.value);
// Alert the copied text
jQuery.growl({message: leantime.i18n.__("short_notifications.url_copied"), style: "success"});
};
var copyToClipboard = function (content) {
navigator.clipboard.writeText(content);
// Alert the copied text
jQuery.growl({message: leantime.i18n.__("short_notifications.url_copied"), style: "success"});
};
var initConfettiClick = function() {
jQuery(".confetti").click(function(){
confetti.start();
});
};
var accordionToggle = function (id) {
var currentLink = jQuery("#accordion_toggle_"+id).find("i.fa").first();
var submenuName = 'accordion_content-'+id;
var submenuState = "closed";
if(currentLink.hasClass("fa-angle-right")){
currentLink.removeClass("fa-angle-right");
currentLink.addClass("fa-angle-down");
jQuery('#accordion_content-'+id).slideDown("fast");
submenuState = "open";
}else{
currentLink.removeClass("fa-angle-down");
currentLink.addClass("fa-angle-right");
jQuery('#accordion_content-'+id).slideUp("fast");
submenuState = "closed";
}
leantime.rpc('Api.Api.setSubmenuState', {
submenu : submenuName,
state : submenuState
}).catch(function (e) { console.error('Could not persist accordion state', e); });
};
var toggleTheme = function (theme) {
var themeUrl = jQuery("#themeStyleSheet").attr("href");
if(theme == "light"){
themeUrl = themeUrl.replace("dark.css", "light.css");
jQuery("#themeStyleSheet").attr("href", themeUrl);
}else if (theme == "dark"){
themeUrl = themeUrl.replace("light.css", "dark.css");
jQuery("#themeStyleSheet").attr("href", themeUrl);
}
};
var toggleBg = function (theme) {
var themeUrl = jQuery("#themeStyleSheet").attr("href");
if(theme == "minimal"){
themeUrl = themeUrl.replace("default", "minimal");
jQuery("#themeStyleSheet").attr("href", themeUrl);
}else if (theme == "default"){
themeUrl = themeUrl.replace("minimal", "default");
jQuery("#themeStyleSheet").attr("href", themeUrl);
}
};
var toggleFont = function (font) {
jQuery("#fontStyleSetter").html(":root { --primary-font-family: '"+font+"', 'Helvetica Neue', Helvetica, sans-serif; }")
};
var toggleColors = function (accent1, accent2) {
jQuery("#colorSchemeSetter").html(":root { --accent1: "+accent1+"; --accent2: "+accent2+"}")
};
// Make public what you want to have public, everything else is private
return {
copyUrl:copyUrl,
copyToClipboard:copyToClipboard,
initConfettiClick:initConfettiClick,
accordionToggle:accordionToggle,
toggleTheme:toggleTheme,
toggleFont:toggleFont,
toggleColors:toggleColors,
toggleBg:toggleBg
};
})();

View File

@@ -0,0 +1,448 @@
/**
* Column Layouts Extension for Tiptap
*
* Enables creating multi-column layouts (2, 3, or 4 columns)
* with optional asymmetric layout variants (sidebar-left, sidebar-right, sidebar-both).
* Custom implementation for responsive grid-based layouts.
*
* @module tiptap/extensions/columns
*/
const { Node, mergeAttributes } = require('@tiptap/core');
/**
* Shared helper: delete a columnLayout node at a known position,
* extracting its content back into regular paragraphs.
*/
function deleteColumnLayoutAtPos(props, layoutNode, layoutPos) {
var content = [];
layoutNode.forEach(function(column) {
column.forEach(function(child) {
content.push(child.toJSON());
});
});
return props.chain()
.command(function(cmdProps) {
var nodes = content.map(function(c) {
return props.editor.schema.nodeFromJSON(c);
});
if (nodes.length === 0) {
nodes = [props.editor.schema.nodes.paragraph.create()];
}
cmdProps.tr.replaceWith(layoutPos, layoutPos + layoutNode.nodeSize, nodes);
return true;
})
.run();
}
/**
* Column Layout Node - Container for columns
*/
var ColumnLayout = Node.create({
name: 'columnLayout',
group: 'block',
content: 'column+',
defining: true,
isolating: true,
addAttributes: function() {
return {
columns: {
default: 2,
parseHTML: function(element) {
return parseInt(element.getAttribute('data-columns'), 10) || 2;
},
renderHTML: function(attributes) {
return { 'data-columns': attributes.columns };
},
},
layout: {
default: 'equal',
parseHTML: function(element) {
return element.getAttribute('data-layout') || 'equal';
},
renderHTML: function(attributes) {
return { 'data-layout': attributes.layout };
},
},
};
},
parseHTML: function() {
return [
{ tag: 'div[data-column-layout]' },
{ tag: 'div.tiptap-columns' },
];
},
renderHTML: function(props) {
var cols = props.node.attrs.columns || 2;
var layout = props.node.attrs.layout || 'equal';
var classes = 'tiptap-columns tiptap-columns--' + cols;
if (layout !== 'equal') {
classes += ' tiptap-columns--' + layout;
}
return [
'div',
mergeAttributes({
class: classes,
'data-column-layout': '',
'data-columns': cols,
'data-layout': layout,
}, props.HTMLAttributes),
0,
];
},
addCommands: function() {
var self = this;
return {
setColumns: function(columns) {
columns = columns || 2;
return function(props) {
var content = [];
for (var i = 0; i < columns; i++) {
content.push({
type: 'column',
content: [{ type: 'paragraph' }],
});
}
return props.commands.insertContent({
type: self.name,
attrs: { columns: columns, layout: 'equal' },
content: content,
});
};
},
setColumnLayout: function(columns, layout) {
columns = columns || 2;
layout = layout || 'equal';
return function(props) {
var content = [];
for (var i = 0; i < columns; i++) {
content.push({
type: 'column',
content: [{ type: 'paragraph' }],
});
}
return props.commands.insertContent({
type: self.name,
attrs: { columns: columns, layout: layout },
content: content,
});
};
},
updateColumnCount: function(columns) {
return function(props) {
var state = props.state;
var selection = state.selection;
var layoutNode = null;
var layoutPos = null;
// Find the columnLayout node that contains the selection
state.doc.nodesBetween(selection.from, selection.to, function(node, pos) {
if (node.type.name === 'columnLayout') {
layoutNode = node;
layoutPos = pos;
return false;
}
});
if (layoutNode && layoutPos !== null) {
var currentColumns = layoutNode.childCount;
var newAttrs = { ...layoutNode.attrs, columns: columns };
if (columns > currentColumns) {
// Add more columns
var newContent = [];
layoutNode.forEach(function(child) {
newContent.push(child.toJSON());
});
for (var i = currentColumns; i < columns; i++) {
newContent.push({
type: 'column',
content: [{ type: 'paragraph' }],
});
}
return props.chain()
.command(function(cmdProps) {
cmdProps.tr.replaceWith(
layoutPos,
layoutPos + layoutNode.nodeSize,
props.editor.schema.nodeFromJSON({
type: 'columnLayout',
attrs: newAttrs,
content: newContent,
})
);
return true;
})
.run();
} else if (columns < currentColumns) {
// Remove columns (keep content from removed columns in last column)
var keptContent = [];
var mergedContent = [];
var idx = 0;
layoutNode.forEach(function(child) {
if (idx < columns - 1) {
keptContent.push(child.toJSON());
} else if (idx === columns - 1) {
// Last column - merge content from remaining columns
var lastColumnContent = [];
child.forEach(function(c) {
lastColumnContent.push(c.toJSON());
});
mergedContent = lastColumnContent;
keptContent.push({
type: 'column',
content: mergedContent,
});
} else {
// Merge content from removed columns
child.forEach(function(c) {
mergedContent.push(c.toJSON());
});
keptContent[columns - 1].content = mergedContent;
}
idx++;
});
return props.chain()
.command(function(cmdProps) {
cmdProps.tr.replaceWith(
layoutPos,
layoutPos + layoutNode.nodeSize,
props.editor.schema.nodeFromJSON({
type: 'columnLayout',
attrs: newAttrs,
content: keptContent,
})
);
return true;
})
.run();
} else {
// Same count, just update attrs
return props.chain()
.command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(layoutPos, undefined, newAttrs);
return true;
})
.run();
}
}
return false;
};
},
/**
* Delete column layout by walking up from current selection.
* Extracts content from all columns back into regular paragraphs.
*/
deleteColumnLayout: function() {
return function(props) {
var state = props.state;
var $pos = state.selection.$from;
var layoutNode = null;
var layoutPos = null;
// Walk up the resolved position to find columnLayout
for (var d = $pos.depth; d >= 0; d--) {
var node = $pos.node(d);
if (node.type.name === 'columnLayout') {
layoutNode = node;
layoutPos = $pos.before(d);
break;
}
}
if (layoutNode && layoutPos !== null) {
return deleteColumnLayoutAtPos(props, layoutNode, layoutPos);
}
return false;
};
},
/**
* Delete column layout at a known document position (used by NodeView button).
*/
deleteColumnLayoutAt: function(pos) {
return function(props) {
var node = props.state.doc.nodeAt(pos);
if (node && node.type.name === 'columnLayout') {
return deleteColumnLayoutAtPos(props, node, pos);
}
return false;
};
},
};
},
addNodeView: function() {
return function(viewProps) {
var node = viewProps.node;
var editor = viewProps.editor;
var getPos = viewProps.getPos;
var cols = node.attrs.columns || 2;
var layout = node.attrs.layout || 'equal';
// Wrapper with position:relative for toolbar positioning
var dom = document.createElement('div');
dom.className = 'tiptap-columns-wrapper';
// Floating toolbar (hidden until hover/focus)
var toolbar = document.createElement('div');
toolbar.className = 'tiptap-columns-toolbar';
toolbar.contentEditable = 'false';
var removeBtn = document.createElement('button');
removeBtn.className = 'tiptap-columns-toolbar__btn tiptap-columns-toolbar__remove';
removeBtn.type = 'button';
removeBtn.title = 'Remove columns';
removeBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
removeBtn.addEventListener('mousedown', function(e) {
e.preventDefault();
e.stopPropagation();
var pos = getPos();
if (pos != null) {
editor.commands.deleteColumnLayoutAt(pos);
}
});
toolbar.appendChild(removeBtn);
dom.appendChild(toolbar);
// Content area — the actual CSS grid container
var contentDOM = document.createElement('div');
var classes = 'tiptap-columns tiptap-columns--' + cols;
if (layout !== 'equal') {
classes += ' tiptap-columns--' + layout;
}
contentDOM.className = classes;
contentDOM.setAttribute('data-column-layout', '');
contentDOM.setAttribute('data-columns', cols);
contentDOM.setAttribute('data-layout', layout);
dom.appendChild(contentDOM);
return {
dom: dom,
contentDOM: contentDOM,
update: function(updatedNode) {
if (updatedNode.type.name !== 'columnLayout') return false;
var newCols = updatedNode.attrs.columns || 2;
var newLayout = updatedNode.attrs.layout || 'equal';
var newClasses = 'tiptap-columns tiptap-columns--' + newCols;
if (newLayout !== 'equal') {
newClasses += ' tiptap-columns--' + newLayout;
}
contentDOM.className = newClasses;
contentDOM.setAttribute('data-columns', newCols);
contentDOM.setAttribute('data-layout', newLayout);
return true;
},
};
};
},
addKeyboardShortcuts: function() {
// Arrow functions so `this` resolves to the extension context. Tiptap
// invokes shortcut handlers bare (`() => method({ editor })`), so a
// regular `function () {}` here would run with `this === undefined` and
// throw on `this.editor`, aborting the keydown before the core keymap
// (joinBackward etc.) could run.
return {
'Mod-Alt-2': () => {
return this.editor.commands.setColumns(2);
},
'Mod-Alt-3': () => {
return this.editor.commands.setColumns(3);
},
'Backspace': () => {
var editor = this.editor;
var state = editor.state;
var selection = state.selection;
// Only when cursor is collapsed (no selection range)
if (!selection.empty) return false;
// Walk up the node tree to find a columnLayout ancestor
var $pos = selection.$from;
for (var d = $pos.depth; d >= 0; d--) {
var node = $pos.node(d);
if (node.type.name === 'columnLayout') {
// Check if every column is empty (single empty paragraph)
var allEmpty = true;
node.forEach(function(column) {
if (column.childCount > 1 ||
(column.childCount === 1 && column.firstChild && column.firstChild.textContent !== '')) {
allEmpty = false;
}
});
if (allEmpty) {
return editor.commands.deleteColumnLayout();
}
return false;
}
}
return false;
},
};
},
});
/**
* Column Node - Individual column within a layout
*/
var Column = Node.create({
name: 'column',
group: 'column',
content: 'block+',
defining: true,
isolating: true,
parseHTML: function() {
return [
{ tag: 'div[data-column]' },
{ tag: 'div.tiptap-column' },
];
},
renderHTML: function(props) {
return [
'div',
mergeAttributes({
class: 'tiptap-column',
'data-column': '',
}, props.HTMLAttributes),
0,
];
},
addNodeView: function() {
return function(props) {
var dom = document.createElement('div');
dom.className = 'tiptap-column';
dom.setAttribute('data-column', '');
var contentDOM = document.createElement('div');
contentDOM.className = 'tiptap-column__content';
dom.appendChild(contentDOM);
return {
dom: dom,
contentDOM: contentDOM,
};
};
},
});
/**
* Create the Columns extension bundle
*/
function createColumnsExtension() {
return [ColumnLayout, Column];
}
module.exports = {
createColumnsExtension: createColumnsExtension,
ColumnLayout: ColumnLayout,
Column: Column,
};

View File

@@ -0,0 +1,356 @@
/**
* Details/Collapsible Extension for Tiptap
*
* Enables creating collapsible sections using HTML5 details/summary elements.
* Custom implementation compatible with Tiptap v2.
*
* @module tiptap/extensions/details
*/
const { Node, mergeAttributes } = require('@tiptap/core');
/**
* Details Node - The container for collapsible content
*/
var Details = Node.create({
name: 'details',
group: 'block',
content: 'detailsSummary detailsContent',
defining: true,
addAttributes: function() {
return {
open: {
default: true,
parseHTML: function(element) {
return element.hasAttribute('open');
},
renderHTML: function(attributes) {
if (attributes.open) {
return { open: 'open' };
}
return {};
},
},
};
},
parseHTML: function() {
return [
{ tag: 'details' },
];
},
renderHTML: function(props) {
return [
'details',
mergeAttributes({ class: 'tiptap-details' }, props.HTMLAttributes),
0,
];
},
addNodeView: function() {
return function(props) {
var node = props.node;
var dom = document.createElement('details');
dom.className = 'tiptap-details';
// Set initial open state
if (node.attrs.open) {
dom.setAttribute('open', 'open');
}
var contentDOM = dom;
return {
dom: dom,
contentDOM: contentDOM,
update: function(updatedNode) {
if (updatedNode.type.name !== 'details') {
return false;
}
// Update open state
if (updatedNode.attrs.open) {
dom.setAttribute('open', 'open');
} else {
dom.removeAttribute('open');
}
return true;
},
};
};
},
addCommands: function() {
var self = this;
return {
setDetails: function() {
return function(props) {
return props.commands.insertContent({
type: self.name,
attrs: { open: true },
content: [
{
type: 'detailsSummary',
content: [
{
type: 'text',
text: 'Click to expand',
},
],
},
{
type: 'detailsContent',
content: [
{
type: 'paragraph',
},
],
},
],
});
};
},
toggleDetails: function() {
return function(props) {
var state = props.state;
var selection = state.selection;
var detailsNode = null;
var detailsPos = null;
// Find the details node that contains the selection
state.doc.nodesBetween(selection.from, selection.to, function(node, pos) {
if (node.type.name === 'details') {
detailsNode = node;
detailsPos = pos;
return false;
}
});
if (detailsNode && detailsPos !== null) {
return props.chain().command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(detailsPos, undefined, {
...detailsNode.attrs,
open: !detailsNode.attrs.open,
});
return true;
}).run();
}
return false;
};
},
unsetDetails: function() {
return function(props) {
return props.commands.lift('details');
};
},
};
},
addKeyboardShortcuts: function() {
return {
'Mod-Alt-d': function() {
return this.editor.commands.setDetails();
},
// Allow Mod-Enter to exit details and create paragraph after
'Mod-Enter': function() {
var editor = this.editor;
var state = editor.state;
var selection = state.selection;
// Check if we're inside a details node
var detailsNode = null;
var detailsPos = null;
state.doc.nodesBetween(selection.from, selection.to, function(node, pos) {
if (node.type.name === 'details') {
detailsNode = node;
detailsPos = pos;
}
});
if (detailsNode && detailsPos !== null) {
// Insert paragraph after details
var endPos = detailsPos + detailsNode.nodeSize;
return editor.chain()
.insertContentAt(endPos, { type: 'paragraph' })
.focus(endPos + 1)
.run();
}
return false;
},
};
},
});
/**
* Details Summary Node - The clickable header
*/
var DetailsSummary = Node.create({
name: 'detailsSummary',
group: 'detailsSummary',
content: 'inline*',
defining: true,
parseHTML: function() {
return [
{ tag: 'summary' },
];
},
renderHTML: function(props) {
return [
'summary',
mergeAttributes({ class: 'tiptap-details__summary' }, props.HTMLAttributes),
0,
];
},
addNodeView: function() {
return function(props) {
var editor = props.editor;
var getPos = props.getPos;
var dom = document.createElement('summary');
dom.className = 'tiptap-details__summary';
// Create arrow icon that triggers toggle
var arrow = document.createElement('span');
arrow.className = 'tiptap-details__arrow';
dom.appendChild(arrow);
var contentDOM = document.createElement('span');
contentDOM.className = 'tiptap-details__summary-content';
dom.appendChild(contentDOM);
// Create delete button
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'tiptap-details__delete-btn';
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
deleteBtn.title = 'Delete collapsible section';
dom.appendChild(deleteBtn);
// Handle click on the arrow to toggle
arrow.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
// Find the details node and toggle it via the editor
if (typeof getPos === 'function') {
var pos = getPos();
// Find parent details node
var resolved = editor.state.doc.resolve(pos);
for (var depth = resolved.depth; depth >= 0; depth--) {
var node = resolved.node(depth);
if (node.type.name === 'details') {
var detailsPos = resolved.before(depth);
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(detailsPos, undefined, {
...node.attrs,
open: !node.attrs.open,
});
return true;
}).run();
break;
}
}
}
});
// Handle delete button click
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
var pos = getPos();
// Find parent details node and delete it
var resolved = editor.state.doc.resolve(pos);
for (var depth = resolved.depth; depth >= 0; depth--) {
var node = resolved.node(depth);
if (node.type.name === 'details') {
var detailsPos = resolved.before(depth);
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.delete(detailsPos, detailsPos + node.nodeSize);
return true;
}).run();
break;
}
}
}
});
// Also handle clicking the summary background (not the text)
dom.addEventListener('click', function(e) {
// If click was on the dom itself (not arrow, not contentDOM text, not delete button)
if (e.target === dom) {
e.preventDefault();
arrow.click(); // Trigger the arrow click
}
});
return {
dom: dom,
contentDOM: contentDOM,
stopEvent: function(event) {
return event.target === deleteBtn || deleteBtn.contains(event.target);
},
};
};
},
});
/**
* Details Content Node - The collapsible content area
*/
var DetailsContent = Node.create({
name: 'detailsContent',
group: 'detailsContent',
content: 'block+',
parseHTML: function() {
return [
{ tag: 'div.tiptap-details__content' },
// Fallback for content after summary that isn't wrapped
{
tag: 'details > *:not(summary)',
getAttrs: function(dom) {
// Only match direct children of details that aren't summary
if (dom.parentElement && dom.parentElement.tagName === 'DETAILS') {
return {};
}
return false;
},
},
];
},
renderHTML: function(props) {
return [
'div',
mergeAttributes({ class: 'tiptap-details__content' }, props.HTMLAttributes),
0,
];
},
});
/**
* Create the Details extension bundle
* Returns an array of all three nodes needed
*/
function createDetailsExtension() {
return [Details, DetailsSummary, DetailsContent];
}
module.exports = {
createDetailsExtension: createDetailsExtension,
Details: Details,
DetailsSummary: DetailsSummary,
DetailsContent: DetailsContent,
};

View File

@@ -0,0 +1,584 @@
/**
* Tiptap Embed Extension for Leantime
*
* Provides embedding functionality for various services:
* - YouTube, Vimeo (video)
* - Google Docs, Sheets, Slides
* - Microsoft Office (OneDrive)
* - Figma
* - Loom
* - Miro
* - Airtable
* - Typeform
* - Calendly
*/
const { Node, mergeAttributes } = require('@tiptap/core');
/**
* URL patterns for various services
*/
var patterns = {
// Video
youtube: /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:\S*)?$/,
vimeo: /^(?:https?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)(?:\S*)?$/,
loom: /^(?:https?:\/\/)?(?:www\.)?loom\.com\/share\/([a-zA-Z0-9]+)(?:\S*)?$/,
// Google
googleDocs: /^(?:https?:\/\/)?docs\.google\.com\/document\/d\/([a-zA-Z0-9_-]+)(?:\/\S*)?$/,
googleSheets: /^(?:https?:\/\/)?docs\.google\.com\/spreadsheets\/d\/([a-zA-Z0-9_-]+)(?:\/\S*)?$/,
googleSlides: /^(?:https?:\/\/)?docs\.google\.com\/presentation\/d\/([a-zA-Z0-9_-]+)(?:\/\S*)?$/,
googleForms: /^(?:https?:\/\/)?docs\.google\.com\/forms\/d\/(?:e\/)?([a-zA-Z0-9_-]+)(?:\/\S*)?$/,
// Microsoft
oneDrive: /^(?:https?:\/\/)?(?:1drv\.ms|onedrive\.live\.com|.*\.sharepoint\.com)\/\S+$/,
office365: /^(?:https?:\/\/)?(?:.*\.sharepoint\.com|.*\.officeapps\.live\.com)\/\S+$/,
// Design & Collaboration
figma: /^(?:https?:\/\/)?(?:www\.)?figma\.com\/(file|proto|design)\/([a-zA-Z0-9]+)(?:\/\S*)?$/,
miro: /^(?:https?:\/\/)?(?:www\.)?miro\.com\/app\/board\/([a-zA-Z0-9_=-]+)(?:\/\S*)?$/,
// Other
airtable: /^(?:https?:\/\/)?airtable\.com\/(?:embed\/|shr)?([a-zA-Z0-9]+)(?:\/\S*)?$/,
typeform: /^(?:https?:\/\/)?(?:www\.)?(?:[a-zA-Z0-9-]+\.)?typeform\.com\/to\/([a-zA-Z0-9]+)(?:\S*)?$/,
calendly: /^(?:https?:\/\/)?calendly\.com\/([a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)?)(?:\S*)?$/,
// Code & Dev
codepen: /^(?:https?:\/\/)?codepen\.io\/([a-zA-Z0-9_-]+)\/(?:pen|embed)\/([a-zA-Z0-9]+)(?:\S*)?$/,
codesandbox: /^(?:https?:\/\/)?codesandbox\.io\/(?:s|embed)\/([a-zA-Z0-9_-]+)(?:\S*)?$/,
};
/**
* Detect embed type from URL
*/
function detectEmbedType(url) {
if (!url) return null;
for (var type in patterns) {
if (patterns[type].test(url)) {
return type;
}
}
return null;
}
/**
* Extract ID from URL based on type
*/
function extractId(url, type) {
if (!url || !type || !patterns[type]) return null;
var match = url.match(patterns[type]);
return match ? match[1] : null;
}
/**
* Generate embed URL for each service
* Uses editable/interactive versions where possible
*/
function getEmbedUrl(url, type, id) {
switch (type) {
case 'youtube':
return 'https://www.youtube.com/embed/' + id;
case 'vimeo':
return 'https://player.vimeo.com/video/' + id;
case 'loom':
return 'https://www.loom.com/embed/' + id;
case 'googleDocs':
// Use edit mode - allows editing if user has permission
return 'https://docs.google.com/document/d/' + id + '/edit?embedded=true';
case 'googleSheets':
// Use edit mode - allows editing if user has permission
return 'https://docs.google.com/spreadsheets/d/' + id + '/edit?embedded=true&rm=minimal';
case 'googleSlides':
// Use edit mode for slides
return 'https://docs.google.com/presentation/d/' + id + '/edit?embedded=true&rm=minimal';
case 'googleForms':
return 'https://docs.google.com/forms/d/e/' + id + '/viewform?embedded=true';
case 'figma':
// Figma requires the full URL for embedding
return 'https://www.figma.com/embed?embed_host=leantime&url=' + encodeURIComponent(url);
case 'miro':
// Miro live embed - allows interaction if board permissions allow
return 'https://miro.com/app/live-embed/' + id + '/?moveToViewport=-1000,-1000,2000,2000&embedAutoplay=false';
case 'airtable':
return 'https://airtable.com/embed/' + id + '?backgroundColor=transparent';
case 'typeform':
return 'https://form.typeform.com/to/' + id;
case 'calendly':
return 'https://calendly.com/' + id + '?embed_type=Inline';
case 'codepen':
var match = url.match(patterns.codepen);
if (match) {
// Editable codepen embed
return 'https://codepen.io/' + match[1] + '/embed/' + match[2] + '?default-tab=result&editable=true';
}
return null;
case 'codesandbox':
return 'https://codesandbox.io/embed/' + id + '?fontsize=14&theme=light&codemirror=1';
case 'oneDrive':
case 'office365':
// For Office docs, use action=edit for editable embeds
if (url.includes('sharepoint.com')) {
return url.replace(/\?.*$/, '') + '?action=edit&embedded=true';
}
// For OneDrive personal links
if (url.includes('1drv.ms') || url.includes('onedrive.live.com')) {
return url.replace(/\?.*$/, '') + '?action=edit&embedded=true';
}
return url;
default:
// Unknown embed type - return null to reject
return null;
}
}
/**
* Get display name for embed type
*/
function getTypeName(type) {
var names = {
youtube: 'YouTube',
vimeo: 'Vimeo',
loom: 'Loom',
googleDocs: 'Google Docs',
googleSheets: 'Google Sheets',
googleSlides: 'Google Slides',
googleForms: 'Google Forms',
figma: 'Figma',
miro: 'Miro',
airtable: 'Airtable',
typeform: 'Typeform',
calendly: 'Calendly',
codepen: 'CodePen',
codesandbox: 'CodeSandbox',
oneDrive: 'OneDrive',
office365: 'Office 365',
};
return names[type] || type;
}
/**
* Get icon class for embed type
*/
function getTypeIcon(type) {
var icons = {
youtube: 'fa-youtube',
vimeo: 'fa-vimeo',
loom: 'fa-video',
googleDocs: 'fa-file-alt',
googleSheets: 'fa-table',
googleSlides: 'fa-desktop',
googleForms: 'fa-list-alt',
figma: 'fa-pen-nib',
miro: 'fa-object-group',
airtable: 'fa-database',
typeform: 'fa-wpforms',
calendly: 'fa-calendar',
codepen: 'fa-codepen',
codesandbox: 'fa-cube',
oneDrive: 'fa-cloud',
office365: 'fa-microsoft',
};
return icons[type] || 'fa-link';
}
/**
* Get aspect ratio class for embed type
*/
function getAspectRatio(type) {
switch (type) {
case 'youtube':
case 'vimeo':
case 'loom':
return 'video'; // 16:9
case 'googleSlides':
return 'presentation'; // 16:9
case 'figma':
case 'miro':
return 'design'; // 4:3 or flexible
case 'googleDocs':
case 'googleSheets':
case 'airtable':
return 'document'; // taller
case 'typeform':
case 'googleForms':
case 'calendly':
return 'form'; // flexible height
default:
return 'default';
}
}
/**
* Create the Embed node extension
*/
var EmbedNode = Node.create({
name: 'embed',
group: 'block',
atom: true,
addAttributes: function() {
return {
src: { default: null },
type: { default: 'youtube' },
embedId: { default: null },
originalUrl: { default: null },
title: { default: null },
};
},
parseHTML: function() {
return [
{
tag: 'div[data-embed]',
getAttrs: function(dom) {
return {
src: dom.getAttribute('data-src'),
type: dom.getAttribute('data-type'),
embedId: dom.getAttribute('data-embed-id'),
originalUrl: dom.getAttribute('data-original-url'),
title: dom.getAttribute('data-title'),
};
},
},
// Legacy support for direct iframes
{
tag: 'iframe[src*="youtube.com"]',
getAttrs: function(dom) {
var src = dom.getAttribute('src');
var videoId = src.match(/embed\/([a-zA-Z0-9_-]{11})/);
return {
src: src,
type: 'youtube',
embedId: videoId ? videoId[1] : null,
title: dom.getAttribute('title'),
};
},
},
{
tag: 'iframe[src*="vimeo.com"]',
getAttrs: function(dom) {
var src = dom.getAttribute('src');
var videoId = src.match(/video\/(\d+)/);
return {
src: src,
type: 'vimeo',
embedId: videoId ? videoId[1] : null,
title: dom.getAttribute('title'),
};
},
},
{
tag: 'iframe[src*="docs.google.com"]',
getAttrs: function(dom) {
var src = dom.getAttribute('src');
var type = 'googleDocs';
if (src.includes('spreadsheets')) type = 'googleSheets';
if (src.includes('presentation')) type = 'googleSlides';
if (src.includes('forms')) type = 'googleForms';
return {
src: src,
type: type,
title: dom.getAttribute('title'),
};
},
},
{
tag: 'iframe[src*="figma.com"]',
getAttrs: function(dom) {
return {
src: dom.getAttribute('src'),
type: 'figma',
title: dom.getAttribute('title'),
};
},
},
];
},
renderHTML: function(props) {
var attrs = props.HTMLAttributes;
var type = attrs.type || 'youtube';
var embedSrc = attrs.src;
var aspectRatio = getAspectRatio(type);
// Trusted embeds are services that require same-origin cookie access or
// postMessage with origin validation to authenticate and render correctly.
// Without allow-same-origin their internal scripts receive a null origin,
// auth cookies are inaccessible, and the embed fails with a 400 error.
//
// NOTE: allow-scripts + allow-same-origin together allow sandboxed content
// to remove its own sandbox via script — this is acceptable for these
// known first-party services but should NOT be added for arbitrary URLs.
var trustedEmbeds = {
googleDocs: true,
googleSheets: true,
googleSlides: true,
googleForms: true,
figma: true,
miro: true,
oneDrive: true,
office365: true,
};
var sandboxValue = trustedEmbeds[type]
? 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-storage-access-by-user-activation allow-downloads allow-modals'
: 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-downloads allow-modals';
return ['div', mergeAttributes({
class: 'tiptap-embed tiptap-embed--' + type + ' tiptap-embed--' + aspectRatio,
'data-embed': '',
'data-type': type,
'data-embed-id': attrs.embedId || '',
'data-src': embedSrc,
'data-original-url': attrs.originalUrl || '',
'data-title': attrs.title || '',
}), [
'div', { class: 'tiptap-embed__wrapper' }, [
'iframe', {
src: embedSrc,
frameborder: '0',
allowfullscreen: 'true',
// Allow all permissions needed for editable embeds
allow: 'accelerometer; autoplay; clipboard-read; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen; camera; microphone',
sandbox: sandboxValue,
title: attrs.title || getTypeName(type) + ' embed',
loading: 'lazy',
}
]
]];
},
addCommands: function() {
var self = this;
return {
setEmbed: function(options) {
return function(props) {
var commands = props.commands;
var url = options.url || options.src;
var type = options.type || detectEmbedType(url);
if (!type) {
console.warn('[Embed] Unsupported URL:', url);
return false;
}
var embedId = extractId(url, type);
var embedSrc = getEmbedUrl(url, type, embedId);
if (!embedSrc) {
console.warn('[Embed] Could not generate embed URL for:', url);
return false;
}
return commands.insertContent({
type: self.name,
attrs: {
src: embedSrc,
type: type,
embedId: embedId,
originalUrl: url,
title: options.title || getTypeName(type),
},
});
};
},
// Legacy commands for backwards compatibility
setYouTubeVideo: function(options) {
return function(props) {
var commands = props.commands;
var videoId = options.videoId || extractId(options.src, 'youtube');
if (!videoId) return false;
return commands.insertContent({
type: self.name,
attrs: {
type: 'youtube',
embedId: videoId,
src: 'https://www.youtube.com/embed/' + videoId,
originalUrl: options.src,
title: options.title || 'YouTube video',
},
});
};
},
setVimeoVideo: function(options) {
return function(props) {
var commands = props.commands;
var videoId = options.videoId || extractId(options.src, 'vimeo');
if (!videoId) return false;
return commands.insertContent({
type: self.name,
attrs: {
type: 'vimeo',
embedId: videoId,
src: 'https://player.vimeo.com/video/' + videoId,
originalUrl: options.src,
title: options.title || 'Vimeo video',
},
});
};
},
};
},
});
/**
* Show embed dialog
*/
function showEmbedDialog(editor) {
// Close existing dialog
var existing = document.querySelector('.tiptap-embed-dialog');
if (existing) {
existing.remove();
}
var supportedServices = [
{ name: 'YouTube', icon: 'fa-youtube', example: 'youtube.com/watch?v=...' },
{ name: 'Vimeo', icon: 'fa-vimeo', example: 'vimeo.com/...' },
{ name: 'Loom', icon: 'fa-video', example: 'loom.com/share/...' },
{ name: 'Google Docs', icon: 'fa-file-alt', example: 'docs.google.com/document/...' },
{ name: 'Google Sheets', icon: 'fa-table', example: 'docs.google.com/spreadsheets/...' },
{ name: 'Google Slides', icon: 'fa-desktop', example: 'docs.google.com/presentation/...' },
{ name: 'Figma', icon: 'fa-pen-nib', example: 'figma.com/file/...' },
{ name: 'Miro', icon: 'fa-object-group', example: 'miro.com/app/board/...' },
{ name: 'Airtable', icon: 'fa-database', example: 'airtable.com/...' },
{ name: 'Calendly', icon: 'fa-calendar', example: 'calendly.com/...' },
];
var servicesHtml = supportedServices.map(function(s) {
return '<div class="tiptap-embed-dialog__service">' +
'<i class="fa ' + s.icon + '"></i>' +
'<span>' + s.name + '</span>' +
'</div>';
}).join('');
var dialog = document.createElement('div');
dialog.className = 'tiptap-embed-dialog';
dialog.innerHTML =
'<div class="tiptap-embed-dialog__overlay"></div>' +
'<div class="tiptap-embed-dialog__content">' +
'<div class="tiptap-embed-dialog__header">' +
'<h3>Embed Content</h3>' +
'<button type="button" class="tiptap-embed-dialog__close" aria-label="Close">&times;</button>' +
'</div>' +
'<div class="tiptap-embed-dialog__body">' +
'<div class="tiptap-embed-dialog__field">' +
'<label>Paste URL</label>' +
'<input type="text" class="tiptap-embed-dialog__input" placeholder="Paste a link to embed..." />' +
'<div class="tiptap-embed-dialog__preview" style="display:none;"></div>' +
'</div>' +
'<div class="tiptap-embed-dialog__services">' +
'<div class="tiptap-embed-dialog__services-label">Supported services:</div>' +
'<div class="tiptap-embed-dialog__services-grid">' + servicesHtml + '</div>' +
'</div>' +
'</div>' +
'<div class="tiptap-embed-dialog__footer">' +
'<button type="button" class="tiptap-embed-dialog__btn tiptap-embed-dialog__btn--cancel">Cancel</button>' +
'<button type="button" class="tiptap-embed-dialog__btn tiptap-embed-dialog__btn--primary" disabled>Embed</button>' +
'</div>' +
'</div>';
document.body.appendChild(dialog);
var input = dialog.querySelector('.tiptap-embed-dialog__input');
var preview = dialog.querySelector('.tiptap-embed-dialog__preview');
var embedBtn = dialog.querySelector('.tiptap-embed-dialog__btn--primary');
var detectedType = null;
setTimeout(function() { input.focus(); }, 100);
function closeDialog() {
dialog.remove();
}
function updatePreview() {
var url = input.value.trim();
detectedType = detectEmbedType(url);
if (detectedType) {
var typeName = getTypeName(detectedType);
var icon = getTypeIcon(detectedType);
preview.innerHTML = '<i class="fa ' + icon + '"></i> ' + typeName + ' detected';
preview.style.display = 'block';
preview.className = 'tiptap-embed-dialog__preview tiptap-embed-dialog__preview--success';
embedBtn.disabled = false;
} else if (url.length > 0) {
preview.innerHTML = '<i class="fa fa-exclamation-circle"></i> URL not recognized';
preview.style.display = 'block';
preview.className = 'tiptap-embed-dialog__preview tiptap-embed-dialog__preview--error';
embedBtn.disabled = true;
} else {
preview.style.display = 'none';
embedBtn.disabled = true;
}
}
input.addEventListener('input', updatePreview);
input.addEventListener('paste', function() {
setTimeout(updatePreview, 50);
});
dialog.querySelector('.tiptap-embed-dialog__overlay').addEventListener('click', closeDialog);
dialog.querySelector('.tiptap-embed-dialog__close').addEventListener('click', closeDialog);
dialog.querySelector('.tiptap-embed-dialog__btn--cancel').addEventListener('click', closeDialog);
embedBtn.addEventListener('click', function() {
var url = input.value.trim();
if (!url || !detectedType) return;
editor.chain().focus().setEmbed({ url: url, type: detectedType }).run();
closeDialog();
});
input.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !embedBtn.disabled) {
embedBtn.click();
}
});
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
closeDialog();
document.removeEventListener('keydown', escHandler);
}
});
}
// Make available globally for slash commands
window.leantime = window.leantime || {};
window.leantime.tiptapEmbed = {
showDialog: showEmbedDialog,
detectType: detectEmbedType,
getEmbedUrl: getEmbedUrl,
getTypeName: getTypeName,
patterns: patterns,
};
// Export
module.exports = {
EmbedNode: EmbedNode,
showEmbedDialog: showEmbedDialog,
detectEmbedType: detectEmbedType,
getEmbedUrl: getEmbedUrl,
patterns: patterns,
};

View File

@@ -0,0 +1,825 @@
/**
* Emoji Extension for Tiptap
*
* Enables inserting emojis using :shortcode: syntax (Slack/Discord style).
* Shows a searchable popup when typing : followed by characters.
*
* @module tiptap/extensions/emoji
*/
const { Extension } = require('@tiptap/core');
const { Plugin, PluginKey } = require('@tiptap/pm/state');
const { Decoration, DecorationSet } = require('@tiptap/pm/view');
// Common emojis with shortcodes
var emojiData = [
// Smileys & Emotion
{ shortcode: 'smile', emoji: '\u{1F604}', keywords: ['happy', 'joy', 'grin'] },
{ shortcode: 'grin', emoji: '\u{1F600}', keywords: ['happy', 'smile'] },
{ shortcode: 'joy', emoji: '\u{1F602}', keywords: ['laugh', 'tears', 'happy', 'lol'] },
{ shortcode: 'rofl', emoji: '\u{1F923}', keywords: ['laugh', 'rolling'] },
{ shortcode: 'wink', emoji: '\u{1F609}', keywords: ['flirt', 'playful'] },
{ shortcode: 'blush', emoji: '\u{1F60A}', keywords: ['happy', 'shy', 'smile'] },
{ shortcode: 'innocent', emoji: '\u{1F607}', keywords: ['angel', 'halo'] },
{ shortcode: 'heart_eyes', emoji: '\u{1F60D}', keywords: ['love', 'crush', 'adore'] },
{ shortcode: 'kissing_heart', emoji: '\u{1F618}', keywords: ['love', 'kiss'] },
{ shortcode: 'thinking', emoji: '\u{1F914}', keywords: ['hmm', 'consider', 'wonder'] },
{ shortcode: 'raised_eyebrow', emoji: '\u{1F928}', keywords: ['skeptical', 'disbelief'] },
{ shortcode: 'neutral', emoji: '\u{1F610}', keywords: ['meh', 'indifferent'] },
{ shortcode: 'expressionless', emoji: '\u{1F611}', keywords: ['blank', 'meh'] },
{ shortcode: 'rolling_eyes', emoji: '\u{1F644}', keywords: ['whatever', 'bored'] },
{ shortcode: 'smirk', emoji: '\u{1F60F}', keywords: ['smug', 'sly'] },
{ shortcode: 'persevere', emoji: '\u{1F623}', keywords: ['struggle', 'endure'] },
{ shortcode: 'disappointed', emoji: '\u{1F61E}', keywords: ['sad', 'upset'] },
{ shortcode: 'worried', emoji: '\u{1F61F}', keywords: ['anxious', 'nervous'] },
{ shortcode: 'confused', emoji: '\u{1F615}', keywords: ['puzzled'] },
{ shortcode: 'slight_frown', emoji: '\u{1F641}', keywords: ['sad'] },
{ shortcode: 'frown', emoji: '\u{2639}\u{FE0F}', keywords: ['sad', 'unhappy'] },
{ shortcode: 'open_mouth', emoji: '\u{1F62E}', keywords: ['surprise', 'wow'] },
{ shortcode: 'hushed', emoji: '\u{1F62F}', keywords: ['surprise', 'shock'] },
{ shortcode: 'astonished', emoji: '\u{1F632}', keywords: ['shock', 'surprise'] },
{ shortcode: 'flushed', emoji: '\u{1F633}', keywords: ['embarrassed', 'blush'] },
{ shortcode: 'fearful', emoji: '\u{1F628}', keywords: ['scared', 'afraid'] },
{ shortcode: 'cold_sweat', emoji: '\u{1F630}', keywords: ['nervous', 'anxious'] },
{ shortcode: 'cry', emoji: '\u{1F622}', keywords: ['sad', 'tears'] },
{ shortcode: 'sob', emoji: '\u{1F62D}', keywords: ['cry', 'sad', 'tears'] },
{ shortcode: 'scream', emoji: '\u{1F631}', keywords: ['horror', 'shock'] },
{ shortcode: 'angry', emoji: '\u{1F620}', keywords: ['mad', 'grumpy'] },
{ shortcode: 'rage', emoji: '\u{1F621}', keywords: ['angry', 'mad', 'furious'] },
{ shortcode: 'triumph', emoji: '\u{1F624}', keywords: ['winning', 'proud'] },
{ shortcode: 'sleepy', emoji: '\u{1F62A}', keywords: ['tired', 'drowsy'] },
{ shortcode: 'yawning', emoji: '\u{1F971}', keywords: ['tired', 'bored', 'sleepy'] },
{ shortcode: 'mask', emoji: '\u{1F637}', keywords: ['sick', 'ill'] },
{ shortcode: 'sunglasses', emoji: '\u{1F60E}', keywords: ['cool', 'awesome'] },
{ shortcode: 'nerd', emoji: '\u{1F913}', keywords: ['geek', 'smart'] },
{ shortcode: 'clown', emoji: '\u{1F921}', keywords: ['silly', 'joker'] },
{ shortcode: 'cowboy', emoji: '\u{1F920}', keywords: ['western', 'hat'] },
{ shortcode: 'partying', emoji: '\u{1F973}', keywords: ['party', 'celebrate'] },
{ shortcode: 'shushing', emoji: '\u{1F92B}', keywords: ['quiet', 'secret'] },
{ shortcode: 'zany', emoji: '\u{1F92A}', keywords: ['crazy', 'wild', 'silly'] },
{ shortcode: 'monocle', emoji: '\u{1F9D0}', keywords: ['fancy', 'curious'] },
{ shortcode: 'skull', emoji: '\u{1F480}', keywords: ['dead', 'death', 'skeleton'] },
{ shortcode: 'ghost', emoji: '\u{1F47B}', keywords: ['spooky', 'halloween'] },
{ shortcode: 'alien', emoji: '\u{1F47D}', keywords: ['ufo', 'space'] },
{ shortcode: 'robot', emoji: '\u{1F916}', keywords: ['bot', 'machine'] },
{ shortcode: 'poop', emoji: '\u{1F4A9}', keywords: ['crap', 'poo'] },
// Gestures & People
{ shortcode: 'wave', emoji: '\u{1F44B}', keywords: ['hello', 'hi', 'goodbye', 'bye'] },
{ shortcode: 'ok_hand', emoji: '\u{1F44C}', keywords: ['perfect', 'nice'] },
{ shortcode: 'pinched', emoji: '\u{1F90C}', keywords: ['small', 'tiny'] },
{ shortcode: 'v', emoji: '\u{270C}\u{FE0F}', keywords: ['peace', 'victory', 'two'] },
{ shortcode: 'crossed_fingers', emoji: '\u{1F91E}', keywords: ['luck', 'hope'] },
{ shortcode: 'call_me', emoji: '\u{1F919}', keywords: ['phone', 'shaka'] },
{ shortcode: 'point_left', emoji: '\u{1F448}', keywords: ['left', 'direction'] },
{ shortcode: 'point_right', emoji: '\u{1F449}', keywords: ['right', 'direction'] },
{ shortcode: 'point_up', emoji: '\u{1F446}', keywords: ['up', 'direction'] },
{ shortcode: 'point_down', emoji: '\u{1F447}', keywords: ['down', 'direction'] },
{ shortcode: 'thumbsup', emoji: '\u{1F44D}', keywords: ['yes', 'good', 'like', '+1'] },
{ shortcode: 'thumbsdown', emoji: '\u{1F44E}', keywords: ['no', 'bad', 'dislike', '-1'] },
{ shortcode: 'fist', emoji: '\u{270A}', keywords: ['power', 'punch'] },
{ shortcode: 'fist_bump', emoji: '\u{1F91C}', keywords: ['punch', 'bro'] },
{ shortcode: 'clap', emoji: '\u{1F44F}', keywords: ['applause', 'congrats'] },
{ shortcode: 'raised_hands', emoji: '\u{1F64C}', keywords: ['celebrate', 'praise', 'hooray'] },
{ shortcode: 'pray', emoji: '\u{1F64F}', keywords: ['please', 'hope', 'thanks', 'namaste'] },
{ shortcode: 'handshake', emoji: '\u{1F91D}', keywords: ['deal', 'agreement'] },
{ shortcode: 'muscle', emoji: '\u{1F4AA}', keywords: ['strong', 'flex', 'bicep'] },
{ shortcode: 'eyes', emoji: '\u{1F440}', keywords: ['look', 'see', 'watch'] },
{ shortcode: 'brain', emoji: '\u{1F9E0}', keywords: ['smart', 'think', 'mind'] },
// Hearts & Symbols
{ shortcode: 'heart', emoji: '\u{2764}\u{FE0F}', keywords: ['love', 'red'] },
{ shortcode: 'orange_heart', emoji: '\u{1F9E1}', keywords: ['love'] },
{ shortcode: 'yellow_heart', emoji: '\u{1F49B}', keywords: ['love'] },
{ shortcode: 'green_heart', emoji: '\u{1F49A}', keywords: ['love'] },
{ shortcode: 'blue_heart', emoji: '\u{1F499}', keywords: ['love'] },
{ shortcode: 'purple_heart', emoji: '\u{1F49C}', keywords: ['love'] },
{ shortcode: 'black_heart', emoji: '\u{1F5A4}', keywords: ['love', 'dark'] },
{ shortcode: 'white_heart', emoji: '\u{1F90D}', keywords: ['love', 'pure'] },
{ shortcode: 'broken_heart', emoji: '\u{1F494}', keywords: ['sad', 'heartbreak'] },
{ shortcode: 'sparkling_heart', emoji: '\u{1F496}', keywords: ['love', 'shiny'] },
{ shortcode: 'fire', emoji: '\u{1F525}', keywords: ['hot', 'lit', 'flame'] },
{ shortcode: 'star', emoji: '\u{2B50}', keywords: ['favorite', 'gold'] },
{ shortcode: 'sparkles', emoji: '\u{2728}', keywords: ['shiny', 'magic', 'new'] },
{ shortcode: 'zap', emoji: '\u{26A1}', keywords: ['lightning', 'electric', 'fast'] },
{ shortcode: 'boom', emoji: '\u{1F4A5}', keywords: ['explosion', 'collision'] },
{ shortcode: 'dizzy', emoji: '\u{1F4AB}', keywords: ['stars', 'confused'] },
{ shortcode: '100', emoji: '\u{1F4AF}', keywords: ['perfect', 'score', 'hundred'] },
{ shortcode: 'exclamation', emoji: '\u{2757}', keywords: ['alert', 'important'] },
{ shortcode: 'question', emoji: '\u{2753}', keywords: ['what', 'confused'] },
{ shortcode: 'checkmark', emoji: '\u{2705}', keywords: ['done', 'complete', 'yes'] },
{ shortcode: 'x', emoji: '\u{274C}', keywords: ['no', 'wrong', 'delete'] },
// Animals & Nature
{ shortcode: 'dog', emoji: '\u{1F436}', keywords: ['puppy', 'pet'] },
{ shortcode: 'cat', emoji: '\u{1F431}', keywords: ['kitten', 'pet'] },
{ shortcode: 'mouse', emoji: '\u{1F42D}', keywords: ['rodent'] },
{ shortcode: 'rabbit', emoji: '\u{1F430}', keywords: ['bunny'] },
{ shortcode: 'fox', emoji: '\u{1F98A}', keywords: ['animal'] },
{ shortcode: 'bear', emoji: '\u{1F43B}', keywords: ['animal'] },
{ shortcode: 'panda', emoji: '\u{1F43C}', keywords: ['animal', 'bear'] },
{ shortcode: 'koala', emoji: '\u{1F428}', keywords: ['animal'] },
{ shortcode: 'tiger', emoji: '\u{1F42F}', keywords: ['animal', 'cat'] },
{ shortcode: 'lion', emoji: '\u{1F981}', keywords: ['animal', 'cat', 'king'] },
{ shortcode: 'unicorn', emoji: '\u{1F984}', keywords: ['magic', 'horse'] },
{ shortcode: 'bee', emoji: '\u{1F41D}', keywords: ['insect', 'buzz'] },
{ shortcode: 'butterfly', emoji: '\u{1F98B}', keywords: ['insect', 'pretty'] },
{ shortcode: 'turtle', emoji: '\u{1F422}', keywords: ['slow', 'animal'] },
{ shortcode: 'octopus', emoji: '\u{1F419}', keywords: ['sea', 'tentacles'] },
{ shortcode: 'crab', emoji: '\u{1F980}', keywords: ['sea', 'pinch'] },
{ shortcode: 'shark', emoji: '\u{1F988}', keywords: ['sea', 'fish'] },
{ shortcode: 'whale', emoji: '\u{1F433}', keywords: ['sea', 'big'] },
{ shortcode: 'dolphin', emoji: '\u{1F42C}', keywords: ['sea', 'smart'] },
{ shortcode: 'bird', emoji: '\u{1F426}', keywords: ['fly', 'tweet'] },
{ shortcode: 'eagle', emoji: '\u{1F985}', keywords: ['bird', 'america'] },
{ shortcode: 'owl', emoji: '\u{1F989}', keywords: ['bird', 'wise', 'night'] },
{ shortcode: 'snake', emoji: '\u{1F40D}', keywords: ['reptile'] },
{ shortcode: 'dragon', emoji: '\u{1F409}', keywords: ['mythical', 'fire'] },
{ shortcode: 'sauropod', emoji: '\u{1F995}', keywords: ['dinosaur', 'dino'] },
{ shortcode: 'trex', emoji: '\u{1F996}', keywords: ['dinosaur', 'dino'] },
{ shortcode: 'tree', emoji: '\u{1F333}', keywords: ['nature', 'plant'] },
{ shortcode: 'evergreen', emoji: '\u{1F332}', keywords: ['tree', 'pine', 'christmas'] },
{ shortcode: 'palm_tree', emoji: '\u{1F334}', keywords: ['tree', 'beach', 'tropical'] },
{ shortcode: 'cactus', emoji: '\u{1F335}', keywords: ['plant', 'desert'] },
{ shortcode: 'flower', emoji: '\u{1F33C}', keywords: ['blossom', 'nature'] },
{ shortcode: 'rose', emoji: '\u{1F339}', keywords: ['flower', 'love', 'red'] },
{ shortcode: 'sunflower', emoji: '\u{1F33B}', keywords: ['flower', 'yellow'] },
{ shortcode: 'four_leaf_clover', emoji: '\u{1F340}', keywords: ['luck', 'irish'] },
{ shortcode: 'mushroom', emoji: '\u{1F344}', keywords: ['fungus', 'nature'] },
{ shortcode: 'sun', emoji: '\u{2600}\u{FE0F}', keywords: ['sunny', 'weather', 'hot'] },
{ shortcode: 'moon', emoji: '\u{1F319}', keywords: ['night', 'sleep'] },
{ shortcode: 'full_moon', emoji: '\u{1F315}', keywords: ['night'] },
{ shortcode: 'cloud', emoji: '\u{2601}\u{FE0F}', keywords: ['weather', 'cloudy'] },
{ shortcode: 'rain', emoji: '\u{1F327}\u{FE0F}', keywords: ['weather', 'wet'] },
{ shortcode: 'snow', emoji: '\u{2744}\u{FE0F}', keywords: ['cold', 'winter', 'snowflake'] },
{ shortcode: 'rainbow', emoji: '\u{1F308}', keywords: ['colors', 'gay', 'pride'] },
// Food & Drink
{ shortcode: 'apple', emoji: '\u{1F34E}', keywords: ['fruit', 'red'] },
{ shortcode: 'orange', emoji: '\u{1F34A}', keywords: ['fruit', 'tangerine'] },
{ shortcode: 'lemon', emoji: '\u{1F34B}', keywords: ['fruit', 'sour', 'yellow'] },
{ shortcode: 'banana', emoji: '\u{1F34C}', keywords: ['fruit', 'yellow'] },
{ shortcode: 'watermelon', emoji: '\u{1F349}', keywords: ['fruit', 'summer'] },
{ shortcode: 'grapes', emoji: '\u{1F347}', keywords: ['fruit', 'wine'] },
{ shortcode: 'strawberry', emoji: '\u{1F353}', keywords: ['fruit', 'berry', 'red'] },
{ shortcode: 'peach', emoji: '\u{1F351}', keywords: ['fruit'] },
{ shortcode: 'cherry', emoji: '\u{1F352}', keywords: ['fruit', 'red'] },
{ shortcode: 'avocado', emoji: '\u{1F951}', keywords: ['vegetable', 'guac'] },
{ shortcode: 'carrot', emoji: '\u{1F955}', keywords: ['vegetable', 'orange'] },
{ shortcode: 'corn', emoji: '\u{1F33D}', keywords: ['vegetable', 'yellow'] },
{ shortcode: 'hot_pepper', emoji: '\u{1F336}\u{FE0F}', keywords: ['spicy', 'chili'] },
{ shortcode: 'pizza', emoji: '\u{1F355}', keywords: ['food', 'italian'] },
{ shortcode: 'hamburger', emoji: '\u{1F354}', keywords: ['food', 'burger', 'fast food'] },
{ shortcode: 'fries', emoji: '\u{1F35F}', keywords: ['food', 'fast food'] },
{ shortcode: 'hotdog', emoji: '\u{1F32D}', keywords: ['food', 'sausage'] },
{ shortcode: 'taco', emoji: '\u{1F32E}', keywords: ['food', 'mexican'] },
{ shortcode: 'burrito', emoji: '\u{1F32F}', keywords: ['food', 'mexican'] },
{ shortcode: 'sandwich', emoji: '\u{1F96A}', keywords: ['food', 'lunch'] },
{ shortcode: 'egg', emoji: '\u{1F95A}', keywords: ['food', 'breakfast'] },
{ shortcode: 'bacon', emoji: '\u{1F953}', keywords: ['food', 'breakfast', 'meat'] },
{ shortcode: 'pancakes', emoji: '\u{1F95E}', keywords: ['food', 'breakfast'] },
{ shortcode: 'bread', emoji: '\u{1F35E}', keywords: ['food', 'loaf'] },
{ shortcode: 'croissant', emoji: '\u{1F950}', keywords: ['food', 'french', 'breakfast'] },
{ shortcode: 'cheese', emoji: '\u{1F9C0}', keywords: ['food', 'dairy'] },
{ shortcode: 'poultry_leg', emoji: '\u{1F357}', keywords: ['food', 'meat', 'chicken'] },
{ shortcode: 'sushi', emoji: '\u{1F363}', keywords: ['food', 'japanese', 'fish'] },
{ shortcode: 'ramen', emoji: '\u{1F35C}', keywords: ['food', 'noodles', 'japanese'] },
{ shortcode: 'spaghetti', emoji: '\u{1F35D}', keywords: ['food', 'pasta', 'italian'] },
{ shortcode: 'curry', emoji: '\u{1F35B}', keywords: ['food', 'indian', 'rice'] },
{ shortcode: 'ice_cream', emoji: '\u{1F368}', keywords: ['food', 'dessert', 'cold'] },
{ shortcode: 'donut', emoji: '\u{1F369}', keywords: ['food', 'dessert', 'sweet'] },
{ shortcode: 'cookie', emoji: '\u{1F36A}', keywords: ['food', 'dessert', 'sweet'] },
{ shortcode: 'cake', emoji: '\u{1F370}', keywords: ['food', 'dessert', 'birthday'] },
{ shortcode: 'birthday', emoji: '\u{1F382}', keywords: ['cake', 'celebrate', 'party'] },
{ shortcode: 'chocolate', emoji: '\u{1F36B}', keywords: ['food', 'dessert', 'sweet'] },
{ shortcode: 'candy', emoji: '\u{1F36C}', keywords: ['food', 'sweet'] },
{ shortcode: 'lollipop', emoji: '\u{1F36D}', keywords: ['food', 'sweet', 'candy'] },
{ shortcode: 'popcorn', emoji: '\u{1F37F}', keywords: ['food', 'movie', 'snack'] },
{ shortcode: 'coffee', emoji: '\u{2615}', keywords: ['drink', 'hot', 'cafe'] },
{ shortcode: 'tea', emoji: '\u{1F375}', keywords: ['drink', 'hot'] },
{ shortcode: 'beer', emoji: '\u{1F37A}', keywords: ['drink', 'alcohol'] },
{ shortcode: 'beers', emoji: '\u{1F37B}', keywords: ['drink', 'alcohol', 'cheers'] },
{ shortcode: 'wine', emoji: '\u{1F377}', keywords: ['drink', 'alcohol', 'red'] },
{ shortcode: 'cocktail', emoji: '\u{1F378}', keywords: ['drink', 'alcohol', 'martini'] },
{ shortcode: 'tropical_drink', emoji: '\u{1F379}', keywords: ['drink', 'alcohol', 'vacation'] },
{ shortcode: 'champagne', emoji: '\u{1F37E}', keywords: ['drink', 'alcohol', 'celebrate'] },
{ shortcode: 'bubble_tea', emoji: '\u{1F9CB}', keywords: ['drink', 'boba'] },
// Activities & Objects
{ shortcode: 'soccer', emoji: '\u{26BD}', keywords: ['sports', 'football', 'ball'] },
{ shortcode: 'basketball', emoji: '\u{1F3C0}', keywords: ['sports', 'ball'] },
{ shortcode: 'football', emoji: '\u{1F3C8}', keywords: ['sports', 'american'] },
{ shortcode: 'baseball', emoji: '\u{26BE}', keywords: ['sports', 'ball'] },
{ shortcode: 'tennis', emoji: '\u{1F3BE}', keywords: ['sports', 'ball', 'racket'] },
{ shortcode: 'golf', emoji: '\u{26F3}', keywords: ['sports'] },
{ shortcode: 'trophy', emoji: '\u{1F3C6}', keywords: ['winner', 'champion', 'award'] },
{ shortcode: 'medal', emoji: '\u{1F3C5}', keywords: ['winner', 'award', 'first'] },
{ shortcode: 'video_game', emoji: '\u{1F3AE}', keywords: ['gaming', 'controller', 'play'] },
{ shortcode: 'joystick', emoji: '\u{1F579}\u{FE0F}', keywords: ['gaming', 'arcade'] },
{ shortcode: 'dart', emoji: '\u{1F3AF}', keywords: ['bullseye', 'target'] },
{ shortcode: 'bowling', emoji: '\u{1F3B3}', keywords: ['sports', 'ball'] },
{ shortcode: 'guitar', emoji: '\u{1F3B8}', keywords: ['music', 'rock'] },
{ shortcode: 'piano', emoji: '\u{1F3B9}', keywords: ['music', 'keys'] },
{ shortcode: 'microphone', emoji: '\u{1F3A4}', keywords: ['music', 'sing', 'karaoke'] },
{ shortcode: 'headphones', emoji: '\u{1F3A7}', keywords: ['music', 'listen'] },
{ shortcode: 'movie', emoji: '\u{1F3AC}', keywords: ['film', 'cinema', 'clapper'] },
{ shortcode: 'tv', emoji: '\u{1F4FA}', keywords: ['television', 'watch'] },
{ shortcode: 'camera', emoji: '\u{1F4F7}', keywords: ['photo', 'picture'] },
{ shortcode: 'phone', emoji: '\u{1F4F1}', keywords: ['mobile', 'cell', 'smartphone'] },
{ shortcode: 'computer', emoji: '\u{1F4BB}', keywords: ['laptop', 'pc', 'work'] },
{ shortcode: 'keyboard', emoji: '\u{2328}\u{FE0F}', keywords: ['type', 'computer'] },
{ shortcode: 'desktop', emoji: '\u{1F5A5}\u{FE0F}', keywords: ['computer', 'monitor'] },
{ shortcode: 'printer', emoji: '\u{1F5A8}\u{FE0F}', keywords: ['computer', 'paper'] },
{ shortcode: 'mouse_computer', emoji: '\u{1F5B1}\u{FE0F}', keywords: ['computer', 'click'] },
{ shortcode: 'disk', emoji: '\u{1F4BE}', keywords: ['save', 'floppy', 'storage'] },
{ shortcode: 'cd', emoji: '\u{1F4BF}', keywords: ['disc', 'music', 'storage'] },
{ shortcode: 'dvd', emoji: '\u{1F4C0}', keywords: ['disc', 'movie', 'storage'] },
{ shortcode: 'battery', emoji: '\u{1F50B}', keywords: ['power', 'energy'] },
{ shortcode: 'bulb', emoji: '\u{1F4A1}', keywords: ['light', 'idea'] },
{ shortcode: 'flashlight', emoji: '\u{1F526}', keywords: ['light', 'torch'] },
{ shortcode: 'book', emoji: '\u{1F4D6}', keywords: ['read', 'study'] },
{ shortcode: 'books', emoji: '\u{1F4DA}', keywords: ['read', 'study', 'library'] },
{ shortcode: 'notebook', emoji: '\u{1F4D3}', keywords: ['write', 'notes'] },
{ shortcode: 'memo', emoji: '\u{1F4DD}', keywords: ['write', 'notes', 'pencil'] },
{ shortcode: 'pencil', emoji: '\u{270F}\u{FE0F}', keywords: ['write', 'draw'] },
{ shortcode: 'pen', emoji: '\u{1F58A}\u{FE0F}', keywords: ['write'] },
{ shortcode: 'scissors', emoji: '\u{2702}\u{FE0F}', keywords: ['cut'] },
{ shortcode: 'paperclip', emoji: '\u{1F4CE}', keywords: ['attach'] },
{ shortcode: 'pushpin', emoji: '\u{1F4CC}', keywords: ['pin', 'location'] },
{ shortcode: 'folder', emoji: '\u{1F4C1}', keywords: ['file', 'directory'] },
{ shortcode: 'calendar', emoji: '\u{1F4C5}', keywords: ['date', 'schedule'] },
{ shortcode: 'chart', emoji: '\u{1F4CA}', keywords: ['graph', 'stats', 'data'] },
{ shortcode: 'chart_up', emoji: '\u{1F4C8}', keywords: ['graph', 'increase', 'growth'] },
{ shortcode: 'chart_down', emoji: '\u{1F4C9}', keywords: ['graph', 'decrease', 'decline'] },
{ shortcode: 'clipboard', emoji: '\u{1F4CB}', keywords: ['list', 'todo'] },
{ shortcode: 'lock', emoji: '\u{1F512}', keywords: ['secure', 'private'] },
{ shortcode: 'unlock', emoji: '\u{1F513}', keywords: ['open', 'access'] },
{ shortcode: 'key', emoji: '\u{1F511}', keywords: ['lock', 'password', 'access'] },
{ shortcode: 'hammer', emoji: '\u{1F528}', keywords: ['tool', 'build'] },
{ shortcode: 'wrench', emoji: '\u{1F527}', keywords: ['tool', 'fix', 'settings'] },
{ shortcode: 'gear', emoji: '\u{2699}\u{FE0F}', keywords: ['settings', 'options', 'cog'] },
{ shortcode: 'link', emoji: '\u{1F517}', keywords: ['chain', 'url'] },
{ shortcode: 'magnet', emoji: '\u{1F9F2}', keywords: ['attract'] },
{ shortcode: 'hourglass', emoji: '\u{23F3}', keywords: ['time', 'wait', 'sand'] },
{ shortcode: 'alarm', emoji: '\u{23F0}', keywords: ['clock', 'time', 'wake'] },
{ shortcode: 'stopwatch', emoji: '\u{23F1}\u{FE0F}', keywords: ['time', 'speed'] },
{ shortcode: 'timer', emoji: '\u{23F2}\u{FE0F}', keywords: ['clock', 'time'] },
{ shortcode: 'watch', emoji: '\u{231A}', keywords: ['time', 'clock'] },
{ shortcode: 'bell', emoji: '\u{1F514}', keywords: ['notification', 'alert'] },
{ shortcode: 'no_bell', emoji: '\u{1F515}', keywords: ['mute', 'silent'] },
{ shortcode: 'mega', emoji: '\u{1F4E3}', keywords: ['megaphone', 'announce'] },
{ shortcode: 'loudspeaker', emoji: '\u{1F4E2}', keywords: ['announce', 'broadcast'] },
{ shortcode: 'speech', emoji: '\u{1F4AC}', keywords: ['bubble', 'talk', 'chat'] },
{ shortcode: 'thought', emoji: '\u{1F4AD}', keywords: ['bubble', 'think'] },
{ shortcode: 'mail', emoji: '\u{1F4E7}', keywords: ['email', 'message'] },
{ shortcode: 'inbox', emoji: '\u{1F4E5}', keywords: ['email', 'receive'] },
{ shortcode: 'outbox', emoji: '\u{1F4E4}', keywords: ['email', 'send'] },
{ shortcode: 'package', emoji: '\u{1F4E6}', keywords: ['box', 'delivery'] },
{ shortcode: 'gift', emoji: '\u{1F381}', keywords: ['present', 'birthday'] },
{ shortcode: 'balloon', emoji: '\u{1F388}', keywords: ['party', 'celebration'] },
{ shortcode: 'confetti', emoji: '\u{1F38A}', keywords: ['party', 'celebration'] },
{ shortcode: 'ribbon', emoji: '\u{1F380}', keywords: ['decoration', 'pink'] },
{ shortcode: 'money', emoji: '\u{1F4B0}', keywords: ['cash', 'bag', 'dollar'] },
{ shortcode: 'dollar', emoji: '\u{1F4B5}', keywords: ['money', 'cash'] },
{ shortcode: 'credit_card', emoji: '\u{1F4B3}', keywords: ['money', 'payment'] },
{ shortcode: 'gem', emoji: '\u{1F48E}', keywords: ['diamond', 'jewel', 'precious'] },
{ shortcode: 'crown', emoji: '\u{1F451}', keywords: ['king', 'queen', 'royal'] },
{ shortcode: 'ring', emoji: '\u{1F48D}', keywords: ['wedding', 'diamond', 'engaged'] },
{ shortcode: 'lipstick', emoji: '\u{1F484}', keywords: ['makeup', 'cosmetics'] },
{ shortcode: 'pill', emoji: '\u{1F48A}', keywords: ['medicine', 'drug'] },
{ shortcode: 'syringe', emoji: '\u{1F489}', keywords: ['medicine', 'vaccine', 'shot'] },
{ shortcode: 'microscope', emoji: '\u{1F52C}', keywords: ['science', 'lab'] },
{ shortcode: 'telescope', emoji: '\u{1F52D}', keywords: ['science', 'space', 'astronomy'] },
{ shortcode: 'satellite', emoji: '\u{1F6F0}\u{FE0F}', keywords: ['space', 'orbit'] },
{ shortcode: 'rocket', emoji: '\u{1F680}', keywords: ['space', 'launch', 'fast'] },
{ shortcode: 'airplane', emoji: '\u{2708}\u{FE0F}', keywords: ['travel', 'flight'] },
{ shortcode: 'helicopter', emoji: '\u{1F681}', keywords: ['travel', 'flight'] },
{ shortcode: 'car', emoji: '\u{1F697}', keywords: ['vehicle', 'drive', 'auto'] },
{ shortcode: 'taxi', emoji: '\u{1F695}', keywords: ['vehicle', 'cab'] },
{ shortcode: 'bus', emoji: '\u{1F68C}', keywords: ['vehicle', 'transit'] },
{ shortcode: 'train', emoji: '\u{1F686}', keywords: ['vehicle', 'transit', 'rail'] },
{ shortcode: 'bike', emoji: '\u{1F6B2}', keywords: ['bicycle', 'exercise'] },
{ shortcode: 'ship', emoji: '\u{1F6A2}', keywords: ['boat', 'cruise', 'water'] },
{ shortcode: 'anchor', emoji: '\u{2693}', keywords: ['ship', 'boat', 'dock'] },
{ shortcode: 'construction', emoji: '\u{1F6A7}', keywords: ['warning', 'wip', 'work'] },
{ shortcode: 'flag_white', emoji: '\u{1F3F3}\u{FE0F}', keywords: ['surrender', 'peace'] },
{ shortcode: 'flag_black', emoji: '\u{1F3F4}', keywords: ['pirate'] },
{ shortcode: 'checkered_flag', emoji: '\u{1F3C1}', keywords: ['race', 'finish'] },
{ shortcode: 'triangular_flag', emoji: '\u{1F6A9}', keywords: ['flag', 'mark'] }
];
// Plugin key for the emoji suggestion state
var emojiPluginKey = new PluginKey('emoji-suggestion');
/**
* Find emojis matching the query
*/
function searchEmojis(query) {
query = (query || '').toLowerCase();
if (!query) {
return emojiData.slice(0, 50); // Return first 50 when no query
}
return emojiData.filter(function(item) {
// Match shortcode
if (item.shortcode.toLowerCase().indexOf(query) !== -1) {
return true;
}
// Match keywords
if (item.keywords && item.keywords.some(function(kw) {
return kw.toLowerCase().indexOf(query) !== -1;
})) {
return true;
}
return false;
}).slice(0, 20); // Limit results
}
/**
* Create the Emoji extension
*/
function createEmojiExtension() {
return Extension.create({
name: 'emoji',
addProseMirrorPlugins: function() {
var editor = this.editor;
return [
new Plugin({
key: emojiPluginKey,
state: {
init: function() {
return {
active: false,
query: '',
range: null,
selectedIndex: 0,
};
},
apply: function(tr, state) {
var meta = tr.getMeta(emojiPluginKey);
if (meta) {
return meta;
}
// Check if we need to deactivate
if (state.active) {
var selection = tr.selection;
if (!selection.empty) {
return { active: false, query: '', range: null, selectedIndex: 0 };
}
}
return state;
},
},
props: {
handleTextInput: function(view, from, to, text) {
var state = emojiPluginKey.getState(view.state);
// Check for : to start emoji suggestion
if (text === ':' && !state.active) {
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
active: true,
query: '',
range: { from: from, to: to + 1 },
selectedIndex: 0,
}));
showEmojiPopup(editor, view, from + 1, '');
return false;
}
// If active, update the query
if (state.active && state.range) {
// Check if typing valid characters (letters, numbers, underscore)
if (/^[a-zA-Z0-9_]$/.test(text)) {
var newQuery = state.query + text;
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
active: true,
query: newQuery,
range: { from: state.range.from, to: to + 1 },
selectedIndex: 0,
}));
updateEmojiPopup(editor, newQuery);
return false;
}
// Check if completing with :
if (text === ':') {
var emoji = findExactEmoji(state.query);
if (emoji) {
// Insert emoji and close popup
var tr = view.state.tr.delete(state.range.from - 1, to).insertText(emoji.emoji);
view.dispatch(tr.setMeta(emojiPluginKey, {
active: false,
query: '',
range: null,
selectedIndex: 0,
}));
hideEmojiPopup();
return true;
}
}
// Space or other characters close the popup
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
active: false,
query: '',
range: null,
selectedIndex: 0,
}));
hideEmojiPopup();
}
return false;
},
handleKeyDown: function(view, event) {
var state = emojiPluginKey.getState(view.state);
if (!state.active) {
return false;
}
// Handle special keys
if (event.key === 'Escape') {
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
active: false,
query: '',
range: null,
selectedIndex: 0,
}));
hideEmojiPopup();
event.stopPropagation();
return true;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
navigateEmojiPopup(1, view, state);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
navigateEmojiPopup(-1, view, state);
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
selectCurrentEmoji(editor, view, state);
return true;
}
if (event.key === 'Backspace') {
if (state.query.length > 0) {
var newQuery = state.query.slice(0, -1);
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
active: true,
query: newQuery,
range: state.range,
selectedIndex: 0,
}));
updateEmojiPopup(editor, newQuery);
return false; // Let default backspace happen
} else {
// Close popup when deleting the :
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
active: false,
query: '',
range: null,
selectedIndex: 0,
}));
hideEmojiPopup();
return false;
}
}
return false;
},
},
}),
];
},
addCommands: function() {
return {
insertEmoji: function(emoji) {
return function(props) {
return props.commands.insertContent(emoji);
};
},
};
},
});
}
// Popup element reference
var emojiPopup = null;
var currentResults = [];
var currentSelectedIndex = 0;
/**
* Find exact emoji match
*/
function findExactEmoji(shortcode) {
shortcode = (shortcode || '').toLowerCase();
for (var i = 0; i < emojiData.length; i++) {
if (emojiData[i].shortcode.toLowerCase() === shortcode) {
return emojiData[i];
}
}
return null;
}
/**
* Show the emoji popup
*/
function showEmojiPopup(editor, view, from, query) {
hideEmojiPopup();
emojiPopup = document.createElement('div');
emojiPopup.className = 'tiptap-emoji-popup';
currentResults = searchEmojis(query);
currentSelectedIndex = 0;
renderEmojiList(editor, view);
document.body.appendChild(emojiPopup);
positionEmojiPopup(view, from);
}
/**
* Update the emoji popup with new results
*/
function updateEmojiPopup(editor, query) {
if (!emojiPopup) return;
currentResults = searchEmojis(query);
currentSelectedIndex = 0;
renderEmojiList(editor, editor.view);
}
/**
* Render the emoji list
*/
function renderEmojiList(editor, view) {
if (!emojiPopup) return;
emojiPopup.innerHTML = '';
if (currentResults.length === 0) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'tiptap-emoji-popup__empty';
emptyDiv.textContent = 'No emojis found';
emojiPopup.appendChild(emptyDiv);
return;
}
var list = document.createElement('div');
list.className = 'tiptap-emoji-popup__list';
currentResults.forEach(function(item, index) {
var row = document.createElement('div');
row.className = 'tiptap-emoji-popup__item';
if (index === currentSelectedIndex) {
row.classList.add('tiptap-emoji-popup__item--selected');
}
var emojiSpan = document.createElement('span');
emojiSpan.className = 'tiptap-emoji-popup__emoji';
emojiSpan.textContent = item.emoji;
row.appendChild(emojiSpan);
var shortcodeSpan = document.createElement('span');
shortcodeSpan.className = 'tiptap-emoji-popup__shortcode';
shortcodeSpan.textContent = ':' + item.shortcode + ':';
row.appendChild(shortcodeSpan);
row.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
insertEmoji(editor, view, item);
});
row.addEventListener('mouseenter', function() {
currentSelectedIndex = index;
renderEmojiList(editor, view);
});
list.appendChild(row);
});
emojiPopup.appendChild(list);
}
/**
* Position the emoji popup near the cursor
*/
function positionEmojiPopup(view, from) {
if (!emojiPopup) return;
var coords = view.coordsAtPos(from);
var editorRect = view.dom.getBoundingClientRect();
var left = coords.left;
var top = coords.bottom + 5;
// Keep within viewport
var popupWidth = 280;
var popupHeight = 300;
if (left + popupWidth > window.innerWidth) {
left = window.innerWidth - popupWidth - 10;
}
if (left < 10) {
left = 10;
}
if (top + popupHeight > window.innerHeight) {
top = coords.top - popupHeight - 5;
}
emojiPopup.style.left = left + 'px';
emojiPopup.style.top = top + 'px';
}
/**
* Navigate the emoji popup
*/
function navigateEmojiPopup(direction, view, state) {
if (!emojiPopup || currentResults.length === 0) return;
currentSelectedIndex += direction;
if (currentSelectedIndex < 0) {
currentSelectedIndex = currentResults.length - 1;
}
if (currentSelectedIndex >= currentResults.length) {
currentSelectedIndex = 0;
}
view.dispatch(view.state.tr.setMeta(emojiPluginKey, {
...state,
selectedIndex: currentSelectedIndex,
}));
renderEmojiList(view.state.plugins[0].spec.editor || { view: view }, view);
// Scroll to selected
var selected = emojiPopup.querySelector('.tiptap-emoji-popup__item--selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
}
/**
* Select the current emoji
*/
function selectCurrentEmoji(editor, view, state) {
if (currentResults.length === 0) return;
var emoji = currentResults[currentSelectedIndex];
if (emoji) {
insertEmoji(editor, view, emoji);
}
}
/**
* Insert an emoji
*/
function insertEmoji(editor, view, emojiItem) {
var state = emojiPluginKey.getState(view.state);
if (state && state.range) {
// Delete the :query and insert emoji
var tr = view.state.tr.delete(state.range.from - 1, view.state.selection.from).insertText(emojiItem.emoji);
view.dispatch(tr.setMeta(emojiPluginKey, {
active: false,
query: '',
range: null,
selectedIndex: 0,
}));
} else {
// Just insert emoji
editor.commands.insertContent(emojiItem.emoji);
}
hideEmojiPopup();
}
/**
* Hide the emoji popup
*/
function hideEmojiPopup() {
if (emojiPopup) {
emojiPopup.remove();
emojiPopup = null;
}
currentResults = [];
currentSelectedIndex = 0;
}
/**
* Show emoji picker dialog
*/
function showEmojiPickerDialog(editor) {
var overlay = document.createElement('div');
overlay.className = 'tiptap-emoji-dialog__overlay';
var dialog = document.createElement('div');
dialog.className = 'tiptap-emoji-dialog';
// Header
var header = document.createElement('div');
header.className = 'tiptap-emoji-dialog__header';
header.innerHTML = '<h3>Insert Emoji</h3><button type="button" class="tiptap-emoji-dialog__close">&times;</button>';
dialog.appendChild(header);
// Search
var searchDiv = document.createElement('div');
searchDiv.className = 'tiptap-emoji-dialog__search';
var searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = 'Search emojis...';
searchInput.className = 'tiptap-emoji-dialog__search-input';
searchDiv.appendChild(searchInput);
dialog.appendChild(searchDiv);
// Grid
var grid = document.createElement('div');
grid.className = 'tiptap-emoji-dialog__grid';
dialog.appendChild(grid);
// Footer
var footer = document.createElement('div');
footer.className = 'tiptap-emoji-dialog__footer';
footer.innerHTML = '<button type="button" class="tiptap-emoji-dialog__cancel">Cancel</button>';
dialog.appendChild(footer);
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Render emojis
function renderGrid(query) {
grid.innerHTML = '';
var results = searchEmojis(query);
results.forEach(function(item) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tiptap-emoji-dialog__emoji-btn';
btn.textContent = item.emoji;
btn.title = ':' + item.shortcode + ':';
btn.addEventListener('click', function() {
editor.commands.insertContent(item.emoji);
closeDialog();
});
grid.appendChild(btn);
});
if (results.length === 0) {
grid.innerHTML = '<div class="tiptap-emoji-dialog__empty">No emojis found</div>';
}
}
// Initial render
renderGrid('');
// Search handler
var searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
renderGrid(searchInput.value);
}, 150);
});
// Close handlers
function closeDialog() {
overlay.remove();
editor.commands.focus();
}
header.querySelector('.tiptap-emoji-dialog__close').addEventListener('click', closeDialog);
footer.querySelector('.tiptap-emoji-dialog__cancel').addEventListener('click', closeDialog);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeDialog();
}
});
// Focus search
setTimeout(function() {
searchInput.focus();
}, 100);
}
module.exports = {
createEmojiExtension: createEmojiExtension,
showEmojiPickerDialog: showEmojiPickerDialog,
searchEmojis: searchEmojis,
emojiData: emojiData,
};

View File

@@ -0,0 +1,159 @@
/**
* Resizable Image Extension for Tiptap
*
* Extends @tiptap/extension-image with drag-to-resize handles.
* Adds a `width` attribute to image nodes and wraps them in a
* resize container with a corner drag handle when selected.
*
* This is a custom replacement for the broken `tiptap-extension-resize-image`
* npm package (its package.json declares "type":"module" but ships CJS,
* causing webpack to fail with "exports is not defined").
*
* @module tiptap/extensions/imageResize
*/
'use strict';
/**
* Creates a resizable image extension by extending the base Image extension.
*
* @param {Object} Image - The @tiptap/extension-image default export
* @returns {Object} Extended TipTap Image node with resize support
*/
function createResizableImage(Image) {
return Image.extend({
name: 'image',
addAttributes: function() {
return Object.assign({}, this.parent ? this.parent() : {}, {
width: {
default: null,
parseHTML: function(element) {
// Read from style or attribute
return element.getAttribute('width') ||
element.style.width ||
null;
},
renderHTML: function(attributes) {
if (!attributes.width) {
return {};
}
return {
width: attributes.width,
style: 'width: ' + attributes.width + (String(attributes.width).match(/\d$/) ? 'px' : ''),
};
},
},
});
},
addNodeView: function() {
return function(props) {
var node = props.node;
var getPos = props.getPos;
var editor = props.editor;
// Outer container
var container = document.createElement('div');
container.className = 'image-resizer';
container.style.display = 'inline-block';
container.style.position = 'relative';
container.style.lineHeight = '0';
container.style.maxWidth = '100%';
if (node.attrs.width) {
var w = String(node.attrs.width);
container.style.width = w + (w.match(/\d$/) ? 'px' : '');
}
// Image element
var img = document.createElement('img');
img.src = node.attrs.src || '';
img.alt = node.attrs.alt || '';
if (node.attrs.title) img.title = node.attrs.title;
img.style.width = '100%';
img.style.display = 'block';
img.draggable = false;
container.appendChild(img);
// Resize handle (bottom-right corner)
var handle = document.createElement('div');
handle.className = 'resize-trigger';
handle.style.cssText = 'position:absolute;right:-4px;bottom:-4px;width:10px;height:10px;' +
'background:var(--primary-color,#5a67d8);border:2px solid #fff;border-radius:50%;' +
'cursor:se-resize;z-index:10;display:none;';
container.appendChild(handle);
// Show handle only when selected
var selected = false;
function updateSelection(isSelected) {
selected = isSelected;
handle.style.display = isSelected ? 'block' : 'none';
container.style.outline = isSelected ? '2px solid var(--primary-color,#5a67d8)' : 'none';
container.style.outlineOffset = isSelected ? '2px' : '0';
}
// Drag resize logic
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
e.stopPropagation();
var startX = e.clientX;
var startWidth = container.offsetWidth;
function onMouseMove(e) {
var newWidth = Math.max(50, startWidth + (e.clientX - startX));
container.style.width = newWidth + 'px';
img.style.width = '100%';
}
function onMouseUp() {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
// Commit the new width to the document model
var pos = getPos();
if (typeof pos === 'number') {
var newWidth = container.offsetWidth;
editor.chain()
.command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(pos, undefined, Object.assign(
{}, node.attrs, { width: newWidth }
));
return true;
})
.run();
}
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
return {
dom: container,
update: function(updatedNode) {
if (updatedNode.type.name !== 'image') return false;
node = updatedNode;
img.src = updatedNode.attrs.src || '';
img.alt = updatedNode.attrs.alt || '';
if (updatedNode.attrs.title) img.title = updatedNode.attrs.title;
if (updatedNode.attrs.width) {
var w = String(updatedNode.attrs.width);
container.style.width = w + (w.match(/\d$/) ? 'px' : '');
}
return true;
},
selectNode: function() { updateSelection(true); },
deselectNode: function() { updateSelection(false); },
destroy: function() {
// Cleanup handled by GC
},
};
};
},
});
}
module.exports = { createResizableImage: createResizableImage };

View File

@@ -0,0 +1,461 @@
/**
* Math/LaTeX Extension for Tiptap
*
* Enables inserting and rendering LaTeX math formulas using KaTeX.
* Supports both inline math ($ ... $) and block math ($$ ... $$).
*
* @module tiptap/extensions/math
*/
const { Node, mergeAttributes } = require('@tiptap/core');
// Import KaTeX directly from npm package (bundled with webpack)
// Note: KaTeX CSS is loaded separately via link tag in pageBottom.blade.php
const katex = require('katex');
// Make KaTeX available globally for consistency
window.katex = katex;
/**
* Load KaTeX - returns immediately since it's bundled
*/
function loadKaTeX() {
return Promise.resolve(katex);
}
/**
* Render LaTeX to HTML
*/
function renderMath(latex, displayMode) {
displayMode = displayMode === undefined ? false : displayMode;
try {
return katex.renderToString(latex, {
throwOnError: false,
displayMode: displayMode,
strict: false,
trust: false,
output: 'html',
});
} catch (error) {
return '<span class="tiptap-math__error">' + escapeHtml(error.message || 'Invalid LaTeX') + '</span>';
}
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
/**
* Math Inline Node - For inline math like $x^2$
*/
var MathInline = Node.create({
name: 'mathInline',
group: 'inline',
inline: true,
atom: true,
addAttributes: function() {
return {
latex: {
default: '',
},
};
},
parseHTML: function() {
return [
{
tag: 'span[data-math-inline]',
getAttrs: function(dom) {
return { latex: dom.getAttribute('data-latex') || dom.textContent };
},
},
{
tag: 'span.katex',
getAttrs: function(dom) {
var annotation = dom.querySelector('annotation');
return { latex: annotation ? annotation.textContent : '' };
},
},
];
},
renderHTML: function(props) {
return [
'span',
mergeAttributes({
class: 'tiptap-math tiptap-math--inline',
'data-math-inline': '',
'data-latex': props.node.attrs.latex,
}),
props.node.attrs.latex,
];
},
addNodeView: function() {
return function(props) {
var node = props.node;
var editor = props.editor;
var getPos = props.getPos;
var dom = document.createElement('span');
dom.className = 'tiptap-math tiptap-math--inline';
dom.setAttribute('data-math-inline', '');
dom.setAttribute('data-latex', node.attrs.latex);
// Render math
function render() {
loadKaTeX().then(function() {
dom.innerHTML = renderMath(node.attrs.latex, false);
}).catch(function() {
dom.innerHTML = '<span class="tiptap-math__placeholder">$' + escapeHtml(node.attrs.latex) + '$</span>';
});
}
render();
// Double-click to edit
dom.addEventListener('dblclick', function(e) {
e.preventDefault();
e.stopPropagation();
var newLatex = window.prompt('Edit LaTeX:', node.attrs.latex);
if (newLatex !== null && typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(pos, undefined, { latex: newLatex });
return true;
}).run();
}
});
return {
dom: dom,
update: function(updatedNode) {
if (updatedNode.type.name !== 'mathInline') {
return false;
}
if (updatedNode.attrs.latex !== node.attrs.latex) {
node = updatedNode;
dom.setAttribute('data-latex', node.attrs.latex);
render();
}
return true;
},
};
};
},
addCommands: function() {
var self = this;
return {
setMathInline: function(options) {
return function(props) {
return props.commands.insertContent({
type: self.name,
attrs: {
latex: (options && options.latex) || 'x^2',
},
});
};
},
};
},
});
/**
* Math Block Node - For display math like $$ ... $$
*/
var MathBlock = Node.create({
name: 'mathBlock',
group: 'block',
atom: true,
draggable: true,
addAttributes: function() {
return {
latex: {
default: '',
},
};
},
parseHTML: function() {
return [
{
tag: 'div[data-math-block]',
getAttrs: function(dom) {
return { latex: dom.getAttribute('data-latex') || dom.textContent };
},
},
{
tag: 'div.katex-display',
getAttrs: function(dom) {
var annotation = dom.querySelector('annotation');
return { latex: annotation ? annotation.textContent : '' };
},
},
];
},
renderHTML: function(props) {
return [
'div',
mergeAttributes({
class: 'tiptap-math tiptap-math--block',
'data-math-block': '',
'data-latex': props.node.attrs.latex,
}),
props.node.attrs.latex,
];
},
addNodeView: function() {
return function(props) {
var node = props.node;
var editor = props.editor;
var getPos = props.getPos;
var dom = document.createElement('div');
dom.className = 'tiptap-math tiptap-math--block';
dom.setAttribute('data-math-block', '');
dom.setAttribute('data-latex', node.attrs.latex);
var mathContainer = document.createElement('div');
mathContainer.className = 'tiptap-math__display';
dom.appendChild(mathContainer);
// Edit button
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'tiptap-math__edit-btn';
editBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>';
editBtn.title = 'Edit equation';
dom.appendChild(editBtn);
// Delete button
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'tiptap-math__delete-btn';
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
deleteBtn.title = 'Delete equation';
dom.appendChild(deleteBtn);
// Render math
function render() {
loadKaTeX().then(function() {
mathContainer.innerHTML = renderMath(node.attrs.latex, true);
}).catch(function() {
mathContainer.innerHTML = '<div class="tiptap-math__placeholder">$$' + escapeHtml(node.attrs.latex) + '$$</div>';
});
}
render();
// Edit button handler
editBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
showMathDialog(editor, node.attrs.latex, true, function(newLatex) {
if (typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(pos, undefined, { latex: newLatex });
return true;
}).run();
}
});
});
// Delete button handler
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.delete(pos, pos + node.nodeSize);
return true;
}).run();
}
});
return {
dom: dom,
update: function(updatedNode) {
if (updatedNode.type.name !== 'mathBlock') {
return false;
}
if (updatedNode.attrs.latex !== node.attrs.latex) {
node = updatedNode;
dom.setAttribute('data-latex', node.attrs.latex);
render();
}
return true;
},
stopEvent: function(event) {
return event.target === editBtn || event.target === deleteBtn;
},
};
};
},
addCommands: function() {
var self = this;
return {
setMathBlock: function(options) {
return function(props) {
return props.commands.insertContent({
type: self.name,
attrs: {
latex: (options && options.latex) || '\\sum_{i=1}^{n} x_i',
},
});
};
},
};
},
addKeyboardShortcuts: function() {
return {
'Mod-Alt-e': function() {
return this.editor.commands.setMathBlock();
},
};
},
});
/**
* Show Math dialog for inserting/editing equations
*/
function showMathDialog(editor, initialLatex, isBlock, onSave) {
initialLatex = initialLatex || '';
isBlock = isBlock === undefined ? true : isBlock;
// Create dialog overlay
var overlay = document.createElement('div');
overlay.className = 'tiptap-math-dialog__overlay';
var dialog = document.createElement('div');
dialog.className = 'tiptap-math-dialog';
dialog.innerHTML =
'<div class="tiptap-math-dialog__header">' +
'<h3>' + (onSave ? 'Edit' : 'Insert') + ' Math Equation</h3>' +
'<button type="button" class="tiptap-math-dialog__close">&times;</button>' +
'</div>' +
'<div class="tiptap-math-dialog__body">' +
'<div class="tiptap-math-dialog__type">' +
'<label><input type="radio" name="mathType" value="block" ' + (isBlock ? 'checked' : '') + '> Block (display)</label>' +
'<label><input type="radio" name="mathType" value="inline" ' + (!isBlock ? 'checked' : '') + '> Inline</label>' +
'</div>' +
'<textarea class="tiptap-math-dialog__code" rows="4" placeholder="Enter LaTeX...">' + escapeHtml(initialLatex) + '</textarea>' +
'<div class="tiptap-math-dialog__examples">' +
'<small>Examples: \\frac{a}{b}, \\sqrt{x}, x^2, \\sum_{i=1}^n, \\int_0^1</small>' +
'</div>' +
'<div class="tiptap-math-dialog__preview">' +
'<label>Preview:</label>' +
'<div class="tiptap-math-dialog__preview-area"></div>' +
'</div>' +
'</div>' +
'<div class="tiptap-math-dialog__footer">' +
'<button type="button" class="tiptap-math-dialog__cancel">Cancel</button>' +
'<button type="button" class="tiptap-math-dialog__insert">' + (onSave ? 'Save' : 'Insert') + '</button>' +
'</div>';
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Get elements
var codeArea = dialog.querySelector('.tiptap-math-dialog__code');
var previewArea = dialog.querySelector('.tiptap-math-dialog__preview-area');
var typeRadios = dialog.querySelectorAll('input[name="mathType"]');
var closeBtn = dialog.querySelector('.tiptap-math-dialog__close');
var cancelBtn = dialog.querySelector('.tiptap-math-dialog__cancel');
var insertBtn = dialog.querySelector('.tiptap-math-dialog__insert');
// Preview function
var previewTimeout;
function updatePreview() {
clearTimeout(previewTimeout);
previewTimeout = setTimeout(function() {
var latex = codeArea.value.trim();
var displayMode = dialog.querySelector('input[name="mathType"]:checked').value === 'block';
if (!latex) {
previewArea.innerHTML = '<span class="tiptap-math-dialog__preview-empty">Enter LaTeX to see preview</span>';
return;
}
loadKaTeX().then(function() {
previewArea.innerHTML = renderMath(latex, displayMode);
}).catch(function() {
previewArea.innerHTML = '<span class="tiptap-math-dialog__preview-error">Could not load KaTeX</span>';
});
}, 300);
}
// Type change handler
typeRadios.forEach(function(radio) {
radio.addEventListener('change', updatePreview);
});
// Code change handler
codeArea.addEventListener('input', updatePreview);
// Close handlers
function closeDialog() {
overlay.remove();
}
closeBtn.addEventListener('click', closeDialog);
cancelBtn.addEventListener('click', closeDialog);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeDialog();
}
});
// Insert/Save handler
insertBtn.addEventListener('click', function() {
var latex = codeArea.value.trim();
var useBlock = dialog.querySelector('input[name="mathType"]:checked').value === 'block';
if (latex) {
if (onSave) {
onSave(latex);
} else if (useBlock) {
editor.chain().focus().setMathBlock({ latex: latex }).run();
} else {
editor.chain().focus().setMathInline({ latex: latex }).run();
}
}
closeDialog();
});
// Initial preview
updatePreview();
// Focus code area
setTimeout(function() {
codeArea.focus();
codeArea.select();
}, 100);
}
/**
* Create the Math extension bundle
*/
function createMathExtension() {
return [MathInline, MathBlock];
}
module.exports = {
createMathExtension: createMathExtension,
MathInline: MathInline,
MathBlock: MathBlock,
showMathDialog: showMathDialog,
loadKaTeX: loadKaTeX,
};

View File

@@ -0,0 +1,318 @@
/**
* Tiptap Mention Extension for Leantime
*
* Provides @mentions functionality with user autocomplete
*/
const Mention = require('@tiptap/extension-mention').default;
const { PluginKey } = require('@tiptap/pm/state');
const { mergeAttributes } = require('@tiptap/core');
/**
* Extended Mention extension that outputs data-tagged-user-id for backend notifications
*/
const LeantimeMention = Mention.extend({
// Render as <a> tag with data-tagged-user-id attribute for backend processing
renderHTML: function(props) {
var node = props.node;
var HTMLAttributes = props.HTMLAttributes;
return [
'a',
mergeAttributes(
{ 'data-tagged-user-id': node.attrs.id },
this.options.HTMLAttributes,
HTMLAttributes
),
'@' + node.attrs.label
];
},
// Parse mentions from existing HTML (both legacy format and current format)
parseHTML: function() {
return [
{
tag: 'a[data-tagged-user-id]',
getAttrs: function(element) {
return {
id: element.getAttribute('data-tagged-user-id'),
label: element.textContent.replace(/^@/, '')
};
}
},
{
tag: 'a.userMention[data-tagged-user-id]',
getAttrs: function(element) {
return {
id: element.getAttribute('data-tagged-user-id'),
label: element.textContent.replace(/^@/, '')
};
}
},
{
tag: 'span[data-type="mention"]',
getAttrs: function(element) {
return {
id: element.getAttribute('data-id'),
label: element.getAttribute('data-label') || element.textContent.replace(/^@/, '')
};
}
}
];
}
});
/**
* Fetch users from the API based on query
*/
function fetchUsers(query) {
return new Promise(function(resolve, reject) {
leantime.rpc('Users.Users.searchProjectUsers', { query: query || '' })
.then(function(data) {
// Transform API response to mention format
var users = (data || []).map(function(user) {
return {
id: user.id,
label: (user.firstname + ' ' + user.lastname).trim(),
email: user.username || user.email,
profileId: user.profileId || null
};
});
resolve(users);
})
.catch(function(error) {
console.error('[Mention] Error fetching users:', error);
resolve([]);
});
});
}
/**
* Create the suggestion dropdown popup
*/
function createSuggestionPopup() {
var popup = document.createElement('div');
popup.className = 'tiptap-mention-popup';
popup.style.display = 'none';
document.body.appendChild(popup);
return popup;
}
/**
* Render suggestion items in the popup
*/
function renderSuggestionItems(items, popup, selectedIndex, onSelect) {
if (items.length === 0) {
popup.innerHTML = '<div class="tiptap-mention-popup__empty">No users found</div>';
return;
}
var html = items.map(function(item, index) {
var activeClass = index === selectedIndex ? 'tiptap-mention-popup__item--active' : '';
var initials = getInitials(item.label);
return '<div class="tiptap-mention-popup__item ' + activeClass + '" data-index="' + index + '">' +
'<div class="tiptap-mention-popup__avatar">' + initials + '</div>' +
'<div class="tiptap-mention-popup__info">' +
'<div class="tiptap-mention-popup__name">' + escapeHtml(item.label) + '</div>' +
'<div class="tiptap-mention-popup__email">' + escapeHtml(item.email || '') + '</div>' +
'</div>' +
'</div>';
}).join('');
popup.innerHTML = html;
// Add click handlers
var itemElements = popup.querySelectorAll('.tiptap-mention-popup__item');
itemElements.forEach(function(el) {
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var index = parseInt(el.getAttribute('data-index'), 10);
if (items[index]) {
onSelect(items[index]);
}
});
});
}
/**
* Get initials from a name
*/
function getInitials(name) {
if (!name) return '?';
var parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Position the popup near the cursor
*/
function positionPopup(popup, clientRect) {
if (!clientRect) {
popup.style.display = 'none';
return;
}
var popupHeight = popup.offsetHeight || 200;
var popupWidth = popup.offsetWidth || 280;
var viewportHeight = window.innerHeight;
var viewportWidth = window.innerWidth;
// The popup is position: fixed, so clientRect (viewport-relative) is used directly
// without adding scroll offsets.
var top = clientRect.bottom + 4;
var left = clientRect.left;
// Flip above the caret if it would overflow the bottom of the viewport.
if (top + popupHeight > viewportHeight) {
top = clientRect.top - popupHeight - 4;
}
// Keep within horizontal viewport bounds.
if (left + popupWidth > viewportWidth) {
left = viewportWidth - popupWidth - 8;
}
if (left < 0) {
left = 8;
}
popup.style.top = top + 'px';
popup.style.left = left + 'px';
popup.style.display = 'block';
}
/**
* Create the configured Mention extension
*/
function createMentionExtension() {
var popup = null;
var currentItems = [];
var selectedIndex = 0;
var commandRef = null;
function selectItem(item) {
if (commandRef && item) {
commandRef({ id: item.id, label: item.label });
}
}
return LeantimeMention.configure({
HTMLAttributes: {
class: 'tiptap-mention',
},
suggestion: {
char: '@',
pluginKey: new PluginKey('mentionSuggestion'),
allowSpaces: false,
startOfLine: false,
items: function(props) {
var query = props.query || '';
return fetchUsers(query).then(function(users) {
// Filter by query client-side as well for faster results
if (query) {
var lowerQuery = query.toLowerCase();
return users.filter(function(user) {
return user.label.toLowerCase().includes(lowerQuery) ||
(user.email && user.email.toLowerCase().includes(lowerQuery));
}).slice(0, 10);
}
return users.slice(0, 10);
});
},
render: function() {
return {
onStart: function(props) {
// Create popup if needed
if (!popup) {
popup = createSuggestionPopup();
}
selectedIndex = 0;
currentItems = props.items || [];
commandRef = props.command;
renderSuggestionItems(currentItems, popup, selectedIndex, selectItem);
positionPopup(popup, props.clientRect ? props.clientRect() : null);
},
onUpdate: function(props) {
selectedIndex = 0;
currentItems = props.items || [];
commandRef = props.command;
renderSuggestionItems(currentItems, popup, selectedIndex, selectItem);
positionPopup(popup, props.clientRect ? props.clientRect() : null);
},
onKeyDown: function(props) {
var event = props.event;
if (event.key === 'ArrowDown') {
event.preventDefault();
selectedIndex = (selectedIndex + 1) % currentItems.length;
renderSuggestionItems(currentItems, popup, selectedIndex, selectItem);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length;
renderSuggestionItems(currentItems, popup, selectedIndex, selectItem);
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
if (currentItems[selectedIndex]) {
selectItem(currentItems[selectedIndex]);
}
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
if (popup) {
popup.style.display = 'none';
}
return true;
}
return false;
},
onExit: function() {
if (popup) {
popup.style.display = 'none';
}
currentItems = [];
selectedIndex = 0;
commandRef = null;
},
};
},
},
});
}
// Export for use in main module
module.exports = {
createMentionExtension: createMentionExtension,
fetchUsers: fetchUsers
};

View File

@@ -0,0 +1,417 @@
/**
* Mermaid Diagram Extension for Tiptap
*
* Enables inserting and editing Mermaid diagrams in the editor.
* Uses the existing mermaid.js library already loaded in the project.
*
* @module tiptap/extensions/mermaid
*/
const { Node, mergeAttributes } = require('@tiptap/core');
// Default diagram template
var defaultDiagram = 'graph TD\n A[Start] --> B{Decision}\n B -->|Yes| C[Do Something]\n B -->|No| D[Do Something Else]\n C --> E[End]\n D --> E';
// Counter for unique IDs
var mermaidIdCounter = 0;
/**
* Create the Mermaid extension
*/
function createMermaidExtension() {
return Node.create({
name: 'mermaid',
group: 'block',
atom: true,
draggable: true,
addAttributes: function() {
return {
code: {
default: defaultDiagram,
},
};
},
parseHTML: function() {
return [
{
tag: 'div[data-mermaid]',
getAttrs: function(dom) {
return {
code: dom.getAttribute('data-code') || dom.textContent || defaultDiagram,
};
},
},
{
tag: 'pre.mermaid',
getAttrs: function(dom) {
return {
code: dom.textContent || defaultDiagram,
};
},
},
];
},
renderHTML: function(props) {
return [
'div',
mergeAttributes({
class: 'tiptap-mermaid',
'data-mermaid': '',
'data-code': props.node.attrs.code,
}),
['pre', { class: 'mermaid-source' }, props.node.attrs.code],
];
},
addNodeView: function() {
return function(props) {
var node = props.node;
var editor = props.editor;
var getPos = props.getPos;
// Create container
var container = document.createElement('div');
container.className = 'tiptap-mermaid';
container.setAttribute('data-mermaid', '');
// Create diagram display area
var diagramContainer = document.createElement('div');
diagramContainer.className = 'tiptap-mermaid__diagram';
container.appendChild(diagramContainer);
// Create edit button
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'tiptap-mermaid__edit-btn';
editBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>';
editBtn.title = 'Edit diagram';
container.appendChild(editBtn);
// Create delete button
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'tiptap-mermaid__delete-btn';
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
deleteBtn.title = 'Delete diagram';
container.appendChild(deleteBtn);
// Create edit mode container (hidden by default)
var editContainer = document.createElement('div');
editContainer.className = 'tiptap-mermaid__edit';
editContainer.style.display = 'none';
var textarea = document.createElement('textarea');
textarea.className = 'tiptap-mermaid__textarea';
textarea.value = node.attrs.code;
textarea.placeholder = 'Enter Mermaid diagram code...';
editContainer.appendChild(textarea);
var buttonRow = document.createElement('div');
buttonRow.className = 'tiptap-mermaid__buttons';
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'tiptap-mermaid__save-btn';
saveBtn.textContent = 'Save';
buttonRow.appendChild(saveBtn);
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'tiptap-mermaid__cancel-btn';
cancelBtn.textContent = 'Cancel';
buttonRow.appendChild(cancelBtn);
editContainer.appendChild(buttonRow);
container.appendChild(editContainer);
// Render function
function renderDiagram(code) {
if (!window.mermaid) {
diagramContainer.innerHTML = '<div class="tiptap-mermaid__error">Mermaid library not loaded</div>';
return;
}
var id = 'mermaid-' + (++mermaidIdCounter);
try {
// Initialize mermaid if not done
if (!window.mermaidInitialized) {
window.mermaid.initialize({
startOnLoad: false,
theme: document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'default',
securityLevel: 'strict',
});
window.mermaidInitialized = true;
}
// Render the diagram
window.mermaid.render(id, code).then(function(result) {
diagramContainer.innerHTML = result.svg;
}).catch(function(error) {
diagramContainer.innerHTML = '<div class="tiptap-mermaid__error">Invalid diagram syntax:<br>' + escapeHtml(error.message || String(error)) + '</div>';
});
} catch (error) {
diagramContainer.innerHTML = '<div class="tiptap-mermaid__error">Error rendering diagram:<br>' + escapeHtml(error.message || String(error)) + '</div>';
}
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initial render
renderDiagram(node.attrs.code);
// Edit button handler
editBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
diagramContainer.style.display = 'none';
editBtn.style.display = 'none';
deleteBtn.style.display = 'none';
editContainer.style.display = 'block';
textarea.value = node.attrs.code;
textarea.focus();
});
// Save button handler
saveBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var newCode = textarea.value.trim();
if (newCode && typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(params) {
params.tr.setNodeMarkup(pos, undefined, { code: newCode });
return true;
}).run();
}
diagramContainer.style.display = 'block';
editBtn.style.display = '';
deleteBtn.style.display = '';
editContainer.style.display = 'none';
renderDiagram(newCode || node.attrs.code);
});
// Cancel button handler
cancelBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
diagramContainer.style.display = 'block';
editBtn.style.display = '';
deleteBtn.style.display = '';
editContainer.style.display = 'none';
});
// Delete button handler
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(params) {
params.tr.delete(pos, pos + node.nodeSize);
return true;
}).run();
}
});
return {
dom: container,
update: function(updatedNode) {
if (updatedNode.type.name !== 'mermaid') {
return false;
}
if (updatedNode.attrs.code !== node.attrs.code) {
node = updatedNode;
renderDiagram(updatedNode.attrs.code);
}
return true;
},
destroy: function() {
// Cleanup if needed
},
stopEvent: function(event) {
// Allow events on textarea and buttons
return event.target === textarea ||
event.target === saveBtn ||
event.target === cancelBtn ||
event.target === editBtn ||
event.target === deleteBtn;
},
};
};
},
addCommands: function() {
var self = this;
return {
setMermaid: function(options) {
return function(props) {
return props.commands.insertContent({
type: self.name,
attrs: {
code: (options && options.code) || defaultDiagram,
},
});
};
},
};
},
addKeyboardShortcuts: function() {
return {
'Mod-Alt-m': function() {
return this.editor.commands.setMermaid();
},
};
},
});
}
/**
* Show Mermaid dialog for inserting a new diagram
*/
function showMermaidDialog(editor) {
// Create dialog overlay
var overlay = document.createElement('div');
overlay.className = 'tiptap-mermaid-dialog__overlay';
var dialog = document.createElement('div');
dialog.className = 'tiptap-mermaid-dialog';
dialog.innerHTML =
'<div class="tiptap-mermaid-dialog__header">' +
'<h3>Insert Mermaid Diagram</h3>' +
'<button type="button" class="tiptap-mermaid-dialog__close">&times;</button>' +
'</div>' +
'<div class="tiptap-mermaid-dialog__body">' +
'<div class="tiptap-mermaid-dialog__templates">' +
'<label>Template:</label>' +
'<select class="tiptap-mermaid-dialog__template-select">' +
'<option value="flowchart">Flowchart</option>' +
'<option value="sequence">Sequence Diagram</option>' +
'<option value="gantt">Gantt Chart</option>' +
'<option value="pie">Pie Chart</option>' +
'<option value="mindmap">Mind Map</option>' +
'<option value="custom">Custom</option>' +
'</select>' +
'</div>' +
'<textarea class="tiptap-mermaid-dialog__code" rows="10" placeholder="Enter Mermaid diagram code...">' + defaultDiagram + '</textarea>' +
'<div class="tiptap-mermaid-dialog__preview">' +
'<label>Preview:</label>' +
'<div class="tiptap-mermaid-dialog__preview-area"></div>' +
'</div>' +
'</div>' +
'<div class="tiptap-mermaid-dialog__footer">' +
'<button type="button" class="tiptap-mermaid-dialog__cancel">Cancel</button>' +
'<button type="button" class="tiptap-mermaid-dialog__insert">Insert Diagram</button>' +
'</div>';
overlay.appendChild(dialog);
document.body.appendChild(overlay);
// Get elements
var codeArea = dialog.querySelector('.tiptap-mermaid-dialog__code');
var templateSelect = dialog.querySelector('.tiptap-mermaid-dialog__template-select');
var previewArea = dialog.querySelector('.tiptap-mermaid-dialog__preview-area');
var closeBtn = dialog.querySelector('.tiptap-mermaid-dialog__close');
var cancelBtn = dialog.querySelector('.tiptap-mermaid-dialog__cancel');
var insertBtn = dialog.querySelector('.tiptap-mermaid-dialog__insert');
// Templates
var templates = {
flowchart: 'graph TD\n A[Start] --> B{Decision}\n B -->|Yes| C[Do Something]\n B -->|No| D[Do Something Else]\n C --> E[End]\n D --> E',
sequence: 'sequenceDiagram\n participant A as Alice\n participant B as Bob\n A->>B: Hello Bob!\n B-->>A: Hi Alice!',
gantt: 'gantt\n title Project Timeline\n dateFormat YYYY-MM-DD\n section Planning\n Research :a1, 2024-01-01, 7d\n Design :a2, after a1, 5d\n section Development\n Implementation :a3, after a2, 10d\n Testing :a4, after a3, 5d',
pie: 'pie title Distribution\n "Category A" : 40\n "Category B" : 30\n "Category C" : 20\n "Category D" : 10',
mindmap: 'mindmap\n root((Main Topic))\n Branch A\n Leaf 1\n Leaf 2\n Branch B\n Leaf 3\n Branch C',
custom: '',
};
// Preview function
var previewTimeout;
function updatePreview() {
clearTimeout(previewTimeout);
previewTimeout = setTimeout(function() {
var code = codeArea.value.trim();
if (!code) {
previewArea.innerHTML = '<span class="tiptap-mermaid-dialog__preview-empty">Enter code to see preview</span>';
return;
}
if (window.mermaid) {
var id = 'mermaid-preview-' + Date.now();
try {
window.mermaid.render(id, code).then(function(result) {
previewArea.innerHTML = result.svg;
}).catch(function(error) {
var errorSpan = document.createElement('span');
errorSpan.className = 'tiptap-mermaid-dialog__preview-error';
errorSpan.textContent = error.message || 'Invalid syntax';
previewArea.innerHTML = '';
previewArea.appendChild(errorSpan);
});
} catch (error) {
var errorSpan = document.createElement('span');
errorSpan.className = 'tiptap-mermaid-dialog__preview-error';
errorSpan.textContent = error.message || 'Error';
previewArea.innerHTML = '';
previewArea.appendChild(errorSpan);
}
}
}, 500);
}
// Template change handler
templateSelect.addEventListener('change', function() {
var template = templates[templateSelect.value];
if (template !== undefined) {
codeArea.value = template;
updatePreview();
}
});
// Code change handler
codeArea.addEventListener('input', updatePreview);
// Close handlers
function closeDialog() {
overlay.remove();
}
closeBtn.addEventListener('click', closeDialog);
cancelBtn.addEventListener('click', closeDialog);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) {
closeDialog();
}
});
// Insert handler
insertBtn.addEventListener('click', function() {
var code = codeArea.value.trim();
if (code) {
editor.chain().focus().setMermaid({ code: code }).run();
}
closeDialog();
});
// Initial preview
updatePreview();
// Focus code area
setTimeout(function() {
codeArea.focus();
}, 100);
}
module.exports = {
createMermaidExtension: createMermaidExtension,
showMermaidDialog: showMermaidDialog,
};

View File

@@ -0,0 +1,780 @@
/**
* Tiptap Slash Commands Extension for Leantime
*
* Provides Notion-style "/" commands for quick content insertion
*/
const { Extension } = require('@tiptap/core');
const { PluginKey, Plugin } = require('@tiptap/pm/state');
const Suggestion = require('@tiptap/suggestion').default;
/**
* Default slash commands available in the editor
*/
var defaultCommands = [
// Sorted alphabetically by label
{
name: 'columns2',
label: '2 Columns',
description: 'Create two-column layout',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="18" rx="1"/><rect x="14" y="3" width="7" height="18" rx="1"/></svg>',
command: function(editor) {
editor.chain().focus().setColumns(2).run();
}
},
{
name: 'columns3',
label: '3 Columns',
description: 'Create three-column layout',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="5" height="18" rx="1"/><rect x="9.5" y="3" width="5" height="18" rx="1"/><rect x="17" y="3" width="5" height="18" rx="1"/></svg>',
command: function(editor) {
editor.chain().focus().setColumns(3).run();
}
},
{
name: 'columns4',
label: '4 Columns',
description: 'Create four-column layout',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="1" y="3" width="4" height="18" rx="1"/><rect x="7" y="3" width="4" height="18" rx="1"/><rect x="13" y="3" width="4" height="18" rx="1"/><rect x="19" y="3" width="4" height="18" rx="1"/></svg>',
command: function(editor) {
editor.chain().focus().setColumns(4).run();
}
},
{
name: 'sidebarLeft',
label: 'Sidebar Left',
description: 'Narrow left, wide right layout',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="5" height="18" rx="1"/><rect x="11" y="3" width="10" height="18" rx="1"/></svg>',
command: function(editor) {
editor.chain().focus().setColumnLayout(2, 'sidebar-left').run();
}
},
{
name: 'sidebarRight',
label: 'Sidebar Right',
description: 'Wide left, narrow right layout',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="10" height="18" rx="1"/><rect x="16" y="3" width="5" height="18" rx="1"/></svg>',
command: function(editor) {
editor.chain().focus().setColumnLayout(2, 'sidebar-right').run();
}
},
{
name: 'sidebarBoth',
label: 'Sidebar Both',
description: 'Sidebars on both sides of content',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="4" height="18" rx="1"/><rect x="8" y="3" width="8" height="18" rx="1"/><rect x="18" y="3" width="4" height="18" rx="1"/></svg>',
command: function(editor) {
editor.chain().focus().setColumnLayout(3, 'sidebar-both').run();
}
},
{
name: 'bulletList',
label: 'Bullet List',
description: 'Create a simple bullet list',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" y2="18"/><circle cx="4" cy="6" r="1.5" fill="currentColor"/><circle cx="4" cy="12" r="1.5" fill="currentColor"/><circle cx="4" cy="18" r="1.5" fill="currentColor"/></svg>',
command: function(editor) {
editor.chain().focus().toggleBulletList().run();
}
},
{
name: 'codeBlock',
label: 'Code Block',
description: 'Add a code snippet',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
command: function(editor) {
editor.chain().focus().toggleCodeBlock().run();
}
},
{
name: 'details',
label: 'Collapsible',
description: 'Add collapsible section',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 9l7 7 7-7"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>',
command: function(editor) {
editor.chain().focus().setDetails().run();
}
},
{
name: 'mermaid',
label: 'Diagram',
description: 'Insert a Mermaid diagram',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="8" y="14" width="7" height="7" rx="1"/><line x1="6.5" y1="10" x2="6.5" y2="14"/><line x1="6.5" y1="14" x2="11.5" y2="14"/><line x1="17.5" y1="10" x2="17.5" y2="14"/><line x1="17.5" y1="14" x2="11.5" y2="14"/></svg>',
command: function(editor) {
if (window.leantime && window.leantime.tiptapMermaid) {
window.leantime.tiptapMermaid.showMermaidDialog(editor);
} else {
editor.chain().focus().setMermaid().run();
}
}
},
{
name: 'horizontalRule',
label: 'Divider',
description: 'Add a horizontal divider',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>',
command: function(editor) {
editor.chain().focus().setHorizontalRule().run();
}
},
{
name: 'embed',
label: 'Embed',
description: 'Embed video, docs, or other content',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>',
command: function(editor) {
if (window.leantime && window.leantime.tiptapEmbed) {
window.leantime.tiptapEmbed.showDialog(editor);
}
}
},
{
name: 'emoji',
label: 'Emoji',
description: 'Insert an emoji',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>',
command: function(editor) {
if (window.leantime && window.leantime.tiptapEmoji) {
window.leantime.tiptapEmoji.showEmojiPickerDialog(editor);
}
}
},
{
name: 'heading1',
label: 'Heading 1',
description: 'Large section heading',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="m17 12 3-2v8"/></svg>',
command: function(editor) {
editor.chain().focus().toggleHeading({ level: 1 }).run();
}
},
{
name: 'heading2',
label: 'Heading 2',
description: 'Medium section heading',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M21 18h-4c0-4 4-3 4-6 0-1.5-2-2.5-4-1"/></svg>',
command: function(editor) {
editor.chain().focus().toggleHeading({ level: 2 }).run();
}
},
{
name: 'heading3',
label: 'Heading 3',
description: 'Small section heading',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8"/><path d="M4 18V6"/><path d="M12 18V6"/><path d="M17.5 10.5c1.7-1 3.5 0 3.5 1.5a2 2 0 0 1-2 2"/><path d="M17 17.5c2 1.5 4 .3 4-1.5a2 2 0 0 0-2-2"/></svg>',
command: function(editor) {
editor.chain().focus().toggleHeading({ level: 3 }).run();
}
},
{
name: 'image',
label: 'Image',
description: 'Upload or embed an image',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>',
command: function(editor) {
// Trigger the image button in toolbar if available
var toolbar = editor.view.dom.closest('.tiptap-wrapper');
if (toolbar) {
var imageBtn = toolbar.querySelector('[data-command="image"]');
if (imageBtn) {
imageBtn.click();
return;
}
}
// Fallback to prompt
var url = window.prompt('Enter image URL:');
if (url) {
editor.chain().focus().setImage({ src: url }).run();
}
}
},
{
name: 'mathInline',
label: 'Inline Math',
description: 'Insert inline LaTeX formula',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><text x="6" y="17" font-size="14" fill="currentColor" stroke="none">x²</text></svg>',
command: function(editor) {
var latex = window.prompt('Enter LaTeX formula:', 'x^2');
if (latex) {
editor.chain().focus().setMathInline({ latex: latex }).run();
}
}
},
{
name: 'math',
label: 'Math Equation',
description: 'Insert a LaTeX math block',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20h4l6-16h4"/><path d="M4 12h6"/></svg>',
command: function(editor) {
if (window.leantime && window.leantime.tiptapMath) {
window.leantime.tiptapMath.showMathDialog(editor, '', true);
} else {
editor.chain().focus().setMathBlock().run();
}
}
},
{
name: 'numberedList',
label: 'Numbered List',
description: 'Create a numbered list',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>',
command: function(editor) {
editor.chain().focus().toggleOrderedList().run();
}
},
{
name: 'blockquote',
label: 'Quote',
description: 'Add a blockquote',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z"/></svg>',
command: function(editor) {
editor.chain().focus().toggleBlockquote().run();
}
},
{
name: 'table',
label: 'Table',
description: 'Insert a table',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>',
command: function(editor) {
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
}
},
{
name: 'toc',
label: 'Table of Contents',
description: 'Insert auto-generated TOC',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="5" y1="10" x2="21" y2="10"/><line x1="5" y1="14" x2="21" y2="14"/><line x1="5" y1="18" x2="21" y2="18"/><circle cx="3" cy="10" r="1" fill="currentColor"/><circle cx="3" cy="14" r="1" fill="currentColor"/><circle cx="3" cy="18" r="1" fill="currentColor"/></svg>',
command: function(editor) {
editor.chain().focus().setTableOfContents().run();
}
},
{
name: 'taskList',
label: 'Task List',
description: 'Create a checklist with tasks',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><line x1="13" y1="6" x2="21" y2="6"/><line x1="13" y1="12" x2="21" y2="12"/><line x1="13" y1="18" x2="21" y2="18"/></svg>',
command: function(editor) {
editor.chain().focus().toggleTaskList().run();
}
},
{
name: 'template',
label: 'Template',
description: 'Insert a document template',
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>',
command: function(editor) {
showTemplatePicker(editor);
}
}
];
/**
* Template picker popup
*/
var templateCache = null;
function fetchTemplates() {
return new Promise(function(resolve, reject) {
if (templateCache) {
resolve(templateCache);
return;
}
fetch(leantime.appUrl + '/wiki/templates', {
method: 'GET',
credentials: 'include',
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function(response) {
if (!response.ok) throw new Error('Failed to fetch templates');
return response.json();
})
.then(function(data) {
templateCache = data;
resolve(data);
})
.catch(function(error) {
console.error('[Templates] Error:', error);
resolve([]);
});
});
}
function showTemplatePicker(editor) {
// Close any existing picker
var existing = document.querySelector('.tiptap-template-picker');
if (existing) {
existing.remove();
}
// Create picker
var picker = document.createElement('div');
picker.className = 'tiptap-template-picker';
picker.innerHTML =
'<div class="tiptap-template-picker__overlay"></div>' +
'<div class="tiptap-template-picker__content">' +
'<div class="tiptap-template-picker__header">' +
'<h3>Insert Template</h3>' +
'<button type="button" class="tiptap-template-picker__close" aria-label="Close">&times;</button>' +
'</div>' +
'<div class="tiptap-template-picker__search">' +
'<input type="text" placeholder="Search templates..." class="tiptap-template-picker__search-input" />' +
'</div>' +
'<div class="tiptap-template-picker__body">' +
'<div class="tiptap-template-picker__loading">Loading templates...</div>' +
'</div>' +
'</div>';
document.body.appendChild(picker);
// Close handlers
function closePicker() {
picker.remove();
}
picker.querySelector('.tiptap-template-picker__overlay').addEventListener('click', closePicker);
picker.querySelector('.tiptap-template-picker__close').addEventListener('click', closePicker);
document.addEventListener('keydown', function escHandler(e) {
if (e.key === 'Escape') {
closePicker();
document.removeEventListener('keydown', escHandler);
}
});
// Fetch and render templates
var body = picker.querySelector('.tiptap-template-picker__body');
var searchInput = picker.querySelector('.tiptap-template-picker__search-input');
fetchTemplates().then(function(templates) {
function renderTemplates(filteredTemplates) {
if (filteredTemplates.length === 0) {
body.innerHTML = '<div class="tiptap-template-picker__empty">No templates found</div>';
return;
}
// Group by category
var categories = {};
filteredTemplates.forEach(function(tpl) {
var cat = tpl.category || 'Other';
if (!categories[cat]) {
categories[cat] = [];
}
categories[cat].push(tpl);
});
var html = '';
Object.keys(categories).forEach(function(category) {
html += '<div class="tiptap-template-picker__category">' +
'<div class="tiptap-template-picker__category-title">' + escapeHtml(category) + '</div>';
categories[category].forEach(function(tpl, index) {
html += '<div class="tiptap-template-picker__item" data-index="' + templates.indexOf(tpl) + '">' +
'<div class="tiptap-template-picker__item-title">' + escapeHtml(tpl.title) + '</div>' +
(tpl.description ? '<div class="tiptap-template-picker__item-desc">' + escapeHtml(tpl.description) + '</div>' : '') +
'</div>';
});
html += '</div>';
});
body.innerHTML = html;
// Click handlers for templates
body.querySelectorAll('.tiptap-template-picker__item').forEach(function(el) {
el.addEventListener('click', function() {
var idx = parseInt(el.getAttribute('data-index'), 10);
var tpl = templates[idx];
if (tpl && tpl.content) {
editor.chain().focus().insertContent(tpl.content).run();
closePicker();
}
});
});
}
renderTemplates(templates);
// Search functionality
searchInput.addEventListener('input', function() {
var query = searchInput.value.toLowerCase();
if (!query) {
renderTemplates(templates);
return;
}
var filtered = templates.filter(function(tpl) {
return tpl.title.toLowerCase().includes(query) ||
(tpl.description && tpl.description.toLowerCase().includes(query)) ||
(tpl.category && tpl.category.toLowerCase().includes(query));
});
renderTemplates(filtered);
});
searchInput.focus();
});
}
/**
* Create suggestion popup element
*/
function createSuggestionPopup() {
var popup = document.createElement('div');
popup.className = 'tiptap-slash-popup';
popup.style.display = 'none';
document.body.appendChild(popup);
return popup;
}
/**
* Track if we're using keyboard navigation (to ignore mouse hover during keyboard use)
*/
var isKeyboardNavigating = false;
var keyboardNavTimeout = null;
/**
* Update only the active state without re-rendering (for keyboard navigation)
*/
function updateActiveItem(popup, selectedIndex) {
var items = popup.querySelectorAll('.tiptap-slash-popup__item');
items.forEach(function(item, index) {
if (index === selectedIndex) {
item.classList.add('tiptap-slash-popup__item--active');
// Scroll into view without re-rendering
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
} else {
item.classList.remove('tiptap-slash-popup__item--active');
}
});
}
/**
* Render command items in popup
*/
function renderCommandItems(items, popup, selectedIndex, onSelect, onHover) {
if (items.length === 0) {
popup.innerHTML = '<div class="tiptap-slash-popup__empty">No commands found</div>';
return;
}
var html = '<div class="tiptap-slash-popup__header">Commands</div>';
html += '<div class="tiptap-slash-popup__items">';
html += items.map(function(item, index) {
var activeClass = index === selectedIndex ? 'tiptap-slash-popup__item--active' : '';
return '<div class="tiptap-slash-popup__item ' + activeClass + '" data-index="' + index + '">' +
'<div class="tiptap-slash-popup__icon">' + (item.icon || '') + '</div>' +
'<div class="tiptap-slash-popup__content">' +
'<div class="tiptap-slash-popup__label">' + escapeHtml(item.label) + '</div>' +
'<div class="tiptap-slash-popup__description">' + escapeHtml(item.description || '') + '</div>' +
'</div>' +
'</div>';
}).join('');
html += '</div>';
popup.innerHTML = html;
// Add click and hover handlers
popup.querySelectorAll('.tiptap-slash-popup__item').forEach(function(el) {
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var index = parseInt(el.getAttribute('data-index'), 10);
if (items[index]) {
onSelect(items[index]);
}
});
// Update active state on hover (only if not using keyboard)
el.addEventListener('mouseenter', function() {
// Skip if we're in the middle of keyboard navigation
if (isKeyboardNavigating) {
return;
}
var index = parseInt(el.getAttribute('data-index'), 10);
// Remove active class from all items
popup.querySelectorAll('.tiptap-slash-popup__item').forEach(function(item) {
item.classList.remove('tiptap-slash-popup__item--active');
});
// Add active class to hovered item
el.classList.add('tiptap-slash-popup__item--active');
// Notify parent of hover for keyboard navigation sync
if (onHover) {
onHover(index);
}
});
});
}
/**
* Escape HTML
*/
function escapeHtml(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Position popup near cursor
* Note: popup uses position:fixed, so coordinates are relative to viewport
*/
function positionPopup(popup, clientRect) {
if (!clientRect) {
popup.style.display = 'none';
return;
}
// Show popup first to get accurate dimensions
popup.style.visibility = 'hidden';
popup.style.display = 'block';
// Use requestAnimationFrame to ensure DOM has rendered
requestAnimationFrame(function() {
var popupHeight = popup.offsetHeight || 300;
var popupWidth = popup.offsetWidth || 280;
var viewportHeight = window.innerHeight;
var viewportWidth = window.innerWidth;
// For position:fixed, use clientRect directly (viewport-relative)
var cursorTop = clientRect.top;
var cursorBottom = clientRect.bottom;
var left = clientRect.left;
// Calculate space above and below
var spaceBelow = viewportHeight - cursorBottom - 16; // 16px margin
var spaceAbove = cursorTop - 16; // 16px margin
var top;
// Position below cursor if there's enough space, otherwise above
if (spaceBelow >= popupHeight || spaceBelow >= spaceAbove) {
// Position below
top = cursorBottom + 8;
// If still overflows, constrain to viewport
if (top + popupHeight > viewportHeight - 8) {
popup.style.maxHeight = (viewportHeight - top - 16) + 'px';
}
} else {
// Position above cursor
top = cursorTop - popupHeight - 8;
// If top goes negative, position at top with constrained height
if (top < 8) {
top = 8;
popup.style.maxHeight = (cursorTop - 24) + 'px';
}
}
// Adjust if popup would go off-screen (right)
if (left + popupWidth > viewportWidth) {
left = viewportWidth - popupWidth - 16;
}
// Adjust if popup would go off-screen (left)
if (left < 16) {
left = 16;
}
popup.style.top = top + 'px';
popup.style.left = left + 'px';
popup.style.visibility = 'visible';
});
}
/**
* Create the SlashCommands extension
*/
function createSlashCommandsExtension(customCommands) {
var commands = defaultCommands.slice();
// Add any custom commands registered via tiptapController
if (window.leantime && window.leantime.tiptapController) {
var registeredCommands = window.leantime.tiptapController.getSlashCommands();
if (registeredCommands && registeredCommands.size > 0) {
registeredCommands.forEach(function(cmdConfig, cmdName) {
commands.push({
name: cmdName,
label: cmdConfig.label || cmdName,
description: cmdConfig.description || '',
icon: cmdConfig.icon || '',
command: cmdConfig.action || cmdConfig.command
});
});
}
}
// Add custom commands passed as parameter
if (customCommands && customCommands.length) {
commands = commands.concat(customCommands);
}
var popup = null;
var currentItems = [];
var selectedIndex = 0;
var commandRef = null;
function selectItem(item) {
if (commandRef && item && item.command) {
commandRef(item);
}
}
return Extension.create({
name: 'slashCommands',
addOptions: function() {
return {
suggestion: {
char: '/',
startOfLine: false,
pluginKey: new PluginKey('slashCommands'),
items: function(props) {
var query = (props.query || '').toLowerCase();
if (!query) {
return commands;
}
return commands.filter(function(cmd) {
return cmd.label.toLowerCase().includes(query) ||
(cmd.description && cmd.description.toLowerCase().includes(query)) ||
cmd.name.toLowerCase().includes(query);
});
},
command: function(props) {
var item = props.props;
var editor = props.editor;
var range = props.range;
// Delete the slash command text
editor.chain().focus().deleteRange(range).run();
// Execute the command
if (item && item.command) {
item.command(editor);
}
},
render: function() {
return {
onStart: function(props) {
if (!popup) {
popup = createSuggestionPopup();
}
selectedIndex = 0;
currentItems = props.items || [];
commandRef = props.command;
isKeyboardNavigating = false;
renderCommandItems(currentItems, popup, selectedIndex, function(item) {
selectItem(item);
}, function(hoveredIndex) {
// Sync selectedIndex when mouse hovers
selectedIndex = hoveredIndex;
});
positionPopup(popup, props.clientRect ? props.clientRect() : null);
},
onUpdate: function(props) {
selectedIndex = 0;
currentItems = props.items || [];
commandRef = props.command;
renderCommandItems(currentItems, popup, selectedIndex, function(item) {
selectItem(item);
}, function(hoveredIndex) {
// Sync selectedIndex when mouse hovers
selectedIndex = hoveredIndex;
});
positionPopup(popup, props.clientRect ? props.clientRect() : null);
},
onKeyDown: function(props) {
var event = props.event;
if (event.key === 'ArrowDown') {
event.preventDefault();
// Set keyboard navigating flag
isKeyboardNavigating = true;
clearTimeout(keyboardNavTimeout);
keyboardNavTimeout = setTimeout(function() {
isKeyboardNavigating = false;
}, 500);
selectedIndex = (selectedIndex + 1) % currentItems.length;
// Just update active state, don't re-render
updateActiveItem(popup, selectedIndex);
return true;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
// Set keyboard navigating flag
isKeyboardNavigating = true;
clearTimeout(keyboardNavTimeout);
keyboardNavTimeout = setTimeout(function() {
isKeyboardNavigating = false;
}, 500);
selectedIndex = (selectedIndex - 1 + currentItems.length) % currentItems.length;
// Just update active state, don't re-render
updateActiveItem(popup, selectedIndex);
return true;
}
if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault();
if (currentItems[selectedIndex]) {
selectItem(currentItems[selectedIndex]);
}
return true;
}
if (event.key === 'Escape') {
event.preventDefault();
if (popup) {
popup.style.display = 'none';
}
return true;
}
return false;
},
onExit: function() {
if (popup) {
popup.style.display = 'none';
}
currentItems = [];
selectedIndex = 0;
commandRef = null;
isKeyboardNavigating = false;
clearTimeout(keyboardNavTimeout);
}
};
}
}
};
},
addProseMirrorPlugins: function() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion
})
];
}
});
}
// Export
module.exports = {
createSlashCommandsExtension: createSlashCommandsExtension,
defaultCommands: defaultCommands
};

View File

@@ -0,0 +1,468 @@
/**
* Table of Contents Extension for Tiptap
*
* Generates a table of contents from document headings.
* Custom implementation for navigation within documents.
*
* @module tiptap/extensions/tableOfContents
*/
const { Node, mergeAttributes, Extension } = require('@tiptap/core');
const { Plugin, PluginKey } = require('@tiptap/pm/state');
// Plugin key for TOC state
var tocPluginKey = new PluginKey('table-of-contents');
/**
* Generate a slug from text
*/
function slugify(text) {
return (text || '')
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.substring(0, 50);
}
/**
* Extract headings from the document
*/
function extractHeadings(doc) {
var headings = [];
var slugCounts = {};
doc.descendants(function(node, pos) {
if (node.type.name === 'heading') {
var text = node.textContent;
var level = node.attrs.level || 1;
var baseSlug = slugify(text);
// Handle duplicate slugs
if (slugCounts[baseSlug] === undefined) {
slugCounts[baseSlug] = 0;
} else {
slugCounts[baseSlug]++;
}
var slug = slugCounts[baseSlug] === 0 ? baseSlug : baseSlug + '-' + slugCounts[baseSlug];
headings.push({
id: slug,
text: text,
level: level,
pos: pos,
});
}
});
return headings;
}
/**
* Table of Contents Node - Rendered TOC block
*/
var TableOfContents = Node.create({
name: 'tableOfContents',
group: 'block',
atom: true,
draggable: true,
addAttributes: function() {
return {
// Maximum depth of headings to include (1-6)
maxDepth: {
default: 3,
},
// Whether to include numbering
numbered: {
default: false,
},
};
},
parseHTML: function() {
return [
{ tag: 'div[data-table-of-contents]' },
{ tag: 'nav.tiptap-toc' },
];
},
renderHTML: function(props) {
return [
'nav',
mergeAttributes({
class: 'tiptap-toc',
'data-table-of-contents': '',
'data-max-depth': props.node.attrs.maxDepth,
'data-numbered': props.node.attrs.numbered ? 'true' : 'false',
}, props.HTMLAttributes),
['div', { class: 'tiptap-toc__content' }, 'Table of Contents'],
];
},
addNodeView: function() {
return function(props) {
var node = props.node;
var editor = props.editor;
var getPos = props.getPos;
var container = document.createElement('nav');
container.className = 'tiptap-toc';
container.setAttribute('data-table-of-contents', '');
// Header
var header = document.createElement('div');
header.className = 'tiptap-toc__header';
var title = document.createElement('span');
title.className = 'tiptap-toc__title';
title.textContent = 'Table of Contents';
header.appendChild(title);
// Settings button
var settingsBtn = document.createElement('button');
settingsBtn.type = 'button';
settingsBtn.className = 'tiptap-toc__settings-btn';
settingsBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>';
settingsBtn.title = 'TOC Settings';
header.appendChild(settingsBtn);
// Delete button
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'tiptap-toc__delete-btn';
deleteBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>';
deleteBtn.title = 'Remove table of contents';
header.appendChild(deleteBtn);
container.appendChild(header);
// Content area
var content = document.createElement('div');
content.className = 'tiptap-toc__content';
container.appendChild(content);
// Settings panel (hidden by default)
var settingsPanel = document.createElement('div');
settingsPanel.className = 'tiptap-toc__settings-panel';
settingsPanel.style.display = 'none';
settingsPanel.innerHTML =
'<label class="tiptap-toc__setting">' +
'<span>Max depth:</span>' +
'<select class="tiptap-toc__depth-select">' +
'<option value="1">H1 only</option>' +
'<option value="2">H1-H2</option>' +
'<option value="3" selected>H1-H3</option>' +
'<option value="4">H1-H4</option>' +
'<option value="5">H1-H5</option>' +
'<option value="6">All headings</option>' +
'</select>' +
'</label>' +
'<label class="tiptap-toc__setting">' +
'<input type="checkbox" class="tiptap-toc__numbered-check"> ' +
'<span>Show numbers</span>' +
'</label>';
container.appendChild(settingsPanel);
var depthSelect = settingsPanel.querySelector('.tiptap-toc__depth-select');
var numberedCheck = settingsPanel.querySelector('.tiptap-toc__numbered-check');
// Set initial values
depthSelect.value = node.attrs.maxDepth;
numberedCheck.checked = node.attrs.numbered;
/**
* Render the TOC
*/
function renderTOC() {
var headings = extractHeadings(editor.state.doc);
var maxDepth = node.attrs.maxDepth;
var numbered = node.attrs.numbered;
// Filter by max depth
headings = headings.filter(function(h) {
return h.level <= maxDepth;
});
if (headings.length === 0) {
content.innerHTML = '<div class="tiptap-toc__empty">No headings found</div>';
return;
}
var list = document.createElement('ul');
list.className = 'tiptap-toc__list';
// Number counters for each level
var counters = [0, 0, 0, 0, 0, 0];
headings.forEach(function(heading, index) {
var item = document.createElement('li');
item.className = 'tiptap-toc__item tiptap-toc__item--level-' + heading.level;
var link = document.createElement('a');
link.className = 'tiptap-toc__link';
link.href = '#' + heading.id;
if (numbered) {
// Reset lower level counters and increment current
for (var i = heading.level; i < 6; i++) {
counters[i] = 0;
}
counters[heading.level - 1]++;
// Build number string
var numberParts = [];
for (var j = 0; j < heading.level; j++) {
if (counters[j] > 0) {
numberParts.push(counters[j]);
}
}
var numberSpan = document.createElement('span');
numberSpan.className = 'tiptap-toc__number';
numberSpan.textContent = numberParts.join('.') + '.';
link.appendChild(numberSpan);
}
var textSpan = document.createElement('span');
textSpan.className = 'tiptap-toc__text';
textSpan.textContent = heading.text;
link.appendChild(textSpan);
// Click to scroll
link.addEventListener('click', function(e) {
e.preventDefault();
// Find the heading in the document and scroll to it
var editorElement = editor.view.dom;
var headingElements = editorElement.querySelectorAll('h1, h2, h3, h4, h5, h6');
headingElements.forEach(function(el) {
if (slugify(el.textContent) === heading.id.split('-').slice(0, -1).join('-') ||
slugify(el.textContent) === heading.id) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Briefly highlight
el.classList.add('tiptap-toc__highlight');
setTimeout(function() {
el.classList.remove('tiptap-toc__highlight');
}, 2000);
}
});
// Also try to focus the editor at that position
editor.commands.focus();
try {
editor.commands.setTextSelection(heading.pos);
} catch (e) {
// Position might have changed
}
});
item.appendChild(link);
list.appendChild(item);
});
content.innerHTML = '';
content.appendChild(list);
}
// Initial render
renderTOC();
// Settings button handler
settingsBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
settingsPanel.style.display = settingsPanel.style.display === 'none' ? 'block' : 'none';
});
// Depth change handler
depthSelect.addEventListener('change', function() {
if (typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(pos, undefined, {
maxDepth: parseInt(depthSelect.value, 10),
numbered: numberedCheck.checked,
});
return true;
}).run();
}
});
// Numbered change handler
numberedCheck.addEventListener('change', function() {
if (typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.setNodeMarkup(pos, undefined, {
maxDepth: parseInt(depthSelect.value, 10),
numbered: numberedCheck.checked,
});
return true;
}).run();
}
});
// Delete button handler
deleteBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (typeof getPos === 'function') {
var pos = getPos();
editor.chain().focus().command(function(cmdProps) {
cmdProps.tr.delete(pos, pos + node.nodeSize);
return true;
}).run();
}
});
return {
dom: container,
update: function(updatedNode) {
if (updatedNode.type.name !== 'tableOfContents') {
return false;
}
node = updatedNode;
depthSelect.value = node.attrs.maxDepth;
numberedCheck.checked = node.attrs.numbered;
renderTOC();
return true;
},
stopEvent: function(event) {
return event.target === settingsBtn ||
event.target === deleteBtn ||
event.target === depthSelect ||
event.target === numberedCheck ||
settingsPanel.contains(event.target) ||
event.target.closest('.tiptap-toc__link');
},
};
};
},
addCommands: function() {
var self = this;
return {
setTableOfContents: function(options) {
options = options || {};
return function(props) {
return props.commands.insertContent({
type: self.name,
attrs: {
maxDepth: options.maxDepth || 3,
numbered: options.numbered || false,
},
});
};
},
};
},
addKeyboardShortcuts: function() {
return {
'Mod-Alt-t': function() {
return this.editor.commands.setTableOfContents();
},
};
},
});
/**
* TOC Tracking Extension - Updates TOC when headings change
*
* This is a separate extension that can be used independently
* to track headings for external TOC rendering (e.g., sidebar)
*/
var TOCTracker = Extension.create({
name: 'tocTracker',
addStorage: function() {
return {
headings: [],
};
},
addProseMirrorPlugins: function() {
var extension = this;
return [
new Plugin({
key: tocPluginKey,
view: function(editorView) {
// Initial extraction
var headings = extractHeadings(editorView.state.doc);
extension.storage.headings = headings;
// Emit initial event
editorView.dom.dispatchEvent(new CustomEvent('toc:update', {
detail: { headings: headings },
bubbles: true,
}));
return {
update: function(view, prevState) {
// Only update if document changed
if (view.state.doc.eq(prevState.doc)) {
return;
}
var newHeadings = extractHeadings(view.state.doc);
// Check if headings actually changed
var changed = newHeadings.length !== extension.storage.headings.length ||
newHeadings.some(function(h, i) {
var old = extension.storage.headings[i];
return !old || h.text !== old.text || h.level !== old.level;
});
if (changed) {
extension.storage.headings = newHeadings;
view.dom.dispatchEvent(new CustomEvent('toc:update', {
detail: { headings: newHeadings },
bubbles: true,
}));
}
},
};
},
}),
];
},
});
/**
* Create the Table of Contents extension bundle
*/
function createTableOfContentsExtension(options) {
options = options || {};
var extensions = [TableOfContents];
// Optionally include the tracker for external TOC rendering
if (options.enableTracker !== false) {
extensions.push(TOCTracker);
}
return extensions;
}
/**
* Get current headings from the editor
*/
function getHeadings(editor) {
return extractHeadings(editor.state.doc);
}
module.exports = {
createTableOfContentsExtension: createTableOfContentsExtension,
TableOfContents: TableOfContents,
TOCTracker: TOCTracker,
getHeadings: getHeadings,
extractHeadings: extractHeadings,
slugify: slugify,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,951 @@
/**
* Tiptap Editor Module for Leantime
*
* Main entry point that exports the tiptapController
* and all editor functionality.
*
* @module tiptap
*/
// Use require for Node/Webpack compatibility
const { Editor } = require('@tiptap/core');
const StarterKit = require('@tiptap/starter-kit').default;
const Placeholder = require('@tiptap/extension-placeholder').default;
const Link = require('@tiptap/extension-link').default;
const Image = require('@tiptap/extension-image').default;
const { createResizableImage } = require('./extensions/imageResize');
const TaskList = require('@tiptap/extension-task-list').default;
const TaskItem = require('@tiptap/extension-task-item').default;
const Table = require('@tiptap/extension-table').default;
const TableRow = require('@tiptap/extension-table-row').default;
const TableCell = require('@tiptap/extension-table-cell').default;
const TableHeader = require('@tiptap/extension-table-header').default;
const Highlight = require('@tiptap/extension-highlight').default;
const Underline = require('@tiptap/extension-underline').default;
const Typography = require('@tiptap/extension-typography').default;
const Superscript = require('@tiptap/extension-superscript').default;
const Subscript = require('@tiptap/extension-subscript').default;
const CharacterCount = require('@tiptap/extension-character-count').default;
const TextAlign = require('@tiptap/extension-text-align').default;
const TextStyle = require('@tiptap/extension-text-style').default;
const Color = require('@tiptap/extension-color').default;
const FontFamily = require('@tiptap/extension-font-family').default;
const FontSize = require('tiptap-extension-font-size').default;
const { createMentionExtension } = require('./extensions/mention');
const { createSlashCommandsExtension } = require('./extensions/slashCommands');
const { EmbedNode, showEmbedDialog } = require('./extensions/embed');
const { createMermaidExtension, showMermaidDialog } = require('./extensions/mermaid');
const { createMathExtension, showMathDialog, loadKaTeX } = require('./extensions/math');
const { createDetailsExtension } = require('./extensions/details');
const { createEmojiExtension, showEmojiPickerDialog } = require('./extensions/emoji');
const { createTableOfContentsExtension } = require('./extensions/tableOfContents');
const { createColumnsExtension } = require('./extensions/columns');
/**
* EditorRegistry - Manages Tiptap editor instances
*/
var EditorRegistry = (function() {
// Private storage using closure
var instances = new WeakMap();
var elementIds = new Map();
var elements = new Set();
return {
register: function(element, editor) {
if (instances.has(element)) {
this.destroy(element);
}
instances.set(element, editor);
elements.add(element);
if (element.id) {
elementIds.set(element.id, element);
}
element.setAttribute('data-tiptap-editor', 'true');
},
get: function(elementOrId) {
var element = elementOrId;
if (typeof elementOrId === 'string') {
element = elementIds.get(elementOrId) || document.getElementById(elementOrId);
}
if (!element) return null;
return instances.get(element) || null;
},
has: function(element) {
return instances.has(element);
},
destroy: function(element) {
var editor = instances.get(element);
if (!editor) return false;
try {
// Only sync content back to textarea if the editor element is
// still attached to the document. When nyroModal replaces modal
// content, the old editor DOM is removed before destroy() runs.
// Using document.getElementById() at that point would find a
// NEW textarea with the same id (e.g. the subtask's
// "ticketDescription") and overwrite it with the parent's
// content — causing #3263.
if (document.contains(element)) {
var textarea = this.findTextarea(element);
if (textarea) {
textarea.value = editor.getHTML();
}
}
editor.destroy();
} catch (e) {
console.warn('[TiptapRegistry] Error destroying editor:', e);
}
instances.delete(element);
elements.delete(element);
if (element.id) {
elementIds.delete(element.id);
}
element.removeAttribute('data-tiptap-editor');
return true;
},
destroyWithin: function(container) {
var editors = container.querySelectorAll('[data-tiptap-editor]');
var count = 0;
var self = this;
editors.forEach(function(element) {
if (self.destroy(element)) {
count++;
}
});
if (container.hasAttribute && container.hasAttribute('data-tiptap-editor')) {
if (this.destroy(container)) {
count++;
}
}
return count;
},
destroyAll: function() {
var count = 0;
var elementsArray = Array.from(elements);
var self = this;
elementsArray.forEach(function(element) {
if (self.destroy(element)) {
count++;
}
});
return count;
},
getAll: function() {
var result = [];
elements.forEach(function(element) {
var editor = instances.get(element);
if (editor) {
result.push({ element: element, editor: editor });
}
});
return result;
},
get count() {
return elements.size;
},
findTextarea: function(element) {
if (element.tagName === 'TEXTAREA') {
return element;
}
var textareaId = element.getAttribute('data-textarea-id');
if (textareaId) {
return document.getElementById(textareaId);
}
var sibling = element.previousElementSibling || element.nextElementSibling;
if (sibling && sibling.tagName === 'TEXTAREA') {
return sibling;
}
var parent = element.parentElement;
if (parent) {
var textarea = parent.querySelector('textarea');
if (textarea) {
return textarea;
}
}
return null;
}
};
})();
/**
* Default editor options
*/
var defaultOptions = {
placeholder: "Type '/' for commands or start writing...",
autosave: false,
autosaveKey: null,
autosaveInterval: 30000,
uploadUrl: '/api/files',
toolbar: null, // 'complex', 'simple', 'notes', or false to disable
onUpdate: null,
onBlur: null,
onFocus: null,
onCreate: null,
};
/**
* Create a Tiptap editor instance
*/
function createTiptapEditor(elementOrSelector, options) {
options = Object.assign({}, defaultOptions, options || {});
var element;
if (typeof elementOrSelector === 'string') {
element = document.querySelector(elementOrSelector);
} else {
element = elementOrSelector;
}
if (!element) {
console.error('[TiptapEditor] Element not found:', elementOrSelector);
return null;
}
var textarea = null;
// Check if element is a textarea
if (element.tagName === 'TEXTAREA') {
textarea = element;
// Check if wrapper already exists (editor being re-initialized)
var existingWrapper = textarea.closest('.tiptap-wrapper');
if (!existingWrapper && textarea.nextElementSibling && textarea.nextElementSibling.classList.contains('tiptap-wrapper')) {
existingWrapper = textarea.nextElementSibling;
}
var wrapper, editorEl;
if (existingWrapper) {
// Reuse existing wrapper, but clean up old toolbars and editor element
wrapper = existingWrapper;
// Remove any existing toolbars
var oldToolbars = wrapper.querySelectorAll('.tiptap-toolbar');
oldToolbars.forEach(function(tb) { tb.remove(); });
// Remove old editor element if exists
var oldEditorEl = wrapper.querySelector('.tiptap-editor');
if (oldEditorEl) {
// Destroy any existing editor instance
EditorRegistry.destroy(oldEditorEl);
oldEditorEl.remove();
}
// Create new editor container
editorEl = document.createElement('div');
editorEl.className = 'tiptap-editor';
if (options.toolbar && typeof options.toolbar === 'string') {
editorEl.classList.add('tiptap-' + options.toolbar);
}
if (textarea.id) {
editorEl.setAttribute('data-textarea-id', textarea.id);
}
wrapper.appendChild(editorEl);
} else {
// Create new wrapper
wrapper = document.createElement('div');
wrapper.className = 'tiptap-wrapper';
// Create editor container
editorEl = document.createElement('div');
editorEl.className = 'tiptap-editor';
if (options.toolbar && typeof options.toolbar === 'string') {
editorEl.classList.add('tiptap-' + options.toolbar);
}
if (textarea.id) {
editorEl.setAttribute('data-textarea-id', textarea.id);
}
// Hide textarea but keep for form submission
textarea.style.display = 'none';
// Insert wrapper after textarea
textarea.parentNode.insertBefore(wrapper, textarea.nextSibling);
wrapper.appendChild(textarea);
wrapper.appendChild(editorEl);
}
element = editorEl;
} else {
// Look for textarea in parent
var parent = element.parentElement;
if (parent) {
textarea = parent.querySelector('textarea');
}
// Also check for and remove existing toolbars in the wrapper
var wrapper = element.closest('.tiptap-wrapper');
if (wrapper) {
var oldToolbars = wrapper.querySelectorAll('.tiptap-toolbar');
oldToolbars.forEach(function(tb) { tb.remove(); });
}
}
// Get initial content
var initialContent = textarea ? textarea.value : (element.innerHTML || '');
// Build extensions
var extensions = [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4],
},
}),
Placeholder.configure({
placeholder: options.placeholder,
emptyEditorClass: 'is-editor-empty',
}),
Link.configure({
openOnClick: 'whenNotEditable',
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
// Allow Ctrl/Cmd+click to open links while editing
validate: function(url) {
return /^https?:\/\//.test(url) || /^mailto:/.test(url);
},
}),
createResizableImage(Image).configure({
inline: false,
allowBase64: false,
}),
TaskList,
TaskItem.configure({
nested: true,
}),
Highlight.configure({
multicolor: true,
}),
Underline,
Typography,
Superscript,
Subscript,
TextStyle,
Color,
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
CharacterCount.configure({
limit: null, // No limit by default
}),
FontFamily,
FontSize,
];
// Add Phase 6 advanced extensions if enabled (enabled by default for complex editors)
if (options.advancedExtensions !== false) {
// Mermaid diagrams
extensions.push(createMermaidExtension());
// LaTeX/Math (inline and block)
extensions.push.apply(extensions, createMathExtension());
// Details/Collapsible sections
extensions.push.apply(extensions, createDetailsExtension());
// Emoji picker
extensions.push(createEmojiExtension());
// Table of Contents
extensions.push.apply(extensions, createTableOfContentsExtension());
// Column layouts
extensions.push.apply(extensions, createColumnsExtension());
}
// Add mention extension if enabled (enabled by default)
if (options.mentions !== false) {
extensions.push(createMentionExtension());
}
// Add slash commands extension if enabled (enabled by default for complex/notes editors)
if (options.slashCommands !== false) {
extensions.push(createSlashCommandsExtension());
}
// Add embed extension for video embeds
if (options.embeds !== false) {
extensions.push(EmbedNode);
}
// Add table extensions if needed
if (options.tables !== false) {
extensions.push(
Table.configure({
resizable: true,
}),
TableRow,
TableCell,
TableHeader
);
}
// Create editor
var editor = new Editor({
element: element,
extensions: extensions,
content: initialContent,
autofocus: false,
editable: true,
injectCSS: false,
onCreate: function(params) {
element.setAttribute('data-tiptap-editor', 'true');
if (options.onCreate) {
options.onCreate(params);
}
},
onUpdate: function(params) {
// Sync to textarea
if (textarea) {
textarea.value = params.editor.getHTML();
}
if (options.onUpdate) {
options.onUpdate(params);
}
},
onBlur: function(params) {
// Sync to textarea on blur
if (textarea) {
textarea.value = params.editor.getHTML();
}
if (options.onBlur) {
options.onBlur(params);
}
},
onFocus: function(params) {
if (options.onFocus) {
options.onFocus(params);
}
},
});
// Register with registry
EditorRegistry.register(element, editor);
// Verify editor is properly created and ensure it's interactive
var proseMirrorEl = element.querySelector('.ProseMirror');
if (proseMirrorEl) {
// Ensure contenteditable is set
if (proseMirrorEl.getAttribute('contenteditable') !== 'true') {
proseMirrorEl.setAttribute('contenteditable', 'true');
}
// Ensure it's focusable
if (!proseMirrorEl.getAttribute('tabindex')) {
proseMirrorEl.setAttribute('tabindex', '0');
}
// Force pointer-events via inline style as fallback
proseMirrorEl.style.pointerEvents = 'auto';
proseMirrorEl.style.cursor = 'text';
}
// Create toolbar if configured.
// The toolbar module is loaded as a separate <script> (compiled-tiptap-toolbar).
// When content is injected via HTMX, the editor init can fire before that
// script has evaluated. Retry with a short delay to handle this race.
var toolbar = null;
function attachToolbar() {
if (window.leantime && window.leantime.tiptapToolbar) {
toolbar = window.leantime.tiptapToolbar.create(editor, options.toolbar);
window.leantime.tiptapToolbar.attach({ element: element }, toolbar);
}
}
if (options.toolbar) {
if (window.leantime && window.leantime.tiptapToolbar) {
attachToolbar();
} else {
// Toolbar script hasn't loaded yet — poll briefly
var retries = 0;
var toolbarPoll = setInterval(function() {
retries++;
if (window.leantime && window.leantime.tiptapToolbar) {
clearInterval(toolbarPoll);
attachToolbar();
} else if (retries > 20) {
// 2 seconds — give up
clearInterval(toolbarPoll);
console.warn('[TiptapEditor] Toolbar module not available after 2s');
}
}, 100);
}
}
// Store event handler references for cleanup
var handlers = {};
// Click handler to ensure focus works and handle Ctrl/Cmd+click on links
handlers.click = function(e) {
// Check if Ctrl/Cmd+click on a link
if ((e.ctrlKey || e.metaKey) && e.target.tagName === 'A' && e.target.href) {
e.preventDefault();
window.open(e.target.href, '_blank', 'noopener,noreferrer');
return;
}
// Also check if clicking on an element inside a link
var linkEl = e.target.closest('a[href]');
if ((e.ctrlKey || e.metaKey) && linkEl && linkEl.href) {
e.preventDefault();
window.open(linkEl.href, '_blank', 'noopener,noreferrer');
return;
}
if (!editor.isFocused) {
editor.commands.focus();
}
};
element.addEventListener('click', handlers.click);
// Image upload helper function
// Scope ID lookups to the editor's closest form/modal to support stacked modals
function uploadImage(file, callback) {
// Get module info from the page context
var moduleId = '';
var module = 'ticket';
// Scope the ID lookup to the editor's form or modal container
var scope = element.closest('form') || element.closest('.nyroModalCont') || document;
// Try to get ticket ID from scoped context first, then fall back to document
var ticketIdInput = scope.querySelector('input[name="id"], input[name="itemId"], input[name="ticketId"]')
|| document.querySelector('input[name="id"], input[name="itemId"], input[name="ticketId"]');
if (ticketIdInput && ticketIdInput.value) {
moduleId = ticketIdInput.value;
}
// Check if we're in a wiki/doc context
if (window.location.href.indexOf('/wiki/') > -1 || window.location.href.indexOf('/docs/') > -1) {
module = 'wiki';
var wikiIdInput = scope.querySelector('input[name="id"]') || document.querySelector('input[name="id"]');
if (wikiIdInput) moduleId = wikiIdInput.value;
}
// Check project context
if (window.location.href.indexOf('/projects/') > -1) {
module = 'project';
}
// Fallback to current project
if (!moduleId && window.leantime && window.leantime.currentProject) {
moduleId = window.leantime.currentProject;
module = 'project';
}
var formData = new FormData();
formData.append('file', file);
var uploadUrl = leantime.appUrl + '/api/files';
if (module && moduleId) {
uploadUrl += '?module=' + module + '&moduleId=' + moduleId;
}
fetch(uploadUrl, {
method: 'POST',
body: formData,
credentials: 'include',
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(function(response) {
if (!response.ok) throw new Error('Upload failed');
return response.json();
})
.then(function(data) {
var imageUrl = leantime.appUrl + '/files/get?module=' + encodeURIComponent(data.module) +
'&encName=' + encodeURIComponent(data.encName) +
'&ext=' + encodeURIComponent(data.extension) +
'&realName=' + encodeURIComponent(data.realName);
callback(null, imageUrl, data.realName);
})
.catch(function(err) {
console.error('Image upload failed:', err);
callback(err);
});
}
// Handle paste events for images
handlers.paste = function(e) {
var items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (var i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') !== -1) {
e.preventDefault();
var file = items[i].getAsFile();
if (file) {
uploadImage(file, function(err, url, name) {
if (!err && url) {
editor.chain().focus().setImage({ src: url, alt: name || 'Pasted image' }).run();
}
});
}
break;
}
}
};
element.addEventListener('paste', handlers.paste);
// Handle drag and drop for images
handlers.dragover = function(e) {
e.preventDefault();
element.classList.add('tiptap-dragover');
};
element.addEventListener('dragover', handlers.dragover);
handlers.dragleave = function(e) {
e.preventDefault();
element.classList.remove('tiptap-dragover');
};
element.addEventListener('dragleave', handlers.dragleave);
handlers.drop = function(e) {
e.preventDefault();
element.classList.remove('tiptap-dragover');
var files = e.dataTransfer && e.dataTransfer.files;
if (!files || files.length === 0) return;
for (var i = 0; i < files.length; i++) {
var file = files[i];
if (file.type.indexOf('image') !== -1) {
uploadImage(file, function(err, url, name) {
if (!err && url) {
editor.chain().focus().setImage({ src: url, alt: name || 'Dropped image' }).run();
}
});
}
}
};
element.addEventListener('drop', handlers.drop);
// Return wrapper object with useful methods
return {
editor: editor,
element: element,
textarea: textarea,
toolbar: toolbar,
getHTML: function() { return editor.getHTML(); },
getText: function() { return editor.getText(); },
getJSON: function() { return editor.getJSON(); },
setContent: function(content) { editor.commands.setContent(content); },
insertContent: function(content) { editor.commands.insertContent(content); },
focus: function(position) { editor.commands.focus(position || 'end'); },
blur: function() { editor.commands.blur(); },
isEmpty: function() { return editor.isEmpty; },
isEditable: function() { return editor.isEditable; },
setEditable: function(editable) { editor.setEditable(editable); },
destroy: function() {
// Remove event listeners to prevent memory leaks
if (handlers.click) element.removeEventListener('click', handlers.click);
if (handlers.paste) element.removeEventListener('paste', handlers.paste);
if (handlers.dragover) element.removeEventListener('dragover', handlers.dragover);
if (handlers.dragleave) element.removeEventListener('dragleave', handlers.dragleave);
if (handlers.drop) element.removeEventListener('drop', handlers.drop);
if (toolbar) {
toolbar.destroy();
}
if (textarea) {
textarea.value = editor.getHTML();
}
editor.destroy();
EditorRegistry.destroy(element);
}
};
}
/**
* Initialize editors by selector
*/
function initEditorsBySelector(selector, options) {
var editors = [];
var textareas = document.querySelectorAll(selector);
textareas.forEach(function(textarea) {
if (textarea.getAttribute('data-tiptap-initialized') === 'true') {
return;
}
var editor = createTiptapEditor(textarea, options);
if (editor) {
textarea.setAttribute('data-tiptap-initialized', 'true');
editors.push(editor);
}
});
return editors;
}
/**
* Setup HTMX lifecycle hooks
*/
function setupHtmxHooks() {
// Clean up editors before HTMX replaces content
document.body.addEventListener('htmx:beforeSwap', function(event) {
var target = event.detail.target;
if (!target) return;
EditorRegistry.destroyWithin(target);
});
// Initialize new editors after HTMX swaps content
document.body.addEventListener('htmx:afterSwap', function(event) {
var target = event.detail.target;
if (!target) return;
setTimeout(function() {
if (window.leantime && window.leantime.tiptapController) {
window.leantime.tiptapController.initEditors(target);
}
}, 50);
});
// Sync editor content before form submission
document.body.addEventListener('htmx:beforeRequest', function(event) {
var element = event.detail.elt;
if (!element) return;
var form = element.closest('form') || element;
var editors = form.querySelectorAll('[data-tiptap-editor]');
editors.forEach(function(editorEl) {
var editor = EditorRegistry.get(editorEl);
if (editor) {
var textareaId = editorEl.getAttribute('data-textarea-id');
var textarea = textareaId ? document.getElementById(textareaId) : null;
if (textarea) {
textarea.value = editor.getHTML();
}
}
});
});
}
/**
* Extension registry for plugins
*/
var extensionRegistry = new Map();
var slashCommandRegistry = new Map();
var toolbarButtonRegistry = new Map();
/**
* Tiptap Controller - Main interface for managing editors
*/
var tiptapController = {
registry: EditorRegistry,
initComplex: function(elementOrSelector, options) {
// Scope the ID lookup to the element's closest form or modal container
// to prevent stacked modals from picking up the parent ticket's ID
var entityId = (options && options.entityId) || 'new';
if (entityId === 'new') {
var el = (typeof elementOrSelector === 'string')
? document.querySelector(elementOrSelector) : elementOrSelector;
if (el) {
var scope = el.closest('form') || el.closest('.nyroModalCont') || document;
var idInput = scope.querySelector('input[name="id"]');
if (idInput) {
entityId = idInput.value;
}
}
}
var projectId = (options && options.projectId) || (window.leantime && window.leantime.projectId) || '';
var path = window.location.pathname;
var mergedOptions = Object.assign({
placeholder: "Start writing your description...\nType '/' for commands",
autosave: true,
autosaveKey: 'leantime-tiptap-complex-' + path + '-' + projectId + '-' + entityId,
tables: true,
toolbar: 'complex',
}, options || {});
return createTiptapEditor(elementOrSelector, mergedOptions);
},
initSimple: function(elementOrSelector, options) {
var formId = (options && options.formId) || 'comment';
var path = window.location.pathname;
var mergedOptions = Object.assign({
placeholder: 'Write a comment...',
autosave: true,
autosaveKey: 'leantime-tiptap-simple-' + path + '-' + formId,
tables: false,
toolbar: 'simple',
slashCommands: false, // Disable slash commands for simple editors
}, options || {});
return createTiptapEditor(elementOrSelector, mergedOptions);
},
initNotes: function(elementOrSelector, options) {
var noteId = (options && options.noteId) ||
(document.querySelector('input[name="id"]') ? document.querySelector('input[name="id"]').value : 'new');
var notebookId = (options && options.notebookId) ||
(document.querySelector('input[name="canvasId"]') ? document.querySelector('input[name="canvasId"]').value : '');
var mergedOptions = Object.assign({
placeholder: "Start writing your note...\nType '/' for commands",
autosave: true,
autosaveKey: 'leantime-tiptap-notes-' + notebookId + '-' + noteId,
tables: true,
toolbar: 'notes',
}, options || {});
return createTiptapEditor(elementOrSelector, mergedOptions);
},
initInline: function(elementOrSelector, options) {
var mergedOptions = Object.assign({
placeholder: 'Click to edit...',
autosave: false,
tables: false,
toolbar: false,
}, options || {});
return createTiptapEditor(elementOrSelector, mergedOptions);
},
initEditors: function(container) {
container = container || document;
var editors = [];
// Initialize complex editors
container.querySelectorAll('textarea.tiptapComplex').forEach(function(textarea) {
if (textarea.getAttribute('data-tiptap-initialized') !== 'true') {
var editor = tiptapController.initComplex(textarea);
if (editor) {
textarea.setAttribute('data-tiptap-initialized', 'true');
editors.push(editor);
}
}
});
// Initialize simple editors
container.querySelectorAll('textarea.tiptapSimple').forEach(function(textarea) {
if (textarea.getAttribute('data-tiptap-initialized') !== 'true') {
var editor = tiptapController.initSimple(textarea);
if (editor) {
textarea.setAttribute('data-tiptap-initialized', 'true');
editors.push(editor);
}
}
});
// Initialize notes editors
container.querySelectorAll('textarea.tiptapNotes').forEach(function(textarea) {
if (textarea.getAttribute('data-tiptap-initialized') !== 'true') {
var editor = tiptapController.initNotes(textarea);
if (editor) {
textarea.setAttribute('data-tiptap-initialized', 'true');
editors.push(editor);
}
}
});
return editors;
},
// Backwards compatibility methods
initComplexEditor: function() {
return initEditorsBySelector('textarea.tiptapComplex', {
placeholder: "Start writing your description...",
tables: true,
toolbar: 'complex',
});
},
initSimpleEditor: function(callback) {
var editors = initEditorsBySelector('textarea.tiptapSimple', {
placeholder: 'Write a comment...',
tables: false,
toolbar: 'simple',
});
if (callback && editors.length > 0) {
callback(editors);
}
return editors;
},
initNotesEditor: function(callback) {
var editors = initEditorsBySelector('textarea.tiptapNotes', {
placeholder: "Type '/' for commands or start writing...",
toolbar: 'notes',
tables: true,
onBlur: callback,
});
return editors;
},
getEditor: function(elementOrId) {
return EditorRegistry.get(elementOrId);
},
destroyAll: function() {
return EditorRegistry.destroyAll();
},
registerExtension: function(name, extension) {
extensionRegistry.set(name, extension);
},
registerSlashCommand: function(command, handler) {
slashCommandRegistry.set(command, handler);
},
registerToolbarButton: function(name, config) {
toolbarButtonRegistry.set(name, config);
},
getSlashCommands: function() {
return slashCommandRegistry;
},
getToolbarButtons: function() {
return toolbarButtonRegistry;
},
};
// Make available globally
window.leantime = window.leantime || {};
window.leantime.tiptapController = tiptapController;
window.leantime.editorController = tiptapController;
// Expose Phase 6 extension dialogs for slash commands
window.leantime.tiptapMermaid = { showMermaidDialog: showMermaidDialog };
window.leantime.tiptapMath = { showMathDialog: showMathDialog, loadKaTeX: loadKaTeX };
window.leantime.tiptapEmoji = { showEmojiPickerDialog: showEmojiPickerDialog };
window.leantime.tiptapEmbed = { showDialog: showEmbedDialog };
// Auto-initialize HTMX hooks when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupHtmxHooks);
} else {
setupHtmxHooks();
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
tiptapController: tiptapController,
EditorRegistry: EditorRegistry,
createTiptapEditor: createTiptapEditor,
};
}

View File

@@ -0,0 +1,299 @@
/**
* Tiptap Editor Test Utilities
*
* Run these tests in browser console to verify editor functionality.
* Usage: leantime.tiptapTests.runAll()
*
* @module tiptap/test-utils
*/
(function() {
'use strict';
var testResults = [];
function log(message, success) {
var status = success ? '✓' : '✗';
var color = success ? 'color: green' : 'color: red';
console.log('%c' + status + ' ' + message, color);
testResults.push({ message: message, success: success });
}
function assert(condition, message) {
log(message, condition);
return condition;
}
/**
* Test 1: Registry exists and is functional
*/
function testRegistry() {
console.log('\n--- Testing EditorRegistry ---');
var registry = window.leantime?.tiptapController?.registry;
assert(registry !== undefined, 'Registry exists on tiptapController');
assert(typeof registry.register === 'function', 'Registry has register method');
assert(typeof registry.get === 'function', 'Registry has get method');
assert(typeof registry.destroy === 'function', 'Registry has destroy method');
assert(typeof registry.destroyAll === 'function', 'Registry has destroyAll method');
assert(typeof registry.destroyWithin === 'function', 'Registry has destroyWithin method');
}
/**
* Test 2: Controller exists and has required methods
*/
function testController() {
console.log('\n--- Testing TiptapController ---');
var controller = window.leantime?.tiptapController;
assert(controller !== undefined, 'tiptapController exists on window.leantime');
assert(typeof controller.initComplex === 'function', 'Controller has initComplex method');
assert(typeof controller.initSimple === 'function', 'Controller has initSimple method');
assert(typeof controller.initNotes === 'function', 'Controller has initNotes method');
assert(typeof controller.initInline === 'function', 'Controller has initInline method');
assert(typeof controller.initEditors === 'function', 'Controller has initEditors method');
assert(typeof controller.getEditor === 'function', 'Controller has getEditor method');
assert(typeof controller.destroyAll === 'function', 'Controller has destroyAll method');
assert(typeof controller.registerExtension === 'function', 'Controller has registerExtension method');
}
/**
* Test 3: Create and destroy an editor
*/
function testEditorLifecycle() {
console.log('\n--- Testing Editor Lifecycle ---');
// Create a test textarea
var container = document.createElement('div');
container.id = 'tiptap-test-container';
container.innerHTML = '<textarea id="tiptap-test-textarea" class="tiptapComplex">Initial content</textarea>';
document.body.appendChild(container);
var controller = window.leantime.tiptapController;
var textarea = document.getElementById('tiptap-test-textarea');
// Initialize editor
var editorWrapper = controller.initComplex(textarea);
assert(editorWrapper !== null, 'Editor initialized successfully');
assert(editorWrapper.editor !== null, 'Editor instance exists');
assert(typeof editorWrapper.getHTML === 'function', 'Editor has getHTML method');
// Test content
var content = editorWrapper.getHTML();
assert(content.includes('Initial content'), 'Editor loaded initial content');
// Test setContent
editorWrapper.setContent('<p>New content</p>');
var newContent = editorWrapper.getHTML();
assert(newContent.includes('New content'), 'Editor setContent works');
// Test textarea sync
var textareaValue = textarea.value;
assert(textareaValue.includes('New content'), 'Textarea synced with editor content');
// Test registry tracking
var editorElement = editorWrapper.element;
var registeredEditor = controller.registry.get(editorElement);
assert(registeredEditor !== null, 'Editor registered in registry');
// Test destroy
editorWrapper.destroy();
var afterDestroy = controller.registry.get(editorElement);
assert(afterDestroy === null, 'Editor removed from registry after destroy');
// Cleanup
container.remove();
}
/**
* Test 4: Multiple editors
*/
function testMultipleEditors() {
console.log('\n--- Testing Multiple Editors ---');
var container = document.createElement('div');
container.id = 'tiptap-multi-test';
container.innerHTML = `
<textarea id="test-editor-1" class="tiptapComplex">Editor 1</textarea>
<textarea id="test-editor-2" class="tiptapSimple">Editor 2</textarea>
<textarea id="test-editor-3" class="tiptapNotes">Editor 3</textarea>
`;
document.body.appendChild(container);
var controller = window.leantime.tiptapController;
// Initialize all
var editors = controller.initEditors(container);
assert(editors.length === 3, 'All 3 editors initialized');
// Check registry count
var allEditors = controller.registry.getAll();
assert(allEditors.length === 3, 'Registry tracks all 3 editors');
// Destroy all
var destroyedCount = controller.destroyAll();
assert(destroyedCount === 3, 'All 3 editors destroyed');
// Verify empty
var afterDestroy = controller.registry.getAll();
assert(afterDestroy.length === 0, 'Registry is empty after destroyAll');
// Cleanup
container.remove();
}
/**
* Test 5: Editor formatting commands
*/
function testFormattingCommands() {
console.log('\n--- Testing Formatting Commands ---');
var container = document.createElement('div');
container.id = 'tiptap-format-test';
container.innerHTML = '<textarea id="format-test-textarea" class="tiptapComplex"></textarea>';
document.body.appendChild(container);
var controller = window.leantime.tiptapController;
var editorWrapper = controller.initComplex(document.getElementById('format-test-textarea'));
var editor = editorWrapper.editor;
// Test bold
editor.commands.setContent('<p>test</p>');
editor.commands.selectAll();
editor.commands.toggleBold();
var boldContent = editorWrapper.getHTML();
assert(boldContent.includes('<strong>') || boldContent.includes('font-weight'), 'Bold command works');
// Test heading
editor.commands.setContent('<p>heading test</p>');
editor.commands.selectAll();
editor.commands.toggleHeading({ level: 2 });
var headingContent = editorWrapper.getHTML();
assert(headingContent.includes('<h2>'), 'Heading command works');
// Test bullet list
editor.commands.setContent('<p>list item</p>');
editor.commands.selectAll();
editor.commands.toggleBulletList();
var listContent = editorWrapper.getHTML();
assert(listContent.includes('<ul>') && listContent.includes('<li>'), 'Bullet list command works');
// Cleanup
editorWrapper.destroy();
container.remove();
}
/**
* Test 6: destroyWithin for HTMX simulation
*/
function testDestroyWithin() {
console.log('\n--- Testing destroyWithin (HTMX simulation) ---');
var outerContainer = document.createElement('div');
outerContainer.id = 'htmx-test-outer';
var innerContainer = document.createElement('div');
innerContainer.id = 'htmx-test-inner';
innerContainer.innerHTML = `
<textarea id="htmx-editor-1" class="tiptapComplex">Content 1</textarea>
<textarea id="htmx-editor-2" class="tiptapSimple">Content 2</textarea>
`;
outerContainer.appendChild(innerContainer);
document.body.appendChild(outerContainer);
var controller = window.leantime.tiptapController;
// Initialize editors
controller.initEditors(innerContainer);
var beforeCount = controller.registry.getAll().length;
assert(beforeCount === 2, 'Two editors initialized in inner container');
// Simulate HTMX swap - destroy editors within inner container
var destroyed = controller.registry.destroyWithin(innerContainer);
assert(destroyed === 2, 'destroyWithin destroyed 2 editors');
var afterCount = controller.registry.getAll().length;
assert(afterCount === 0, 'Registry empty after destroyWithin');
// Cleanup
outerContainer.remove();
}
/**
* Test 7: Plugin extension registration
*/
function testExtensionRegistration() {
console.log('\n--- Testing Extension Registration ---');
var controller = window.leantime.tiptapController;
// Register a mock extension
var mockExtension = { name: 'testExtension' };
controller.registerExtension('test', mockExtension);
assert(true, 'Extension registration did not throw error');
// Register a slash command
var mockHandler = function() { return true; };
controller.registerSlashCommand('/test', mockHandler);
assert(true, 'Slash command registration did not throw error');
var commands = controller.getSlashCommands();
assert(commands.has('/test'), 'Slash command was registered');
// Register a toolbar button
controller.registerToolbarButton('testBtn', { icon: 'test', action: function() {} });
var buttons = controller.getToolbarButtons();
assert(buttons.has('testBtn'), 'Toolbar button was registered');
}
/**
* Run all tests
*/
function runAll() {
console.log('=== Tiptap Editor Tests ===\n');
testResults = [];
try {
testRegistry();
testController();
testEditorLifecycle();
testMultipleEditors();
testFormattingCommands();
testDestroyWithin();
testExtensionRegistration();
} catch (e) {
console.error('Test error:', e);
}
// Summary
console.log('\n=== Test Summary ===');
var passed = testResults.filter(function(r) { return r.success; }).length;
var failed = testResults.filter(function(r) { return !r.success; }).length;
console.log('Passed: ' + passed);
console.log('Failed: ' + failed);
console.log('Total: ' + testResults.length);
return {
passed: passed,
failed: failed,
total: testResults.length,
results: testResults
};
}
// Export to global
window.leantime = window.leantime || {};
window.leantime.tiptapTests = {
runAll: runAll,
testRegistry: testRegistry,
testController: testController,
testEditorLifecycle: testEditorLifecycle,
testMultipleEditors: testMultipleEditors,
testFormattingCommands: testFormattingCommands,
testDestroyWithin: testDestroyWithin,
testExtensionRegistration: testExtensionRegistration
};
console.log('[Tiptap] Test utilities loaded. Run leantime.tiptapTests.runAll() to test.');
})();