OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
Some checks failed
Security Audit / Composer Security Audit (push) Has been cancelled
Security Audit / NPM Security Audit (push) Has been cancelled
Security Audit / CodeQL Analysis (push) Has been cancelled

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

187
public/assets/js/app/app.js Normal file
View File

@@ -0,0 +1,187 @@
//Lets get this party started.
var leantime = leantime || {};
var themeColor = jQuery('meta[name=theme-color]').attr("content");
leantime.companyColor = themeColor;
var colorScheme = jQuery('meta[name=color-scheme]').attr("content");
leantime.colorScheme = colorScheme;
var theme = jQuery('meta[name=theme]').attr("content");
leantime.theme = theme;
var appURL = jQuery('meta[name=identifier-URL]').attr("content");
leantime.appUrl = appURL;
var leantimeVersion = jQuery('meta[name=leantime-version]').attr("content");
leantime.version = leantimeVersion;
leantime.replaceSVGColors = function () {
jQuery(document).ready(function () {
if (leantime.companyColor != "#1b75bb") {
jQuery("svg").children().each(function () {
if (jQuery(this).attr("fill") == "#1b75bb") {
jQuery(this).attr("fill", leantime.companyColor);
}
});
}
});
};
leantime.handleAsyncResponse = function (response) {
if (response !== undefined) {
if (response.result !== undefined && response.result.html !== undefined) {
var content = jQuery(response.result.html);
jQuery("body").append(content);
}
}
};
jQuery.noConflict();
// On touch devices, require a brief long-press + a few px of movement before a
// jQuery-UI sortable/draggable starts dragging. Without this the whole card is a
// drag target, so a tap "grabs" it instead of opening it and a swipe drags it
// instead of scrolling. Set on the widget prototypes so it applies to every
// sortable/draggable (dashboard to-dos, kanban cards, ideas cards) without
// touching each init. Touch-only, so mouse drag on desktop is unaffected.
// Fixes #1357 (can't tap cards), #3350 (scrolling moves tasks), #1465 (tablet drag).
leantime.applyTouchDragGuards = function () {
if (!(('ontouchstart' in window || navigator.maxTouchPoints > 0) && jQuery.ui)) {
return;
}
if (jQuery.ui.sortable) {
jQuery.extend(jQuery.ui.sortable.prototype.options, { delay: 200, distance: 8 });
}
if (jQuery.ui.draggable) {
jQuery.extend(jQuery.ui.draggable.prototype.options, { delay: 200, distance: 8 });
}
};
// Initialise Tippy tooltips idempotently. Calling tippy('[data-tippy-content]')
// on every document.ready AND every htmx.onLoad re-instanced ALL tooltips on
// each HTMX swap, piling up dozens of duplicate "Notifications" tooltips (one of
// which stayed visible). Skip elements that already have an instance, and scope
// to the swapped subtree when given one.
leantime.initTooltips = function (root) {
// Hover tooltips are a desktop affordance — useless on touch, and on narrow
// screens the header-icon tooltips flashed/stuck during HTMX page loads
// (a transient pointerover shows them, then the element re-renders so no
// mouseleave ever fires to hide them). So on mobile/touch we don't run them
// at all, and tear down any that already exist.
var isMobile = window.innerWidth < 1200 || ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
if (isMobile) {
document.querySelectorAll('[data-tippy-content]').forEach(function (el) {
if (el._tippy) { el._tippy.destroy(); }
});
return;
}
// Desktop: a short show-delay debounces transient hovers from layout reflow.
if (window.tippy && tippy.setDefaultProps) {
tippy.setDefaultProps({ delay: [300, 0] });
}
var scope = (root && root.querySelectorAll) ? root : document;
scope.querySelectorAll('[data-tippy-content]').forEach(function (el) {
if (!el._tippy) {
tippy(el, { delay: [300, 0] });
}
});
};
jQuery(document).ready(function () {
leantime.applyTouchDragGuards();
leantime.replaceSVGColors();
jQuery(".confetti").click(function () {
confetti.start();
});
leantime.initTooltips();
if (jQuery('.login-alert .alert').text() !== '') {
jQuery('.login-alert').fadeIn();
}
document.addEventListener('scroll', () => {
document.documentElement.dataset.scroll = window.scrollY;
});
});
htmx.onLoad(function(element){
leantime.initTooltips(element);
// Re-assert touch drag guards before HTMX-loaded sortables (e.g. the
// dashboard to-do list) initialise.
if (leantime.applyTouchDragGuards) {
leantime.applyTouchDragGuards();
}
});
// --- Singleton HTMX progress bar -------------------------------------------------
// A single fixed top-of-page bar driven by an in-flight request COUNTER. Concurrent
// requests (e.g. the dashboard's parallel widget loads) keep it visible until ALL of
// them settle — it does not serialize requests. It is position:fixed, so it has zero
// layout impact, and replaces page-wide `.htmx-indicator` toggling as the general
// "something is loading" affordance.
leantime.htmxProgress = (function () {
var inFlight = 0;
var bar = null;
var hideTimer = null;
function ensureBar() {
if (bar) { return bar; }
bar = document.getElementById('lt-htmx-progress');
if (!bar) {
bar = document.createElement('div');
bar.id = 'lt-htmx-progress';
bar.setAttribute('aria-hidden', 'true');
bar.innerHTML = '<div class="bar"></div>';
(document.body || document.documentElement).appendChild(bar);
}
return bar;
}
return {
start: function () {
inFlight++;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
ensureBar().classList.add('active');
},
done: function () {
inFlight = Math.max(0, inFlight - 1);
if (inFlight === 0) {
// Small delay smooths over back-to-back requests so the bar doesn't strobe.
hideTimer = setTimeout(function () {
if (inFlight === 0 && bar) { bar.classList.remove('active'); }
}, 150);
}
}
};
})();
// htmx request events bubble to document, so a single document-level listener covers
// every request regardless of which element triggered it.
document.addEventListener('htmx:beforeRequest', function () { leantime.htmxProgress.start(); });
document.addEventListener('htmx:afterRequest', function () { leantime.htmxProgress.done(); });
leantime.showNotification = function (evt) {
jQuery.get(leantime.appUrl+"/notifications/getLatestGrowl", function(data){
let notification = JSON.parse(data);
if(notification.notification && notification.notification !== "undefined") {
jQuery.growl({
message: notification.notification, style: notification.notificationType
});
}
})
};
window.addEventListener("lt:ui:notify", leantime.showNotification);
window.addEventListener("HTMX.ShowNotification", leantime.showNotification);

View File

@@ -0,0 +1,70 @@
leantime.authController = (function () {
//Variables
var closeModal = false;
//Constructor
(function () {
jQuery(document).ready(
function () {
_initializeEventHandlers();
}
);
})();
//Functions
function showNewTokenModal() {
jQuery('#newTokenModal').modal('show');
}
function createToken() {
var name = jQuery('#tokenName').val();
jQuery.ajax({
url: leantime.appUrl + '/advancedAuth/personalTokens/create',
type: 'POST',
data: {
name: name
},
success: function(response) {
jQuery('#newTokenModal').modal('hide');
jQuery('#tokenName').val('');
leantime.notification.show('Token created successfully. Make sure to copy your token now, you won\'t be able to see it again!');
// Show token in a modal
leantime.snippets.copyToClipboard(response.token);
// Reload the tokens table
window.location.reload();
},
error: function(xhr) {
leantime.notification.show(xhr.responseJSON.error, 'error');
}
});
}
function deleteToken(id) {
if (!confirm('Are you sure you want to delete this token?')) {
return;
}
jQuery.ajax({
url: leantime.appUrl + '/advancedAuth/personalTokens/delete/' + id,
type: 'DELETE',
success: function() {
leantime.notification.show('Token deleted successfully');
window.location.reload();
},
error: function() {
leantime.notification.show('Error deleting token', 'error');
}
});
}
return {
showNewTokenModal: showNewTokenModal,
createToken: createToken,
deleteToken: deleteToken
};
})();

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.');
})();

View File

@@ -0,0 +1,925 @@
{
"type": {
"scale": {
"100": {
"value": 11.237299919128418,
"type": "number",
"prefix": "constant"
},
"200": {
"value": 12.640000343322754,
"type": "number",
"prefix": "constant"
},
"300": {
"value": 14.220000267028809,
"type": "number",
"prefix": "constant"
},
"400": {
"value": 16,
"type": "number",
"prefix": "constant"
},
"500": {
"value": 18,
"type": "number",
"prefix": "constant"
},
"600": {
"value": 20.25,
"type": "number",
"prefix": "constant"
},
"700": {
"value": 22.780000686645508,
"type": "number",
"prefix": "constant"
},
"800": {
"value": 25.6299991607666,
"type": "number",
"prefix": "constant"
},
"900": {
"value": 28.829999923706055,
"type": "number",
"prefix": "constant"
},
"1000": {
"value": 32.439998626708984,
"type": "number",
"prefix": "constant"
},
"1100": {
"value": 36.4900016784668,
"type": "number",
"prefix": "constant"
},
"1200": {
"value": 41.04999923706055,
"type": "number",
"prefix": "constant"
},
"1300": {
"value": 46.18000030517578,
"type": "number",
"prefix": "constant"
},
"1400": {
"value": 51.959999084472656,
"type": "number",
"prefix": "constant"
},
"1500": {
"value": 58.45199966430664,
"type": "number",
"prefix": "constant"
},
"1600": {
"value": 65.75800323486328,
"type": "number",
"prefix": "constant"
},
"1700": {
"value": 73.97799682617188,
"type": "number",
"prefix": "constant"
},
"1800": {
"value": 83.2249984741211,
"type": "number",
"prefix": "constant"
},
"1900": {
"value": 93.62799835205078,
"type": "number",
"prefix": "constant"
},
"2000": {
"value": 105.33200073242188,
"type": "number",
"prefix": "constant"
}
}
},
"flow": {
"space": {
"0": {
"value": 0,
"type": "number",
"prefix": "constant"
},
"1": {
"value": 2,
"type": "number",
"prefix": "constant"
},
"2": {
"value": 4,
"type": "number",
"prefix": "constant"
},
"3": {
"value": 8,
"type": "number",
"prefix": "constant"
},
"4": {
"value": 12,
"type": "number",
"prefix": "constant"
},
"5": {
"value": 16,
"type": "number",
"prefix": "constant"
},
"6": {
"value": 24,
"type": "number",
"prefix": "constant"
},
"7": {
"value": 32,
"type": "number",
"prefix": "constant"
},
"8": {
"value": 40,
"type": "number",
"prefix": "constant"
},
"9": {
"value": 48,
"type": "number",
"prefix": "constant"
},
"10": {
"value": 56,
"type": "number",
"prefix": "constant"
},
"11": {
"value": 64,
"type": "number",
"prefix": "constant"
},
"12": {
"value": 72,
"type": "number",
"prefix": "constant"
},
"13": {
"value": 80,
"type": "number",
"prefix": "constant"
},
"14": {
"value": 96,
"type": "number",
"prefix": "constant"
},
"15": {
"value": 120,
"type": "number",
"prefix": "constant"
},
"20": {
"value": 160,
"type": "number",
"prefix": "constant"
}
}
},
"palette": {
"purple": {
"10": {
"value": "#e6ccec",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#cd99d8",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#b366c5",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#9a33b1",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#81009e",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#6a0381",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#540665",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#3d0948",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#260c2c",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#1b0e1e",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#f2e5f5",
"type": "color",
"prefix": "constant"
}
},
"pink": {
"10": {
"value": "#efd0e5",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#dfa1cb",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#ce72b1",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#be4397",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#ae147d",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#8e1367",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#6f1251",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#4f113b",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#2f1025",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#1f101a",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#f7e7f2",
"type": "color",
"prefix": "constant"
}
},
"cobalt": {
"10": {
"value": "#cedbed",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#9db7da",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#6c92c8",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#3b6eb5",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#0a4aa3",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#0b3e85",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#0c3368",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#0d274a",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#0e1b2d",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#0f151e",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#e6edf6",
"type": "color",
"prefix": "constant"
}
},
"blue": {
"10": {
"value": "#cce3ee",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#99c7dc",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#66abcb",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#338fb9",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#0073a8",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#035f89",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#064b6b",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#09374c",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#0c232e",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#0e191f",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#e5f1f6",
"type": "color",
"prefix": "constant"
}
},
"teal": {
"10": {
"value": "#cceee7",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#99dccf",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#66cbb7",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#33b99f",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#00a887",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#03896f",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#066b57",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#094c3f",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#0c2e27",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#0e1f1b",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#e5f6f3",
"type": "color",
"prefix": "constant"
}
},
"orange": {
"10": {
"value": "#f0d9cd",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#e1b39b",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#d18e69",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#c26837",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#b34205",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#923807",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#722e09",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#51240b",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#30190d",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#18120f",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#f7ece6",
"type": "color",
"prefix": "constant"
}
},
"red": {
"10": {
"value": "#eecece",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#dd9d9d",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#cc6b6b",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#bb3a3a",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#aa0909",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#8b0a0a",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#6c0c0c",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#4d0d0d",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#2e0e0e",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#1f0f0f",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#f6e6e6",
"type": "color",
"prefix": "constant"
}
},
"yellow": {
"10": {
"value": "#fceccf",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#fada9e",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#f7c76e",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#f5b53d",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#f2a20d",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#c5850d",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#97670e",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#6a4a0e",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#3d2d0f",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#261e0f",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#fef6e7",
"type": "color",
"prefix": "constant"
}
},
"green": {
"10": {
"value": "#cfe8d7",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#9fd2af",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#70bb88",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#3fa45f",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#108e38",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#107530",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#105b28",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#104220",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#0f2917",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#0f1c13",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#e7f4eb",
"type": "color",
"prefix": "constant"
}
},
"white": {
"value": "#ffffff",
"type": "color",
"prefix": "constant"
},
"black": {
"value": "#080808",
"type": "color",
"prefix": "constant"
},
"transparent": {
"value": "#ffffff00",
"type": "color",
"prefix": "constant"
},
"shade": {
"10": {
"value": "#0f0f0f1a",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#0f0f0f33",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#0f0f0f4d",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#0f0f0f66",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#0f0f0f80",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#0f0f0f99",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#0f0f0fb2",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#0f0f0fcc",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#0f0f0fe5",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#0f0f0ff2",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#0f0f0f0d",
"type": "color",
"prefix": "constant"
}
},
"tint": {
"10": {
"value": "#ffffff1a",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#ffffff33",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#ffffff4d",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#ffffff66",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#ffffff80",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#ffffff99",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#ffffffb2",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#ffffffcc",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#ffffffe5",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#fffffff2",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#ffffff0d",
"type": "color",
"prefix": "constant"
}
},
"neutral": {
"10": {
"value": "#e7e7e7",
"type": "color",
"prefix": "constant"
},
"20": {
"value": "#cfcfcf",
"type": "color",
"prefix": "constant"
},
"30": {
"value": "#b7b7b7",
"type": "color",
"prefix": "constant"
},
"40": {
"value": "#9f9f9f",
"type": "color",
"prefix": "constant"
},
"50": {
"value": "#878787",
"type": "color",
"prefix": "constant"
},
"60": {
"value": "#6f6f6f",
"type": "color",
"prefix": "constant"
},
"70": {
"value": "#575757",
"type": "color",
"prefix": "constant"
},
"80": {
"value": "#3f3f3f",
"type": "color",
"prefix": "constant"
},
"90": {
"value": "#272727",
"type": "color",
"prefix": "constant"
},
"95": {
"value": "#1b1b1b",
"type": "color",
"prefix": "constant"
},
"05": {
"value": "#f3f3f3",
"type": "color",
"prefix": "constant"
}
}
},
"block": {
"radius": {
"small": {
"value": "{flow.space.2}",
"type": "number",
"prefix": "constant"
},
"default": {
"value": "{flow.space.3}",
"type": "number",
"prefix": "constant"
},
"large": {
"value": "{flow.space.5}",
"type": "number",
"prefix": "constant"
},
"pill": {
"value": "{flow.space.10}",
"type": "number",
"prefix": "constant"
},
"round": {
"value": "{flow.space.20}",
"type": "number",
"prefix": "constant"
}
}
}
}

View File

@@ -0,0 +1,3 @@
require('htmx.org/dist/ext/head-support');
require('htmx.org/dist/ext/preload');
require('htmx.org/dist/ext/sse');

View File

@@ -0,0 +1,9 @@
window.htmx = require('htmx.org');
// Global view transitions are intentionally OFF. With no hx-boost and no per-element
// view-transition-name scoping, htmx.config.globalViewTransitions=true wrapped EVERY partial
// swap in document.startViewTransition(), which snapshots and cross-fades the whole viewport —
// so the dashboard's ~22 widget swaps each repainted the entire page (heavy flicker, including
// the hovered element under the cursor). Opt in per swap instead, e.g.
// hx-swap="innerHTML transition:true" plus a scoped `view-transition-name` in CSS.
window.htmx.config.globalViewTransitions = false;

View File

@@ -0,0 +1,251 @@
import { createUniver } from '@univerjs/presets';
import { UniverSheetsCorePreset } from '@univerjs/preset-sheets-core';
import { UniverSheetsDrawingPreset } from '@univerjs/preset-sheets-drawing';
import { LocaleType, ImageSourceType } from '@univerjs/core';
import zhCN from '@univerjs/preset-sheets-core/locales/zh-CN';
// Univer 表格 UI 需要显式引入 CSS(preset 的 es 入口只 import 逻辑,不 import 样式)
import '@univerjs/design/lib/index.css';
import '@univerjs/ui/lib/index.css';
import '@univerjs/docs-ui/lib/index.css';
import '@univerjs/sheets-ui/lib/index.css';
import '@univerjs/sheets-formula-ui/lib/index.css';
import '@univerjs/sheets-numfmt-ui/lib/index.css';
// drawing(浮动图片/贴图)相关 CSS
import '@univerjs/drawing-ui/lib/index.css';
import '@univerjs/sheets-drawing-ui/lib/index.css';
import '@univerjs/preset-sheets-drawing/lib/index.css';
const DEFAULT_COL_WIDTH = 110;
/**
* 根据列中文标签推断合理列宽,避免所有列一样宽。
*/
function guessColWidth(label) {
label = (label || '').toString();
if (label.includes('序号')) return 56;
if (label.includes('备注') || label.includes('描述')) return 180;
if (label.includes('名称') || label.includes('图号') || label.includes('材质')) return 150;
if (label.includes('规格') || label.includes('工序') || label.includes('编号')) return 120;
if (label.includes('用量') || label.includes('数量') || label.includes('单位')) return 80;
return DEFAULT_COL_WIDTH;
}
/**
* OneBot BOM 明细表 —— Univer 表格引擎入口。
* 走 ESM 入口(icons 包的 cjs 构建 require 路径坏掉,esm 构建正常)。
*
* 数据布局:第 0 行是表头,第 1..n 行是数据。
* 返回 { univer, univerAPI, sheet, workbook, endEditing, getActiveCell, insertImage,
* insertImageAt, fitToContainer },供页面做编辑/保存/附件/自适应等操作。
*/
function createOneBotSheet(opts) {
const {
container,
data, // 原始数据行数组(已含 _id 和所有列 key)
columns, // 全部列 key 数组(fixed + dynamic)
fixedLabels, // { key: 中文标签 }
imageColumns, // 图片列 key 数组(可选)
fileColumns, // 附件列 key 数组(可选)
onCellChange, // (row, col) => void (row 是 0-based 数据行索引,即 sheet 行号-1)
onCellClick, // (row, col) => void (点击单元格,row 是 0-based sheet 行号)
} = opts;
const imageColSet = new Set(imageColumns || []);
const fileColSet = new Set(fileColumns || []);
// 图片列宽一些,附件列次之
const colIndexByKey = {};
columns.forEach(function (key, c) { colIndexByKey[key] = c; });
const { univer, univerAPI } = createUniver({
locale: LocaleType.ZH_CN,
locales: { [LocaleType.ZH_CN]: zhCN },
presets: [
UniverSheetsCorePreset({
container,
}),
UniverSheetsDrawingPreset(),
],
});
// Univer 0.25 不会自动创建 workbook。传空对象 {} 让 Workbook 构造走 getEmptySnapshot() 分支
let workbook = univerAPI.getActiveWorkbook();
if (!workbook) {
workbook = univerAPI.createWorkbook({});
}
// 空 workbook 没有 sheet,手动插入一个
let sheet = workbook.getActiveSheet();
if (!sheet) {
sheet = workbook.insertSheet('Sheet1');
}
// 用 setColumnCount/setRowCount 设置行列数;空表也预置若干空数据行,保证可编辑可保存
sheet.setColumnCount(columns.length + 1);
sheet.setRowCount(Math.max(data.length, 8) + 2);
// 表头(第 0 行)
columns.forEach(function (key, c) {
const label = fixedLabels[key] || key;
sheet.getRange(0, c).setValue(label);
});
// 列宽:按列类型分配,图片列更宽
columns.forEach(function (key, c) {
let w = guessColWidth(fixedLabels[key] || key);
if (imageColSet.has(key)) w = 170;
else if (fileColSet.has(key)) w = 140;
sheet.setColumnWidth(c, w);
});
// 末尾多余列保持默认窄列
sheet.setColumnWidth(columns.length, DEFAULT_COL_WIDTH);
// 数据行(第 1 行起)
data.forEach(function (rowData, r) {
columns.forEach(function (key, c) {
const v = rowData[key];
sheet.getRange(r + 1, c).setValue(v === undefined || v === null ? '' : v);
});
});
// 监听值变化(排除表头第 0 行)。包 try-catch 防止事件 API 异常中断整个初始化
try {
univerAPI.addEvent(univerAPI.Event.SheetValueChanged, function (params) {
if (params && params.effectedRanges) {
params.effectedRanges.forEach(function (rng) {
const row = rng.getRow();
const col = rng.getColumn();
if (row > 0 && typeof onCellChange === 'function') {
let val = null;
try { val = rng.getValue(); } catch (e) {}
// 传 (dataRowIndex, colIndex, newValue)
onCellChange(row - 1, col, val);
}
});
}
});
} catch (e) {
// 事件监听失败不致命,保存时走全量对比兜底
}
// 单元格点击事件(供附件 PDF 弹窗预览/下载等场景使用)
if (typeof onCellClick === 'function') {
try {
univerAPI.addEvent(univerAPI.Event.CellClicked, function (params) {
if (params && typeof params.row === 'number' && typeof params.column === 'number') {
onCellClick(params.row, params.column);
}
});
} catch (e) {}
}
// 读取图片原始尺寸(URL 或 base64),失败返回 null
function loadImageSize(source) {
return new Promise(function (resolve) {
var img = new Image();
img.onload = function () { resolve({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 }); };
img.onerror = function () { resolve(null); };
img.src = source;
});
}
// 按 URL 源插入浮动图片到指定 cell 位置。尺寸自适应:宽度跟随列宽(默认 max(colW-8, 160)),
// 高度按原图比例计算(读不到原图尺寸时按 4:3 兜底)。opts 可覆盖 width/height。
async function insertImageAt(row, col, source, sourceType, offsetX, opts) {
opts = opts || {};
offsetX = offsetX || 0;
let colW = DEFAULT_COL_WIDTH;
try { colW = sheet.getColumnWidth(col) || DEFAULT_COL_WIDTH; } catch (e) {}
const width = opts.width || Math.min(Math.max(colW - 8, 60), 160);
let height = opts.height || 0;
if (!height) {
const sz = await loadImageSize(source);
height = sz ? Math.round(width * sz.h / sz.w) : Math.round(width * 0.75);
}
try {
sheet.setRowHeight(row, Math.max(sheet.getRowHeight(row), height + 6));
} catch (e) {}
const image = await sheet.newOverGridImage()
.setSource(source, sourceType || ImageSourceType.URL)
.setColumn(col)
.setRow(row)
.setColumnOffset(4 + offsetX)
.setRowOffset(4)
.setWidth(width)
.setHeight(height)
.buildAsync();
sheet.insertImages([image]);
return image;
}
// 渲染图片列:解析 JSON [{url,name,image}] 并插入浮动缩略图(异步,不阻塞主流程)
if (imageColumns && imageColumns.length) {
(async function renderImages() {
for (let c = 0; c < columns.length; c++) {
const key = columns[c];
if (!imageColSet.has(key)) continue;
for (let r = 0; r < data.length; r++) {
const v = data[r][key];
if (typeof v !== 'string' || v === '' || v[0] !== '[') continue;
let arr = null;
try { arr = JSON.parse(v); } catch (e) { continue; }
if (!Array.isArray(arr)) continue;
let n = 0;
for (const a of arr) {
if (a && a.url) {
try {
await insertImageAt(r + 1, c, a.url, ImageSourceType.URL, n * 48);
n++;
} catch (e) {}
}
}
}
}
})();
}
const inst = {
univer, univerAPI, sheet, workbook,
endEditing: function () { return workbook.endEditing(true); },
// 获取当前选中单元格(无选中时返回 null)
getActiveCell: function () { return sheet.getActiveCell(); },
// 可靠的行/列数(FWorksheet facade 没有 getRowCount/getColumnCount,只有 getMaxRows/getMaxColumns)
getRowCount: function () {
try { return sheet.getMaxRows(); } catch (e) { return data.length + 2; }
},
getColumnCount: function () {
try { return sheet.getMaxColumns(); } catch (e) { return columns.length + 1; }
},
// 插入浮动图片(base64 或 URL),定位到指定单元格(向后兼容旧签名)
insertImage: function (source, sourceType, row, col) {
return insertImageAt(row, col, source, sourceType, 0);
},
insertImageAt,
// 按容器宽度自适应缩放:让所有列宽之和铺满容器(clamp 到 [0.5, 1.5])
fitToContainer: function () {
try {
const w = container.clientWidth;
if (!w) return;
let total = 0;
const colCount = inst.getColumnCount();
for (let c = 0; c < colCount; c++) {
try { total += sheet.getColumnWidth(c) || DEFAULT_COL_WIDTH; } catch (e) { total += DEFAULT_COL_WIDTH; }
}
if (total <= 0) return;
let zoom = w / total;
zoom = Math.max(0.5, Math.min(1.5, zoom));
sheet.zoom(zoom);
} catch (e) {}
},
// 重置为 100%
resetZoom: function () { try { sheet.zoom(1); } catch (e) {} },
};
window.__onebotInst = inst; // 供调试/自动化测试直接调 API
// 初次自适应(等容器渲染后)
setTimeout(function () { inst.fitToContainer(); }, 60);
return inst;
}
window.createOneBotSheet = createOneBotSheet;

View File

@@ -0,0 +1 @@
<html><body></body></html>

View File

@@ -0,0 +1,7 @@
/**
* Bootstrap.js by @mdo and @fat, extended by @ArnoldDaniels.
* plugins: bootstrap-fileupload.js
* Copyright 2012 Twitter, Inc.
* http://www.apache.org/licenses/LICENSE-2.0.txt
*/
!function(e){var t=function(t,n){this.$element=e(t),this.type=this.$element.data("uploadtype")||(this.$element.find(".thumbnail").length>0?"image":"file"),this.$input=this.$element.find(":file");if(this.$input.length===0)return;this.name=this.$input.attr("name")||n.name,this.$hidden=this.$element.find('input[type=hidden][name="'+this.name+'"]'),this.$hidden.length===0&&(this.$hidden=e('<input type="hidden" />'),this.$element.prepend(this.$hidden)),this.$preview=this.$element.find(".fileupload-preview");var r=this.$preview.css("height");this.$preview.css("display")!="inline"&&r!="0px"&&r!="none"&&this.$preview.css("line-height",r),this.original={exists:this.$element.hasClass("fileupload-exists"),preview:this.$preview.html(),hiddenVal:this.$hidden.val()},this.$remove=this.$element.find('[data-dismiss="fileupload"]'),this.$element.find('[data-trigger="fileupload"]').on("click.fileupload",e.proxy(this.trigger,this)),this.listen()};t.prototype={listen:function(){this.$input.on("change.fileupload",e.proxy(this.change,this)),e(this.$input[0].form).on("reset.fileupload",e.proxy(this.reset,this)),this.$remove&&this.$remove.on("click.fileupload",e.proxy(this.clear,this))},change:function(e,t){if(t==="clear")return;var n=e.target.files!==undefined?e.target.files[0]:e.target.value?{name:e.target.value.replace(/^.+\\/,"")}:null;if(!n){this.clear();return}this.$hidden.val(""),this.$hidden.attr("name",""),this.$input.attr("name",this.name);if(this.type==="image"&&this.$preview.length>0&&(typeof n.type!="undefined"?n.type.match("image.*"):n.name.match("\\.(gif|png|jpe?g)$"))&&typeof FileReader!="undefined"){var r=new FileReader,i=this.$preview,s=this.$element;r.onload=function(e){i.html('<img src="'+e.target.result+'" '+(i.css("max-height")!="none"?'style="max-height: '+i.css("max-height")+';"':"")+" />"),s.addClass("fileupload-exists").removeClass("fileupload-new")},r.readAsDataURL(n)}else this.$preview.text(n.name),this.$element.addClass("fileupload-exists").removeClass("fileupload-new")},clear:function(e){this.$hidden.val(""),this.$hidden.attr("name",this.name),this.$input.attr("name","");if(navigator.userAgent.match(/msie/i)){var t=this.$input.clone(!0);this.$input.after(t),this.$input.remove(),this.$input=t}else this.$input.val("");this.$preview.html(""),this.$element.addClass("fileupload-new").removeClass("fileupload-exists"),e&&(this.$input.trigger("change",["clear"]),e.preventDefault())},reset:function(e){this.clear(),this.$hidden.val(this.original.hiddenVal),this.$preview.html(this.original.preview),this.original.exists?this.$element.addClass("fileupload-exists").removeClass("fileupload-new"):this.$element.addClass("fileupload-new").removeClass("fileupload-exists")},trigger:function(e){this.$input.trigger("click"),e.preventDefault()}},e.fn.fileupload=function(n){return this.each(function(){var r=e(this),i=r.data("fileupload");i||r.data("fileupload",i=new t(this,n)),typeof n=="string"&&i[n]()})},e.fn.fileupload.Constructor=t,e(document).on("click.fileupload.data-api",'[data-provides="fileupload"]',function(t){var n=e(this);if(n.data("fileupload"))return;n.fileupload(n.data());var r=e(t.target).closest('[data-dismiss="fileupload"],[data-trigger="fileupload"]');r.length>0&&(r.trigger("click.fileupload"),t.preventDefault())})}(window.jQuery)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,12 @@
For more awesome web design code & scripts visit now:
https://www.codehim.com
I just need your one minute, please follow CodeHim.
Follow on Twitter: https://twitter.com/CodeHimOfficial
Follow on Pinterest: https://www.pinterest.com/codehim/
Regards
Asif Mughal.

View File

@@ -0,0 +1,169 @@
//-----------Var Inits--------------
var confetti = (function () {
let canvas = '';
let ctx = '';
let cx = '';
let cy = '';
let confetti = [];
const confettiCount = 50;
const gravity = 1.2;
const terminalVelocity = 5;
const drag = 0.075;
const colors = [
{front: 'red', back: 'darkred'},
{front: 'green', back: 'darkgreen'},
{front: 'blue', back: 'darkblue'},
{front: 'yellow', back: 'darkyellow'},
{front: 'orange', back: 'darkorange'},
{front: 'pink', back: 'darkpink'},
{front: 'purple', back: 'darkpurple'},
{front: 'turquoise', back: 'darkturquoise'}];
var start = function() {
canvas = document.createElement('canvas');
canvas.id = "confetti";
canvas.style.zIndex = 10000;
canvas.style.position = "fixed";
canvas.style.top = 0;
canvas.style.left = 0;
var body = document.getElementsByTagName("body")[0];
body.appendChild(canvas);
canvas = document.getElementById("confetti");
ctx = canvas.getContext("2d");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
cx = ctx.canvas.width / 2;
cy = ctx.canvas.height / 2;
window.addEventListener('resize', function () {
resizeCanvas();
});
initConfetti();
render();
};
//-----------Functions--------------
var resizeCanvas = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
cx = ctx.canvas.width / 2;
cy = ctx.canvas.height / 2;
};
let randomRange = (min, max) => Math.random() * (max - min) + min;
let initConfetti = () => {
for (let i = 0; i < confettiCount; i++) {
confetti.push({
color: colors[Math.floor(randomRange(0, colors.length))],
dimensions: {
x: randomRange(10, 20),
y: randomRange(10, 30) },
position: {
x: randomRange(0, canvas.width),
y: canvas.height - 1 },
rotation: randomRange(0, 2 * Math.PI),
scale: {
x: 1,
y: 1 },
velocity: {
x: randomRange(-25, 25),
y: randomRange(0, -50) }
});
}
};
//---------Render-----------
let render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
confetti.forEach((confetto, index) => {
let width = confetto.dimensions.x * confetto.scale.x;
let height = confetto.dimensions.y * confetto.scale.y;
// Move canvas to position and rotate
ctx.translate(confetto.position.x, confetto.position.y);
ctx.rotate(confetto.rotation);
// Apply forces to velocity
confetto.velocity.x -= confetto.velocity.x * drag;
confetto.velocity.y = Math.min(confetto.velocity.y + gravity, terminalVelocity);
confetto.velocity.x += Math.random() > 0.5 ? Math.random() : -Math.random();
// Set position
confetto.position.x += confetto.velocity.x;
confetto.position.y += confetto.velocity.y;
// Delete confetti when out of frame
if (confetto.position.y >= canvas.height) confetti.splice(index, 1);
// Loop confetto x position
if (confetto.position.x > canvas.width) confetto.position.x = 0;
if (confetto.position.x < 0) confetto.position.x = canvas.width;
// Spin confetto by scaling y
confetto.scale.y = Math.cos(confetto.position.y * 0.1);
ctx.fillStyle = confetto.scale.y > 0 ? confetto.color.front : confetto.color.back;
// Draw confetto
ctx.fillRect(-width / 2, -height / 2, width, height);
// Reset transform matrix
ctx.setTransform(1, 0, 0, 1, 0, 0);
});
// Fire off another round of confetti
if (confetti.length <= 1) {
//initConfetti();
destroy();
return;
}
window.requestAnimationFrame(render);
};
//---------Execution--------
//initConfetti();
//render();
//----------Resize----------
let destroy = function () {
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.remove();
window.cancelAnimationFrame(render);
confetti = [];
};
// Make public what you want to have public, everything else is private
return {
start:start,
initConfetti:initConfetti,
resizeCanvas:resizeCanvas,
render:render
};
})();
//confetti.start();

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 woody180
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,310 @@
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
// Generated by CoffeeScript 2.1.0
(function () {
/*
jQuery Growl
Copyright 2015 Kevin Sylvestre
1.3.5
*/
"use strict";
var $, Animation, Growl;
$ = jQuery;
Animation = function () {
var Animation = function () {
function Animation() {
_classCallCheck(this, Animation);
}
_createClass(Animation, null, [{
key: "transition",
value: function transition($el) {
var el, ref, result, type;
el = $el[0];
ref = this.transitions;
for (type in ref) {
result = ref[type];
if (el.style[type] != null) {
return result;
}
}
}
}]);
return Animation;
}();
;
Animation.transitions = {
"webkitTransition": "webkitTransitionEnd",
"mozTransition": "mozTransitionEnd",
"oTransition": "oTransitionEnd",
"transition": "transitionend"
};
return Animation;
}();
Growl = function () {
var Growl = function () {
_createClass(Growl, null, [{
key: "growl",
value: function growl() {
var settings = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return new Growl(settings);
}
}]);
function Growl() {
var settings = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
_classCallCheck(this, Growl);
this.render = this.render.bind(this);
this.bind = this.bind.bind(this);
this.unbind = this.unbind.bind(this);
this.mouseEnter = this.mouseEnter.bind(this);
this.mouseLeave = this.mouseLeave.bind(this);
this.click = this.click.bind(this);
this.close = this.close.bind(this);
this.cycle = this.cycle.bind(this);
this.waitAndDismiss = this.waitAndDismiss.bind(this);
this.present = this.present.bind(this);
this.dismiss = this.dismiss.bind(this);
this.remove = this.remove.bind(this);
this.animate = this.animate.bind(this);
this.$growls = this.$growls.bind(this);
this.$growl = this.$growl.bind(this);
this.html = this.html.bind(this);
this.content = this.content.bind(this);
this.container = this.container.bind(this);
this.settings = $.extend({}, Growl.settings, settings);
this.initialize(this.settings.location);
this.render();
}
_createClass(Growl, [{
key: "initialize",
value: function initialize(location) {
var id;
id = 'growls-' + location;
return $('body:not(:has(#' + id + '))').append('<div id="' + id + '" />');
}
}, {
key: "render",
value: function render() {
var $growl;
$growl = this.$growl();
this.$growls(this.settings.location).append($growl);
if (this.settings.fixed) {
this.present();
} else {
this.cycle();
}
}
}, {
key: "bind",
value: function bind() {
var $growl = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.$growl();
$growl.on("click", this.click);
if (this.settings.delayOnHover) {
$growl.on("mouseenter", this.mouseEnter);
$growl.on("mouseleave", this.mouseLeave);
}
return $growl.on("contextmenu", this.close).find("." + this.settings.namespace + "-close").on("click", this.close);
}
}, {
key: "unbind",
value: function unbind() {
var $growl = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : this.$growl();
$growl.off("click", this.click);
if (this.settings.delayOnHover) {
$growl.off("mouseenter", this.mouseEnter);
$growl.off("mouseleave", this.mouseLeave);
}
return $growl.off("contextmenu", this.close).find("." + this.settings.namespace + "-close").off("click", this.close);
}
}, {
key: "mouseEnter",
value: function mouseEnter(event) {
var $growl;
$growl = this.$growl();
return $growl.stop(true, true);
}
}, {
key: "mouseLeave",
value: function mouseLeave(event) {
return this.waitAndDismiss();
}
}, {
key: "click",
value: function click(event) {
if (this.settings.url != null) {
event.preventDefault();
event.stopPropagation();
return window.open(this.settings.url);
}
}
}, {
key: "close",
value: function close(event) {
var $growl;
event.preventDefault();
event.stopPropagation();
$growl = this.$growl();
return $growl.stop().queue(this.dismiss).queue(this.remove);
}
}, {
key: "cycle",
value: function cycle() {
var $growl;
$growl = this.$growl();
return $growl.queue(this.present).queue(this.waitAndDismiss());
}
}, {
key: "waitAndDismiss",
value: function waitAndDismiss() {
var $growl;
$growl = this.$growl();
return $growl.delay(this.settings.duration).queue(this.dismiss).queue(this.remove);
}
}, {
key: "present",
value: function present(callback) {
var $growl;
$growl = this.$growl();
this.bind($growl);
return this.animate($growl, this.settings.namespace + "-incoming", 'out', callback);
}
}, {
key: "dismiss",
value: function dismiss(callback) {
var $growl;
$growl = this.$growl();
this.unbind($growl);
return this.animate($growl, this.settings.namespace + "-outgoing", 'in', callback);
}
}, {
key: "remove",
value: function remove(callback) {
this.$growl().remove();
return typeof callback === "function" ? callback() : void 0;
}
}, {
key: "animate",
value: function animate($element, name) {
var direction = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'in';
var callback = arguments[3];
var transition;
transition = Animation.transition($element);
$element[direction === 'in' ? 'removeClass' : 'addClass'](name);
$element.offset().position;
$element[direction === 'in' ? 'addClass' : 'removeClass'](name);
if (callback == null) {
return;
}
if (transition != null) {
$element.one(transition, callback);
} else {
callback();
}
}
}, {
key: "$growls",
value: function $growls(location) {
var base;
if (this.$_growls == null) {
this.$_growls = [];
}
return (base = this.$_growls)[location] != null ? base[location] : base[location] = $('#growls-' + location);
}
}, {
key: "$growl",
value: function $growl() {
return this.$_growl != null ? this.$_growl : this.$_growl = $(this.html());
}
}, {
key: "html",
value: function html() {
return this.container(this.content());
}
}, {
key: "content",
value: function content() {
return "<div class='" + this.settings.namespace + "-close'>" + this.settings.close + "</div>\n<div class='" + this.settings.namespace + "-title'>" + this.settings.title + "</div>\n<div class='" + this.settings.namespace + "-message'>" + this.settings.message + "</div>";
}
}, {
key: "container",
value: function container(content) {
return "<div class='" + this.settings.namespace + " " + this.settings.namespace + "-" + this.settings.style + " " + this.settings.namespace + "-" + this.settings.size + "'>\n " + content + "\n</div>";
}
}]);
return Growl;
}();
Growl.settings = {
namespace: 'growl',
duration: 3200,
close: "&#215;",
location: "default",
style: "success",
size: "medium",
delayOnHover: true
};
return Growl;
}();
this.Growl = Growl;
$.growl = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
return Growl.growl(options);
};
$.growl.error = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var settings;
settings = {
title: "Error!",
style: "error"
};
return $.growl($.extend(settings, options));
};
$.growl.notice = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var settings;
settings = {
title: "Notice!",
style: "notice"
};
return $.growl($.extend(settings, options));
};
$.growl.warning = function () {
var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
var settings;
settings = {
title: "Warning!",
style: "warning"
};
return $.growl($.extend(settings, options));
};
}).call(this);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2016 Andy Roche
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,10 @@
# pomodoro
#### A simple pomodoro timer written in JavaScript. Find it at http://roche.io/pomodoro/
There are plenty of timers like this online ([Moosti](http://moosti.com/), [Tomato Timer](http://tomato-timer.com/)), but the best workflow tool is the one you use. This was a quick side project for me in September 2015 to brush up on my web development and build something I would use myself. It takes the best features from all of the similar timers, and drops the cruft.
#### Features:
- Minutes remaining shown in browser tab
- Favicon change and alarm on timer complete (alarms provided by [AndYouAreWho](http://soundcloud.com/andyouarewho))
- Keyboard shortcuts using Mousetrap.js (hit space to pause/resume)
- Custom time with adjustable units

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,104 @@
body {
margin: auto;
text-align: center;
}
header {
padding-bottom: 10px;
border-bottom: thin solid #777;
font-family: 'Indie Flower', cursive;
}
label {
padding: 0;
margin: 0;
}
h1 {
margin-top: 5px;
margin-bottom: 15px;
padding: 0;
font-family: 'Indie Flower', cursive;
font-size: 2em;
font-weight: bold;
}
footer {
padding: 10px 0px;
border-top: thin solid #777;
}
#title {
font-size: 4em;
font-weight: bold;
}
#subtitle {
font-size: 1.5em;
}
.content {
padding: 20px 0px 40px 0px;
}
#timer {
font-size: 6em;
font-weight: bold;
}
button.btn_default {
padding: 8px 12px;
margin: 2px 0px;
font-weight: bold;
border-radius: 4px;
-webkit-border-radius: 4px;
border: 1px solid;
}
#btn_start {
background-color: #9F9;
border: 1px solid #171;
}
#btn_pause {
background-color: #FF9;
border: 1px solid #771;
}
#btn_reset {
background-color: #F99;
border: 1px solid #711;
}
#ipt_custom {
padding: 8px 12px;
width: 75px;
border-radius: 4px;
-webkit-border-radius: 4px;
border: 1px solid;
text-align: center;
}
#custom_units {
padding: 8px 12px;
border-radius: 4px;
-webkit-border-radius: 4px;
border: 1px solid;
text-align: center;
}
div.footerbox {
max-width: 300px;
margin: 10px 5px;
display: inline-block;
vertical-align: top;
background-color: #EEE;
padding: 8px 12px;
border-radius: 8px;
-webkit-border-radius: 8px;
border: 1px solid;
}
div.footerbox ol {
display: inline-block;
text-align: left;
padding-left: 15px;
}
#options {
text-align: left;
}
#options label {
margin-bottom: 5px;
}
#options select {
width: 100%;
margin-bottom: 20px;
}
.credits {
margin: 2em 0;
color: #999;
}

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title id="tab_title">Pomodoro</title>
<link id="dynamic-favicon" rel="icon" type="image/ico"
href="images/red_tomato.ico">
<!-- Original favicon courtesy of http://icons8.com -->
<link rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css">
<link rel='stylesheet' type='text/css'
href='http://fonts.googleapis.com/css?family=Indie+Flower'>
<link rel="stylesheet" href="pomodoro.css">
</head>
<body>
<div class="container">
<header>
<div id="titlebox">
<div id="title">Pomodoro</div>
<div id="subtitle">A simple workflow tool</div>
</div>
</header>
<div class="content">
<p>
<div><span id="timer"></span></div>
<button id="btn_start" class="btn_default">start</button>
<button id="btn_pause" class="btn_default">pause</button>
<button id="btn_reset" class="btn_default">reset</button>
</p>
<p>
<button id="btn_pomodoro" class="btn_default">pomodoro (25m)</button>
<button id="btn_shortbreak" class="btn_default">short break (5m)</button>
<button id="btn_longbreak" class="btn_default">long break (15m)</button>
</p>
<p>
<button id="btn_custom" class="btn_default">
<label for="ipt_custom">custom:</label>
</button>
<input type="number" id="ipt_custom" value="45" min="0" max="100000000">
<select id="custom_units">
<option value="seconds">seconds</option>
<option value="minutes" selected>minutes</option>
<option value="hours">hours</option>
</select>
</p>
</div>
<footer>
<div class="footerbox">
<h1>How it works:</h1>
<ol>
<li>Choose a task to focus on</li>
<li>Work for 25 uninterrupted minutes</li>
<li>Break for 5 minutes</li>
<li>Every four cycles, take a 15-30 minute<br>
break instead, preferably outside</li>
</ol>
<br>
<a href="https://en.wikipedia.org/wiki/Pomodoro_Technique" target="_blank">
Read more at Wikipedia
</a>
</div>
<div class="footerbox">
<h1>Options:</h1>
<div id="options">
<label for="alarm_select" id="lbl_alarm">Alarm:</label><br>
<select id="alarm_select">
<option value="none">None</option>
<option value="alarm_chime">Chime</option>
<option value="alarm_bell" selected>Bell</option>
<option value="alarm_beeps">Beeps</option>
<option value="alarm_boops">Boops</option>
</select>
<br><br>
<label for="alarm_volume" id="lbl_volume">Volume:</label><br>
<input type="range" id="alarm_volume" min=0 max=100 value=50>
</div>
<br>
</div>
<div class="credits">
Created by
<a href="../index.html">Andy Roche</a> -
<a href="https://github.com/rocheio/rocheio.github.io/tree/master/pomodoro">Github</a>
</div>
</footer>
</div>
<audio id="alarm_chime">
<source src="audio/one_chime.mp3" type="audio/mpeg"></audio>
<audio id="alarm_bell">
<source src="audio/one_bell.mp3" type="audio/mpeg"></audio>
<audio id="alarm_beeps">
<source src="audio/three_beeps.mp3" type="audio/mpeg"></audio>
<audio id="alarm_boops">
<source src="audio/three_boops.mp3" type="audio/mpeg"></audio>
<!-- Audio courtesy of https://soundcloud.com/andyouarewho -->
<script type="text/javascript" src="../resources/mousetrap.js"></script>
<script type="text/javascript" src="pomodoro.js"></script>
</body>
</html>

View File

@@ -0,0 +1,222 @@
/** Represents a timer that can count down. */
function CountdownTimer(seconds, tickRate) {
this.seconds = seconds || (25*60);
this.tickRate = tickRate || 500; // Milliseconds
this.tickFunctions = [];
this.isRunning = false;
this.remaining = this.seconds;
/** CountdownTimer starts ticking down and executes all tick
functions once per tick. */
this.start = function() {
if (this.isRunning) {
return;
}
this.isRunning = true;
// Set variables related to when this timer started
var startTime = Date.now(),
thisTimer = this;
// Tick until complete or interrupted
(function tick() {
secondsSinceStart = ((Date.now() - startTime) / 1000) | 0;
var secondsRemaining = thisTimer.remaining - secondsSinceStart;
// Check if timer has been paused by user
if (thisTimer.isRunning === false) {
thisTimer.remaining = secondsRemaining;
} else {
if (secondsRemaining > 0) {
// Execute another tick in tickRate milliseconds
setTimeout(tick, thisTimer.tickRate);
} else {
// Stop this timer
thisTimer.remaining = 0;
thisTimer.isRunning = false;
// Alert user that time is up
playAlarm();
changeFavicon('green');
}
var timeRemaining = parseSeconds(secondsRemaining);
// Execute each tickFunction in the list with thisTimer
// as an argument
thisTimer.tickFunctions.forEach(
function(tickFunction) {
tickFunction.call(this,
timeRemaining.minutes,
timeRemaining.seconds);
},
thisTimer);
}
}());
};
/** Pause the timer. */
this.pause = function() {
this.isRunning = false;
};
/** Pause the timer and reset to its original time. */
this.reset = function(seconds) {
this.isRunning = false;
this.seconds = seconds
this.remaining = seconds
};
/** Add a function to the timer's tickFunctions. */
this.onTick = function(tickFunction) {
if (typeof tickFunction === 'function') {
this.tickFunctions.push(tickFunction);
}
};
}
/** Return minutes and seconds from seconds. */
function parseSeconds(seconds) {
return {
'minutes': (seconds / 60) | 0,
'seconds': (seconds % 60) | 0
}
}
/** Play the selected alarm at selected volume. */
function playAlarm() {
var alarmValue = document.getElementById('alarm_select').value;
if (alarmValue != 'none') {
var alarmAudio = document.getElementById(alarmValue);
var alarmVolume = document.getElementById('alarm_volume').value;
alarmAudio.volume = alarmVolume / 100;
alarmAudio.play();
}
}
/** Change the color of the favicon. */
function changeFavicon(color) {
document.head = document.head || document.getElementsByTagName('head')[0];
var color = color || 'green';
var newFavicon = document.createElement('link'),
oldFavicon = document.getElementById('dynamic-favicon');
newFavicon.id = 'dynamic-favicon'
newFavicon.type = 'image/ico';
newFavicon.rel = 'icon';
newFavicon.href = 'images/' + color + '_tomato.ico';
if (oldFavicon) {
document.head.removeChild(oldFavicon);
}
document.head.appendChild(newFavicon);
}
/** Window onload functions. */
window.onload = function () {
var timerDisplay = document.getElementById('timer'),
customTimeInput = document.getElementById('ipt_custom'),
timer = new CountdownTimer(),
timeObj = parseSeconds(25*60);
/** Set the time on the main clock display and
set the time remaining section in the title. */
function setTimeOnAllDisplays(minutes, seconds) {
if (minutes >= 60) {
// Add an hours section to all displays
hours = Math.floor(minutes / 60);
minutes = minutes % 60;
clockHours = hours + ':';
document.title = '(' + hours + 'h' + minutes + 'm) Pomodoro';
} else {
clockHours = '';
document.title = '(' + minutes + 'm) Pomodoro';
}
clockMinutes = minutes < 10 ? '0' + minutes : minutes;
clockMinutes += ':';
clockSeconds = seconds < 10 ? '0' + seconds : seconds;
timerDisplay.textContent = clockHours + clockMinutes + clockSeconds;
}
/** Revert the favicon to red, delete the old timer
object, and start a new one. */
function resetMainTimer(seconds) {
changeFavicon('red');
timer.pause();
timer = new CountdownTimer(seconds);
timer.onTick(setTimeOnAllDisplays);
}
// Set default page timer displays
setTimeOnAllDisplays(timeObj.minutes, timeObj.seconds);
timer.onTick(setTimeOnAllDisplays);
// Add listeners for start, pause, etc. buttons
document.getElementById('btn_start').addEventListener(
'click', function () {
timer.start();
});
document.getElementById('btn_pause').addEventListener(
'click', function () {
timer.pause();
});
document.getElementById('btn_reset').addEventListener(
'click', function () {
resetMainTimer(timer.seconds);
timer.start();
});
document.getElementById('btn_pomodoro').addEventListener(
'click', function () {
resetMainTimer(25*60);
timer.start();
});
document.getElementById('btn_shortbreak').addEventListener(
'click', function () {
resetMainTimer(5*60);
timer.start();
});
document.getElementById('btn_longbreak').addEventListener(
'click', function () {
resetMainTimer(15*60);
timer.start();
});
document.getElementById('btn_custom').addEventListener(
'click', function () {
customUnits = document.getElementById('custom_units').value
if (customUnits === 'minutes') {
resetMainTimer(customTimeInput.value*60);
} else if (customUnits === 'hours') {
resetMainTimer(customTimeInput.value*3600);
} else {
resetMainTimer(customTimeInput.value);
}
timer.start();
});
// Bind keyboard shortcut for starting/pausing timer
Mousetrap.bind('space', function(e) {
// Remove default behavior of buttons (page scrolling)
if (e.preventDefault()) {
e.preventDefault();
} else {
e.returnValue = false; //IE
}
// Pause or start the timer
if(timer.isRunning) {
timer.pause();
} else {
timer.start();
}
});
};

View File

@@ -0,0 +1,5 @@
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javascript+bash+c+csharp+cpp+coffeescript+css-extras+docker+go+go-module+graphql+ini+java+javadoc+javadoclike+jsdoc+js-extras+jsstacktrace+js-templates+latex+less+markdown+markup-templating+matlab+mongodb+monkey+nginx+objectivec+php+phpdoc+php-extras+plsql+r+ruby+rust+sass+scss+shell-session+sql+swift+twig+typescript+typoscript+vim+xml-doc+yaml&plugins=line-highlight+line-numbers */
code[class*=language-],pre[class*=language-]{color:#000;background:0 0;text-shadow:0 1px #fff;font-family:Consolas,Monaco,'Andale Mono','Ubuntu Mono',monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}code[class*=language-] ::-moz-selection,code[class*=language-]::-moz-selection,pre[class*=language-] ::-moz-selection,pre[class*=language-]::-moz-selection{text-shadow:none;background:#b3d4fc}code[class*=language-] ::selection,code[class*=language-]::selection,pre[class*=language-] ::selection,pre[class*=language-]::selection{text-shadow:none;background:#b3d4fc}@media print{code[class*=language-],pre[class*=language-]{text-shadow:none}}pre[class*=language-]{padding:1em;margin:.5em 0;overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]{background:#f5f2f0}:not(pre)>code[class*=language-]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#708090}.token.punctuation{color:#999}.token.namespace{opacity:.7}.token.boolean,.token.constant,.token.deleted,.token.number,.token.property,.token.symbol,.token.tag{color:#905}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#690}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url{color:#9a6e3a;background:hsla(0,0%,100%,.5)}.token.atrule,.token.attr-value,.token.keyword{color:#07a}.token.class-name,.token.function{color:#dd4a68}.token.important,.token.regex,.token.variable{color:#e90}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}
pre[data-line]{position:relative;padding:1em 0 1em 3em}.line-highlight{position:absolute;left:0;right:0;padding:inherit 0;margin-top:1em;background:hsla(24,20%,50%,.08);background:linear-gradient(to right,hsla(24,20%,50%,.1) 70%,hsla(24,20%,50%,0));pointer-events:none;line-height:inherit;white-space:pre}@media print{.line-highlight{-webkit-print-color-adjust:exact;color-adjust:exact}}.line-highlight:before,.line-highlight[data-end]:after{content:attr(data-start);position:absolute;top:.4em;left:.6em;min-width:1em;padding:0 .5em;background-color:hsla(24,20%,50%,.4);color:#f4f1ef;font:bold 65%/1.5 sans-serif;text-align:center;vertical-align:.3em;border-radius:999px;text-shadow:none;box-shadow:0 1px #fff}.line-highlight[data-end]:after{content:attr(data-end);top:auto;bottom:.4em}.line-numbers .line-highlight:after,.line-numbers .line-highlight:before{content:none}pre[id].linkable-line-numbers span.line-numbers-rows{pointer-events:all}pre[id].linkable-line-numbers span.line-numbers-rows>span:before{cursor:pointer}pre[id].linkable-line-numbers span.line-numbers-rows>span:hover:before{background-color:rgba(128,128,128,.2)}
pre[class*=language-].line-numbers{position:relative;padding-left:3.8em;counter-reset:linenumber}pre[class*=language-].line-numbers>code{position:relative;white-space:inherit}.line-numbers .line-numbers-rows{position:absolute;pointer-events:none;top:0;font-size:100%;left:-3.8em;width:3em;letter-spacing:-1px;border-right:1px solid #999;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.line-numbers-rows>span{display:block;counter-increment:linenumber}.line-numbers-rows>span:before{content:counter(linenumber);color:#999;display:block;padding-right:.8em;text-align:right}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
Copyright (c) 2010 Rachel Carvalho <rachel.carvalho@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -0,0 +1,43 @@
# simpleColorPicker
A simple color picker jQuery plugin that appears as the user focuses the input.
Check out the latest version at http://github.com/rachel-carvalho/simple-color-picker.
## Usage
Just attach the simpleColorPicker to an input text and when it gains focus the color palette appears aligned to its bottom right corner.
### Samples
See them working live at http://rachel-carvalho.github.com/simple-color-picker.
#### Default options
$(document).ready(function() {
$('input#color').simpleColorPicker();
});
#### More colors per line
$(document).ready(function() {
$('input#color2').simpleColorPicker({ colorsPerLine: 16 });
});
#### Different colors
$(document).ready(function() {
var colors = ['#000000', '#444444', '#666666', '#999999', '#cccccc', '#eeeeee', '#f3f3f3', '#ffffff'];
$('input#color3').simpleColorPicker({ colors: colors });
});
#### Effects
$(document).ready(function() {
$('input#color4').simpleColorPicker({ showEffect: 'fade', hideEffect: 'slide' });
});
#### Non-input elements
$(document).ready(function() {
$('button#color5').simpleColorPicker({ onChangeColor: function(color) { $('label#color-result').text(color); } });
});

View File

@@ -0,0 +1,7 @@
- finish tests
- update jquery support
- stop supporting ie 6
- replace clear: both by .first-in-line
- remove as much css as possible from js
- generate hidden span for color codes (avoid empty lis)
- organize plugin code

View File

@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>simpleColorPicker jQuery plugin</title>
<link type="text/css" href="../../../css/libs/jquery.simple-color-picker.css" rel="stylesheet" />
<style type="text/css">
body { font-family: sans-serif; font-size: 12px; margin: 0px; }
h1 { font-size: 16px; margin: 0px; padding: 5px 5px; }
h2 { font-size: 14px; margin: 0px; padding: 5px 5px; }
h3 { font-size: 12px; margin: 0px; padding: 2px 0px; }
ul { list-style-type: none; padding: 0px; margin: 0px 10px; }
p { margin: 10px 0px; padding: 0px 5px; }
label { display: block; }
input#color { width: 150px; }
pre { font-family: monospace; display: block; margin: 5px; }
</style>
<script type="text/javascript" src="http://code.jquery.com/jquery-1.4.3.min.js"></script>
<script type="text/javascript" src="jquery.simple-color-picker.js"></script>
<script type="text/javascript">
$(document).ready(function() {
$('input#color').simpleColorPicker();
$('input#color2').simpleColorPicker({ colorsPerLine: 16 });
var colors = ['#000000', '#444444', '#666666', '#999999', '#cccccc', '#eeeeee', '#f3f3f3', '#ffffff'];
$('input#color3').simpleColorPicker({ colors: colors });
$('input#color4').simpleColorPicker({ showEffect: 'fade', hideEffect: 'slide' });
$('button#color5').simpleColorPicker({ onChangeColor: function(color) { $('label#color-result').text(color); } });
});
</script>
</head>
<body>
<h1>simpleColorPicker jQuery plugin</h1>
<p>A simple color picker jQuery plugin that appears as the user focuses the input.</p>
<p>Just attach the simpleColorPicker to an input text and when it gains focus the color palette appears aligned to its bottom right corner.</p>
<p>Check out the latest version at <a href="http://github.com/rachel-carvalho/simple-color-picker">http://github.com/rachel-carvalho/simple-color-picker</a>.</p>
<h2>Live samples</h2>
<ul>
<li>
<h3>Default options</h3>
<label for="color">Choose a color:</label>
<input type="text" id="color" name="color" />
<pre>
$(document).ready(function() {
$('input#color').simpleColorPicker();
});
</pre>
</li>
<li>
<h3>More colors per line</h3>
<label for="color2">Choose a color:</label>
<input type="text" id="color2" name="color2" />
<pre>
$(document).ready(function() {
$('input#color2').simpleColorPicker({ colorsPerLine: 16 });
});
</pre>
</li>
<li>
<h3>Different colors</h3>
<label for="color3">Choose a color:</label>
<input type="text" id="color3" name="color3" />
<pre>
$(document).ready(function() {
var colors = ['#000000', '#444444', '#666666', '#999999', '#cccccc', '#eeeeee', '#f3f3f3', '#ffffff'];
$('input#color3').simpleColorPicker({ colors: colors });
});
</pre>
</li>
<li>
<h3>Effects</h3>
<label for="color4">Choose a color:</label>
<input type="text" id="color4" name="color4" />
<pre>
$(document).ready(function() {
$('input#color4').simpleColorPicker({ showEffect: 'fade', hideEffect: 'slide' });
});
</pre>
</li>
<li>
<h3>Non-input elements</h3>
<label id="color-result">No color chosen yet</label>
<button id="color5">Choose a color</button>
<pre>
$(document).ready(function() {
$('button#color5').simpleColorPicker({ onChangeColor: function(color) { $('label#color-result').text(color); } });
});
</pre>
</li>
</ul>
</body>
</html>

View File

@@ -0,0 +1,106 @@
jQuery(function($, undefined) {
$.fn.simpleColorPicker = function(options) {
var defaults = {
colorsPerLine: 8,
colors: ['#000000', '#444444', '#666666', '#999999', '#cccccc', '#eeeeee', '#f3f3f3', '#ffffff'
, '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#9900ff', '#ff00ff'
, '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#cfe2f3', '#d9d2e9', '#ead1dc'
, '#ea9999', '#f9cb9c', '#ffe599', '#b6d7a8', '#a2c4c9', '#9fc5e8', '#b4a7d6', '#d5a6bd'
, '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6fa8dc', '#8e7cc3', '#c27ba0'
, '#cc0000', '#e69138', '#f1c232', '#6aa84f', '#45818e', '#3d85c6', '#674ea7', '#a64d79'
, '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#0b5394', '#351c75', '#741b47'
, '#660000', '#783f04', '#7f6000', '#274e13', '#0c343d', '#073763', '#20124d', '#4C1130'],
showEffect: '',
hideEffect: '',
onChangeColor: false,
includeMargins:false,
};
var opts = $.extend(defaults, options);
return this.each(function() {
var txt = $(this);
var colorsMarkup = '';
var prefix = txt.attr('class').replace(/-/g, '') + '_';
for(var i = 0; i < opts.colors.length; i++){
var item = opts.colors[i];
var breakLine = '';
if (i % opts.colorsPerLine == 0)
breakLine = 'clear: both; ';
if (i > 0 && breakLine && $.browser && $.browser.msie && $.browser.version <= 7) {
breakLine = '';
colorsMarkup += '<li style="float: none; clear: both; overflow: hidden; background-color: #fff; display: block; height: 1px; line-height: 1px; font-size: 1px; margin-bottom: -2px;"></li>';
}
colorsMarkup += '<li id="' + prefix + 'color-' + i + '" class="color-box" style="' + breakLine + 'background-color: ' + item + '" title="' + item + '"></li>';
}
var box = $('<div id="' + prefix + 'color-picker" class="color-picker" style="position: absolute; left: 0px; top: 0px;"><ul>' + colorsMarkup + '</ul><div style="clear: both;"></div></div>');
$('body').append(box);
box.hide();
box.find('li.color-box').click(function() {
if (txt.is('input')) {
txt.val(opts.colors[this.id.substr(this.id.indexOf('-') + 1)]);
txt.blur();
}
if ($.isFunction(defaults.onChangeColor)) {
defaults.onChangeColor.call(txt, opts.colors[this.id.substr(this.id.indexOf('-') + 1)]);
}
hideBox(box);
});
$('body').on('click', function() {
hideBox(box);
});
box.click(function(event) {
event.stopPropagation();
});
var positionAndShowBox = function(box) {
var pos = txt.offset();
var left = pos.left + txt.outerWidth(opts.includeMargins) - box.outerWidth(opts.includeMargins);
if (left < pos.left) left = pos.left;
box.css({ left: left, top: (pos.top + txt.outerHeight(opts.includeMargins)) });
showBox(box);
};
txt.click(function(event) {
event.stopPropagation();
if (!txt.is('input')) {
// element is not an input so probably a link or div which requires the color box to be shown
positionAndShowBox(box);
}
});
txt.focus(function() {
positionAndShowBox(box);
});
function hideBox(box) {
if (opts.hideEffect == 'fade')
box.fadeOut();
else if (opts.hideEffect == 'slide')
box.slideUp();
else
box.hide();
}
function showBox(box) {
if (opts.showEffect == 'fade')
box.fadeIn();
else if (opts.showEffect == 'slide')
box.slideDown();
else
box.show();
}
});
};
});

View File

@@ -0,0 +1,24 @@
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Jasmine Spec Runner v2.0.0-rc5</title>
<link rel="shortcut icon" type="image/png" href="vendor/jasmine-2.0.0-rc5/jasmine_favicon.png">
<link rel="stylesheet" type="text/css" href="vendor/jasmine-2.0.0-rc5/jasmine.css">
<script type="text/javascript" src="vendor/jasmine-2.0.0-rc5/jasmine.js"></script>
<script type="text/javascript" src="vendor/jasmine-2.0.0-rc5/jasmine-html.js"></script>
<script type="text/javascript" src="vendor/jasmine-2.0.0-rc5/boot.js"></script>
<!-- include source files here... -->
<script type="text/javascript" src="http://code.jquery.com/jquery-1.4.3.min.js"></script>
<script type="text/javascript" src="jquery.simple-color-picker.js"></script>
<!-- include spec files here... -->
<script type="text/javascript" src="spec/simple-color-picker.spec.js"></script>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,207 @@
var txt = null;
describe('Simple color picker', function() {
var get_box = function() {
return $('div#' + txt.attr('id').replace(/-/g, '') + '_color-picker');
};
beforeEach(function() {
// add an input id="txt"
txt = $(document.createElement('input'));
txt.attr('id', 'txt');
$('body').append(txt);
});
afterEach(function() {
get_box().remove();
txt.remove();
});
describe('default markup', function() {
beforeEach(function() {
txt.simpleColorPicker();
});
it('appends div#txt_color-picker to the body', function() {
expect(get_box().length).toBe(1);
});
it('which is hidden', function() {
expect(get_box().is(':hidden')).toBeTruthy();
});
it('with a ul inside', function() {
expect(get_box().find('ul').length).toBe(1);
});
it('and 64 li.color-box', function() {
expect(get_box().find('li.color-box').length).toBe(64);
});
it('of which 8 have clear:both (according to colorsPerLine default value)', function() {
expect(get_box().find('li.color-box[style*="clear: both"]').length).toBe(8);
});
it('the first li.color-box has background-color #000000', function() {
expect(get_box().find('li.color-box:first').attr('style').indexOf('background-color: #000000')).toBeGreaterThan(-1);
});
it('and title #000000', function() {
expect(get_box().find('li.color-box:first').attr('title')).toBe('#000000');
});
});
describe('options', function() {
it('16 colors per line makes 4 clear:boths', function() {
txt.simpleColorPicker({ colorsPerLine: 16 });
expect(get_box().find('li.color-box[style*="clear: both"]').length).toBe(4);
});
describe('black and white only', function() {
beforeEach(function(){
txt.simpleColorPicker({ colors: ['#000000', '#ffffff'] });
});
it('makes 2 li.color-box', function() {
expect(get_box().find('li.color-box').length).toBe(2);
});
it('one has background-color: #000000', function() {
expect(get_box().find('li.color-box[style*="background-color: #000000"]').length).toBe(1);
});
it('another has background-color: #ffffff', function() {
expect(get_box().find('li.color-box[style*="background-color: #ffffff"]').length).toBe(1);
});
it('one of which has clear:both', function() {
expect(get_box().find('li.color-box[style*="clear: both"]').length).toBe(1);
});
});
});
var test_jq_method = function(spy, elem) {
expect(spy).toHaveBeenCalled();
expect(spy.calls.count()).toEqual(1);
var recent = spy.calls.mostRecent();
expect(recent).not.toBe(undefined);
var obj = recent ? recent.object[0] : undefined;
expect(obj).toBe(elem[0]);
};
describe('behavior', function() {
var box = null;
describe('default', function() {
beforeEach(function() {
txt.simpleColorPicker();
box = get_box();
});
describe('focusing input', function() {
it('positions color picker aligned to input right', function() {
txt.focus();
expect(box.offset().left + box.outerWidth()).toBe(txt.offset().left + txt.outerWidth());
});
it('or to left, if picker is wider than input', function() {
txt.width(10).focus();
expect(box.offset().left).toBe(txt.offset().left);
});
it('shows color picker', function() {
expect(box.is(':hidden')).toBeTruthy();
txt.focus();
expect(box.is(':hidden')).not.toBeTruthy();
});
});
describe('after picker is open', function() {
beforeEach(function() {
txt.focus();
});
it('clicking outside closes it', function() {
expect(box.is(':hidden')).not.toBeTruthy();
$('body').click();
expect(box.is(':hidden')).toBeTruthy();
});
it('clicking another element closes it', function() {
expect(box.is(':hidden')).not.toBeTruthy();
var btn = $(document.createElement('button'));
$('body').append(btn);
btn.click();
expect(box.is(':hidden')).toBeTruthy();
btn.remove();
});
describe('clicking a color', function() {
var color_li = null;
var jq_hide_spy = null;
beforeEach(function() {
jq_hide_spy = spyOn($.fn, 'hide').and.callThrough();
color_li = box.find('li.color-box:first');
color_li.click();
});
it('fills the input with the right color code', function() {
expect(txt.val()).toBe(color_li.attr('title'));
});
it('hides the picker', function() {
expect(box.is(':hidden')).toBeTruthy();
});
it('by calling $.fn.hide on it', function() {
test_jq_method(jq_hide_spy, box);
});
});
});
});
var trigger_txt = function(opts, just_show) {
txt.simpleColorPicker(opts);
txt.focus();
if (!just_show)
get_box().find('li.color-box:first').click();
};
it('should call onChangeColor when specified', function() {
var opts = { onChangeColor: function(txt, color) {} };
var color_spy = spyOn(opts, 'onChangeColor').and.callThrough();
trigger_txt(opts);
expect(color_spy).toHaveBeenCalled();
});
describe('effects', function() {
it('should show with fade', function() {
var spy = spyOn($.fn, 'fadeIn').and.callThrough();
trigger_txt({showEffect: 'fade'}, true);
test_jq_method(spy, get_box());
});
it('should hide with fade', function() {
var spy = spyOn($.fn, 'fadeOut').and.callThrough();
trigger_txt({hideEffect: 'fade'});
test_jq_method(spy, get_box());
});
it('should show with slide', function() {
var spy = spyOn($.fn, 'slideDown').and.callThrough();
trigger_txt({showEffect: 'slide'}, true);
test_jq_method(spy, get_box());
});
it('should hide with slide', function() {
var spy = spyOn($.fn, 'slideUp').and.callThrough();
trigger_txt({hideEffect: 'slide'});
test_jq_method(spy, get_box());
});
});
});
});

View File

@@ -0,0 +1,181 @@
/**
Starting with version 2.0, this file "boots" Jasmine, performing all of the necessary initialization before executing the loaded environment and all of a project's specs. This file should be loaded after `jasmine.js`, but before any project source files or spec files are loaded. Thus this file can also be used to customize Jasmine for a project.
If a project is using Jasmine via the standalone distribution, this file can be customized directly. If a project is using Jasmine via the [Ruby gem][jasmine-gem], this file can be copied into the support directory via `jasmine copy_boot_js`. Other environments (e.g., Python) will have different mechanisms.
The location of `boot.js` can be specified and/or overridden in `jasmine.yml`.
[jasmine-gem]: http://github.com/pivotal/jasmine-gem
*/
(function() {
/**
* ## Require &amp; Instantiate
*
* Require Jasmine's core files. Specifically, this requires and attaches all of Jasmine's code to the `jasmine` reference.
*/
window.jasmine = jasmineRequire.core(jasmineRequire);
/**
* Since this is being run in a browser and the results should populate to an HTML page, require the HTML-specific Jasmine code, injecting the same reference.
*/
jasmineRequire.html(jasmine);
/**
* Create the Jasmine environment. This is used to run all specs in a project.
*/
var env = jasmine.getEnv();
/**
* ## The Global Interface
*
* Build up the functions that will be exposed as the Jasmine public interface. A project can customize, rename or alias any of these functions as desired, provided the implementation remains unchanged.
*/
var jasmineInterface = {
describe: function(description, specDefinitions) {
return env.describe(description, specDefinitions);
},
xdescribe: function(description, specDefinitions) {
return env.xdescribe(description, specDefinitions);
},
it: function(desc, func) {
return env.it(desc, func);
},
xit: function(desc, func) {
return env.xit(desc, func);
},
beforeEach: function(beforeEachFunction) {
return env.beforeEach(beforeEachFunction);
},
afterEach: function(afterEachFunction) {
return env.afterEach(afterEachFunction);
},
expect: function(actual) {
return env.expect(actual);
},
pending: function() {
return env.pending();
},
spyOn: function(obj, methodName) {
return env.spyOn(obj, methodName);
},
jsApiReporter: new jasmine.JsApiReporter({
timer: new jasmine.Timer()
})
};
/**
* Add all of the Jasmine global/public interface to the proper global, so a project can use the public interface directly. For example, calling `describe` in specs instead of `jasmine.getEnv().describe`.
*/
if (typeof window == "undefined" && typeof exports == "object") {
extend(exports, jasmineInterface);
} else {
extend(window, jasmineInterface);
}
/**
* Expose the interface for adding custom equality testers.
*/
jasmine.addCustomEqualityTester = function(tester) {
env.addCustomEqualityTester(tester);
};
/**
* Expose the interface for adding custom expectation matchers
*/
jasmine.addMatchers = function(matchers) {
return env.addMatchers(matchers);
};
/**
* Expose the mock interface for the JavaScript timeout functions
*/
jasmine.clock = function() {
return env.clock;
};
/**
* ## Runner Parameters
*
* More browser specific code - wrap the query string in an object and to allow for getting/setting parameters from the runner user interface.
*/
var queryString = new jasmine.QueryString({
getWindowLocation: function() { return window.location; }
});
var catchingExceptions = queryString.getParam("catch");
env.catchExceptions(typeof catchingExceptions === "undefined" ? true : catchingExceptions);
/**
* ## Reporters
* The `HtmlReporter` builds all of the HTML UI for the runner page. This reporter paints the dots, stars, and x's for specs, as well as all spec names and all failures (if any).
*/
var htmlReporter = new jasmine.HtmlReporter({
env: env,
onRaiseExceptionsClick: function() { queryString.setParam("catch", !env.catchingExceptions()); },
getContainer: function() { return document.body; },
createElement: function() { return document.createElement.apply(document, arguments); },
createTextNode: function() { return document.createTextNode.apply(document, arguments); },
timer: new jasmine.Timer()
});
/**
* The `jsApiReporter` also receives spec results, and is used by any environment that needs to extract the results from JavaScript.
*/
env.addReporter(jasmineInterface.jsApiReporter);
env.addReporter(htmlReporter);
/**
* Filter which specs will be run by matching the start of the full name against the `spec` query param.
*/
var specFilter = new jasmine.HtmlSpecFilter({
filterString: function() { return queryString.getParam("spec"); }
});
env.specFilter = function(spec) {
return specFilter.matches(spec.getFullName());
};
/**
* Setting up timing functions to be able to be overridden. Certain browsers (Safari, IE 8, phantomjs) require this hack.
*/
window.setTimeout = window.setTimeout;
window.setInterval = window.setInterval;
window.clearTimeout = window.clearTimeout;
window.clearInterval = window.clearInterval;
/**
* ## Execution
*
* Replace the browser window's `onload`, ensure it's called, and then run all of the loaded specs. This includes initializing the `HtmlReporter` instance and then executing the loaded Jasmine environment. All of this will happen after all of the specs are loaded.
*/
var currentWindowOnload = window.onload;
window.onload = function() {
if (currentWindowOnload) {
currentWindowOnload();
}
htmlReporter.initialize();
env.execute();
};
/**
* Helper function for readability above.
*/
function extend(destination, source) {
for (var property in source) destination[property] = source[property];
return destination;
}
}());

View File

@@ -0,0 +1,160 @@
/*
Copyright (c) 2008-2013 Pivotal Labs
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
function getJasmineRequireObj() {
if (typeof module !== "undefined" && module.exports) {
return exports;
} else {
window.jasmineRequire = window.jasmineRequire || {};
return window.jasmineRequire;
}
}
getJasmineRequireObj().console = function(jRequire, j$) {
j$.ConsoleReporter = jRequire.ConsoleReporter();
};
getJasmineRequireObj().ConsoleReporter = function() {
var noopTimer = {
start: function(){},
elapsed: function(){ return 0; }
};
function ConsoleReporter(options) {
var print = options.print,
showColors = options.showColors || false,
onComplete = options.onComplete || function() {},
timer = options.timer || noopTimer,
specCount,
failureCount,
failedSpecs = [],
pendingCount,
ansi = {
green: '\033[32m',
red: '\033[31m',
yellow: '\033[33m',
none: '\033[0m'
};
this.jasmineStarted = function() {
specCount = 0;
failureCount = 0;
pendingCount = 0;
print("Started");
printNewline();
timer.start();
};
this.jasmineDone = function() {
printNewline();
for (var i = 0; i < failedSpecs.length; i++) {
specFailureDetails(failedSpecs[i]);
}
printNewline();
var specCounts = specCount + " " + plural("spec", specCount) + ", " +
failureCount + " " + plural("failure", failureCount);
if (pendingCount) {
specCounts += ", " + pendingCount + " pending " + plural("spec", pendingCount);
}
print(specCounts);
printNewline();
var seconds = timer.elapsed() / 1000;
print("Finished in " + seconds + " " + plural("second", seconds));
printNewline();
onComplete(failureCount === 0);
};
this.specDone = function(result) {
specCount++;
if (result.status == "pending") {
pendingCount++;
print(colored("yellow", "*"));
return;
}
if (result.status == "passed") {
print(colored("green", '.'));
return;
}
if (result.status == "failed") {
failureCount++;
failedSpecs.push(result);
print(colored("red", 'F'));
}
};
return this;
function printNewline() {
print("\n");
}
function colored(color, str) {
return showColors ? (ansi[color] + str + ansi.none) : str;
}
function plural(str, count) {
return count == 1 ? str : str + "s";
}
function repeat(thing, times) {
var arr = [];
for (var i = 0; i < times; i++) {
arr.push(thing);
}
return arr;
}
function indent(str, spaces) {
var lines = (str || '').split("\n");
var newArr = [];
for (var i = 0; i < lines.length; i++) {
newArr.push(repeat(" ", spaces).join("") + lines[i]);
}
return newArr.join("\n");
}
function specFailureDetails(result) {
printNewline();
print(result.fullName);
for (var i = 0; i < result.failedExpectations.length; i++) {
var failedExpectation = result.failedExpectations[i];
printNewline();
print(indent(failedExpectation.stack, 2));
}
printNewline();
}
}
return ConsoleReporter;
};

View File

@@ -0,0 +1,359 @@
/*
Copyright (c) 2008-2013 Pivotal Labs
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
jasmineRequire.html = function(j$) {
j$.ResultsNode = jasmineRequire.ResultsNode();
j$.HtmlReporter = jasmineRequire.HtmlReporter(j$);
j$.QueryString = jasmineRequire.QueryString();
j$.HtmlSpecFilter = jasmineRequire.HtmlSpecFilter();
};
jasmineRequire.HtmlReporter = function(j$) {
var noopTimer = {
start: function() {},
elapsed: function() { return 0; }
};
function HtmlReporter(options) {
var env = options.env || {},
getContainer = options.getContainer,
createElement = options.createElement,
createTextNode = options.createTextNode,
onRaiseExceptionsClick = options.onRaiseExceptionsClick || function() {},
timer = options.timer || noopTimer,
results = [],
specsExecuted = 0,
failureCount = 0,
pendingSpecCount = 0,
htmlReporterMain,
symbols;
this.initialize = function() {
htmlReporterMain = createDom("div", {className: "html-reporter"},
createDom("div", {className: "banner"},
createDom("span", {className: "title"}, "Jasmine"),
createDom("span", {className: "version"}, j$.version)
),
createDom("ul", {className: "symbol-summary"}),
createDom("div", {className: "alert"}),
createDom("div", {className: "results"},
createDom("div", {className: "failures"})
)
);
getContainer().appendChild(htmlReporterMain);
symbols = find(".symbol-summary");
};
var totalSpecsDefined;
this.jasmineStarted = function(options) {
totalSpecsDefined = options.totalSpecsDefined || 0;
timer.start();
};
var summary = createDom("div", {className: "summary"});
var topResults = new j$.ResultsNode({}, "", null),
currentParent = topResults;
this.suiteStarted = function(result) {
currentParent.addChild(result, "suite");
currentParent = currentParent.last();
};
this.suiteDone = function(result) {
if (currentParent == topResults) {
return;
}
currentParent = currentParent.parent;
};
this.specStarted = function(result) {
currentParent.addChild(result, "spec");
};
var failures = [];
this.specDone = function(result) {
if (result.status != "disabled") {
specsExecuted++;
}
symbols.appendChild(createDom("li", {
className: result.status,
id: "spec_" + result.id,
title: result.fullName
}
));
if (result.status == "failed") {
failureCount++;
var failure =
createDom("div", {className: "spec-detail failed"},
createDom("div", {className: "description"},
createDom("a", {title: result.fullName, href: specHref(result)}, result.fullName)
),
createDom("div", {className: "messages"})
);
var messages = failure.childNodes[1];
for (var i = 0; i < result.failedExpectations.length; i++) {
var expectation = result.failedExpectations[i];
messages.appendChild(createDom("div", {className: "result-message"}, expectation.message));
messages.appendChild(createDom("div", {className: "stack-trace"}, expectation.stack));
}
failures.push(failure);
}
if (result.status == "pending") {
pendingSpecCount++;
}
};
this.jasmineDone = function() {
var banner = find(".banner");
banner.appendChild(createDom("span", {className: "duration"}, "finished in " + timer.elapsed() / 1000 + "s"));
var alert = find(".alert");
alert.appendChild(createDom("span", { className: "exceptions" },
createDom("label", { className: "label", 'for': "raise-exceptions" }, "raise exceptions"),
createDom("input", {
className: "raise",
id: "raise-exceptions",
type: "checkbox"
})
));
var checkbox = find("input");
checkbox.checked = !env.catchingExceptions();
checkbox.onclick = onRaiseExceptionsClick;
if (specsExecuted < totalSpecsDefined) {
var skippedMessage = "Ran " + specsExecuted + " of " + totalSpecsDefined + " specs - run all";
alert.appendChild(
createDom("span", {className: "bar skipped"},
createDom("a", {href: "?", title: "Run all specs"}, skippedMessage)
)
);
}
var statusBarMessage = "" + pluralize("spec", specsExecuted) + ", " + pluralize("failure", failureCount);
if (pendingSpecCount) { statusBarMessage += ", " + pluralize("pending spec", pendingSpecCount); }
var statusBarClassName = "bar " + ((failureCount > 0) ? "failed" : "passed");
alert.appendChild(createDom("span", {className: statusBarClassName}, statusBarMessage));
var results = find(".results");
results.appendChild(summary);
summaryList(topResults, summary);
function summaryList(resultsTree, domParent) {
var specListNode;
for (var i = 0; i < resultsTree.children.length; i++) {
var resultNode = resultsTree.children[i];
if (resultNode.type == "suite") {
var suiteListNode = createDom("ul", {className: "suite", id: "suite-" + resultNode.result.id},
createDom("li", {className: "suite-detail"},
createDom("a", {href: specHref(resultNode.result)}, resultNode.result.description)
)
);
summaryList(resultNode, suiteListNode);
domParent.appendChild(suiteListNode);
}
if (resultNode.type == "spec") {
if (domParent.getAttribute("class") != "specs") {
specListNode = createDom("ul", {className: "specs"});
domParent.appendChild(specListNode);
}
specListNode.appendChild(
createDom("li", {
className: resultNode.result.status,
id: "spec-" + resultNode.result.id
},
createDom("a", {href: specHref(resultNode.result)}, resultNode.result.description)
)
);
}
}
}
if (failures.length) {
alert.appendChild(
createDom('span', {className: "menu bar spec-list"},
createDom("span", {}, "Spec List | "),
createDom('a', {className: "failures-menu", href: "#"}, "Failures")));
alert.appendChild(
createDom('span', {className: "menu bar failure-list"},
createDom('a', {className: "spec-list-menu", href: "#"}, "Spec List"),
createDom("span", {}, " | Failures ")));
find(".failures-menu").onclick = function() {
setMenuModeTo('failure-list');
};
find(".spec-list-menu").onclick = function() {
setMenuModeTo('spec-list');
};
setMenuModeTo('failure-list');
var failureNode = find(".failures");
for (var i = 0; i < failures.length; i++) {
failureNode.appendChild(failures[i]);
}
}
};
return this;
function find(selector) {
return getContainer().querySelector(selector);
}
function createDom(type, attrs, childrenVarArgs) {
var el = createElement(type);
for (var i = 2; i < arguments.length; i++) {
var child = arguments[i];
if (typeof child === 'string') {
el.appendChild(createTextNode(child));
} else {
if (child) {
el.appendChild(child);
}
}
}
for (var attr in attrs) {
if (attr == "className") {
el[attr] = attrs[attr];
} else {
el.setAttribute(attr, attrs[attr]);
}
}
return el;
}
function pluralize(singular, count) {
var word = (count == 1 ? singular : singular + "s");
return "" + count + " " + word;
}
function specHref(result) {
return "?spec=" + encodeURIComponent(result.fullName);
}
function setMenuModeTo(mode) {
htmlReporterMain.setAttribute("class", "html-reporter " + mode);
}
}
return HtmlReporter;
};
jasmineRequire.HtmlSpecFilter = function() {
function HtmlSpecFilter(options) {
var filterString = options && options.filterString() && options.filterString().replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
var filterPattern = new RegExp(filterString);
this.matches = function(specName) {
return filterPattern.test(specName);
};
}
return HtmlSpecFilter;
};
jasmineRequire.ResultsNode = function() {
function ResultsNode(result, type, parent) {
this.result = result;
this.type = type;
this.parent = parent;
this.children = [];
this.addChild = function(result, type) {
this.children.push(new ResultsNode(result, type, this));
};
this.last = function() {
return this.children[this.children.length - 1];
};
}
return ResultsNode;
};
jasmineRequire.QueryString = function() {
function QueryString(options) {
this.setParam = function(key, value) {
var paramMap = queryStringToParamMap();
paramMap[key] = value;
options.getWindowLocation().search = toQueryString(paramMap);
};
this.getParam = function(key) {
return queryStringToParamMap()[key];
};
return this;
function toQueryString(paramMap) {
var qStrPairs = [];
for (var prop in paramMap) {
qStrPairs.push(encodeURIComponent(prop) + "=" + encodeURIComponent(paramMap[prop]));
}
return "?" + qStrPairs.join('&');
}
function queryStringToParamMap() {
var paramStr = options.getWindowLocation().search.substring(1),
params = [],
paramMap = {};
if (paramStr.length > 0) {
params = paramStr.split('&');
for (var i = 0; i < params.length; i++) {
var p = params[i].split('=');
var value = decodeURIComponent(p[1]);
if (value === "true" || value === "false") {
value = JSON.parse(value);
}
paramMap[decodeURIComponent(p[0])] = value;
}
}
return paramMap;
}
}
return QueryString;
};

View File

@@ -0,0 +1,55 @@
body { background-color: #eeeeee; padding: 0; margin: 5px; overflow-y: scroll; }
.html-reporter { font-size: 11px; font-family: Monaco, "Lucida Console", monospace; line-height: 14px; color: #333333; }
.html-reporter a { text-decoration: none; }
.html-reporter a:hover { text-decoration: underline; }
.html-reporter p, .html-reporter h1, .html-reporter h2, .html-reporter h3, .html-reporter h4, .html-reporter h5, .html-reporter h6 { margin: 0; line-height: 14px; }
.html-reporter .banner, .html-reporter .symbol-summary, .html-reporter .summary, .html-reporter .result-message, .html-reporter .spec .description, .html-reporter .spec-detail .description, .html-reporter .alert .bar, .html-reporter .stack-trace { padding-left: 9px; padding-right: 9px; }
.html-reporter .banner .version { margin-left: 14px; }
.html-reporter #jasmine_content { position: fixed; right: 100%; }
.html-reporter .version { color: #aaaaaa; }
.html-reporter .banner { margin-top: 14px; }
.html-reporter .duration { color: #aaaaaa; float: right; }
.html-reporter .symbol-summary { overflow: hidden; *zoom: 1; margin: 14px 0; }
.html-reporter .symbol-summary li { display: inline-block; height: 8px; width: 14px; font-size: 16px; }
.html-reporter .symbol-summary li.passed { font-size: 14px; }
.html-reporter .symbol-summary li.passed:before { color: #5e7d00; content: "\02022"; }
.html-reporter .symbol-summary li.failed { line-height: 9px; }
.html-reporter .symbol-summary li.failed:before { color: #b03911; content: "x"; font-weight: bold; margin-left: -1px; }
.html-reporter .symbol-summary li.disabled { font-size: 14px; }
.html-reporter .symbol-summary li.disabled:before { color: #bababa; content: "\02022"; }
.html-reporter .symbol-summary li.pending { line-height: 17px; }
.html-reporter .symbol-summary li.pending:before { color: #ba9d37; content: "*"; }
.html-reporter .exceptions { color: #fff; float: right; margin-top: 5px; margin-right: 5px; }
.html-reporter .bar { line-height: 28px; font-size: 14px; display: block; color: #eee; }
.html-reporter .bar.failed { background-color: #b03911; }
.html-reporter .bar.passed { background-color: #a6b779; }
.html-reporter .bar.skipped { background-color: #bababa; }
.html-reporter .bar.menu { background-color: #fff; color: #aaaaaa; }
.html-reporter .bar.menu a { color: #333333; }
.html-reporter .bar a { color: white; }
.html-reporter.spec-list .bar.menu.failure-list, .html-reporter.spec-list .results .failures { display: none; }
.html-reporter.failure-list .bar.menu.spec-list, .html-reporter.failure-list .summary { display: none; }
.html-reporter .running-alert { background-color: #666666; }
.html-reporter .results { margin-top: 14px; }
.html-reporter.showDetails .summaryMenuItem { font-weight: normal; text-decoration: inherit; }
.html-reporter.showDetails .summaryMenuItem:hover { text-decoration: underline; }
.html-reporter.showDetails .detailsMenuItem { font-weight: bold; text-decoration: underline; }
.html-reporter.showDetails .summary { display: none; }
.html-reporter.showDetails #details { display: block; }
.html-reporter .summaryMenuItem { font-weight: bold; text-decoration: underline; }
.html-reporter .summary { margin-top: 14px; }
.html-reporter .summary ul { list-style-type: none; margin-left: 14px; padding-top: 0; padding-left: 0; }
.html-reporter .summary ul.suite { margin-top: 7px; margin-bottom: 7px; }
.html-reporter .summary li.passed a { color: #5e7d00; }
.html-reporter .summary li.failed a { color: #b03911; }
.html-reporter .summary li.pending a { color: #ba9d37; }
.html-reporter .description + .suite { margin-top: 0; }
.html-reporter .suite { margin-top: 14px; }
.html-reporter .suite a { color: #333333; }
.html-reporter .failures .spec-detail { margin-bottom: 28px; }
.html-reporter .failures .spec-detail .description { background-color: #b03911; }
.html-reporter .failures .spec-detail .description a { color: white; }
.html-reporter .result-message { padding-top: 14px; color: #333333; white-space: pre; }
.html-reporter .result-message span.result { display: block; }
.html-reporter .stack-trace { margin: 5px 0 0 0; max-height: 224px; overflow: auto; line-height: 18px; color: #666666; border: 1px solid #ddd; background: white; white-space: pre; }

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long