OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
187
public/assets/js/app/app.js
Normal file
187
public/assets/js/app/app.js
Normal 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);
|
||||
70
public/assets/js/app/controllers/auth.js
Normal file
70
public/assets/js/app/controllers/auth.js
Normal 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
|
||||
};
|
||||
})();
|
||||
334
public/assets/js/app/core/accessibility.js
Normal file
334
public/assets/js/app/core/accessibility.js
Normal 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();
|
||||
});
|
||||
159
public/assets/js/app/core/dateHelper.js
Normal file
159
public/assets/js/app/core/dateHelper.js
Normal 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
|
||||
};
|
||||
|
||||
})();
|
||||
92
public/assets/js/app/core/datePickers.js
Normal file
92
public/assets/js/app/core/datePickers.js
Normal 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,
|
||||
};
|
||||
|
||||
})();
|
||||
169
public/assets/js/app/core/modals.js
Normal file
169
public/assets/js/app/core/modals.js
Normal 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);
|
||||
|
||||
845
public/assets/js/app/core/nestedSortable.js
Normal file
845
public/assets/js/app/core/nestedSortable.js
Normal 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 ));
|
||||
|
||||
119
public/assets/js/app/core/snippets.js
Normal file
119
public/assets/js/app/core/snippets.js
Normal 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
|
||||
};
|
||||
|
||||
})();
|
||||
0
public/assets/js/app/core/tableHandling.js
Normal file
0
public/assets/js/app/core/tableHandling.js
Normal file
448
public/assets/js/app/core/tiptap/extensions/columns.js
Normal file
448
public/assets/js/app/core/tiptap/extensions/columns.js
Normal 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,
|
||||
};
|
||||
356
public/assets/js/app/core/tiptap/extensions/details.js
Normal file
356
public/assets/js/app/core/tiptap/extensions/details.js
Normal 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,
|
||||
};
|
||||
584
public/assets/js/app/core/tiptap/extensions/embed.js
Normal file
584
public/assets/js/app/core/tiptap/extensions/embed.js
Normal 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">×</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,
|
||||
};
|
||||
825
public/assets/js/app/core/tiptap/extensions/emoji.js
Normal file
825
public/assets/js/app/core/tiptap/extensions/emoji.js
Normal 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">×</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,
|
||||
};
|
||||
159
public/assets/js/app/core/tiptap/extensions/imageResize.js
Normal file
159
public/assets/js/app/core/tiptap/extensions/imageResize.js
Normal 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 };
|
||||
461
public/assets/js/app/core/tiptap/extensions/math.js
Normal file
461
public/assets/js/app/core/tiptap/extensions/math.js
Normal 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">×</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,
|
||||
};
|
||||
318
public/assets/js/app/core/tiptap/extensions/mention.js
Normal file
318
public/assets/js/app/core/tiptap/extensions/mention.js
Normal 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
|
||||
};
|
||||
417
public/assets/js/app/core/tiptap/extensions/mermaid.js
Normal file
417
public/assets/js/app/core/tiptap/extensions/mermaid.js
Normal 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">×</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,
|
||||
};
|
||||
780
public/assets/js/app/core/tiptap/extensions/slashCommands.js
Normal file
780
public/assets/js/app/core/tiptap/extensions/slashCommands.js
Normal 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">×</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
|
||||
};
|
||||
468
public/assets/js/app/core/tiptap/extensions/tableOfContents.js
Normal file
468
public/assets/js/app/core/tiptap/extensions/tableOfContents.js
Normal 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,
|
||||
};
|
||||
1292
public/assets/js/app/core/tiptap/extensions/toolbar.js
Normal file
1292
public/assets/js/app/core/tiptap/extensions/toolbar.js
Normal file
File diff suppressed because it is too large
Load Diff
951
public/assets/js/app/core/tiptap/index.js
Normal file
951
public/assets/js/app/core/tiptap/index.js
Normal 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,
|
||||
};
|
||||
}
|
||||
299
public/assets/js/app/core/tiptap/test-utils.js
Normal file
299
public/assets/js/app/core/tiptap/test-utils.js
Normal 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.');
|
||||
})();
|
||||
925
public/assets/js/app/designtokens.js
Normal file
925
public/assets/js/app/designtokens.js
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
public/assets/js/app/htmx-extensions.js
Normal file
3
public/assets/js/app/htmx-extensions.js
Normal file
@@ -0,0 +1,3 @@
|
||||
require('htmx.org/dist/ext/head-support');
|
||||
require('htmx.org/dist/ext/preload');
|
||||
require('htmx.org/dist/ext/sse');
|
||||
9
public/assets/js/app/htmx.js
Normal file
9
public/assets/js/app/htmx.js
Normal 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;
|
||||
251
public/assets/js/app/onebot-sheet.js
Normal file
251
public/assets/js/app/onebot-sheet.js
Normal 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;
|
||||
1
public/assets/js/index.html
Normal file
1
public/assets/js/index.html
Normal file
@@ -0,0 +1 @@
|
||||
<html><body></body></html>
|
||||
7
public/assets/js/libs/bootstrap-fileupload.min.js
vendored
Normal file
7
public/assets/js/libs/bootstrap-fileupload.min.js
vendored
Normal 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)
|
||||
6
public/assets/js/libs/bootstrap-timepicker.min.js
vendored
Normal file
6
public/assets/js/libs/bootstrap-timepicker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
public/assets/js/libs/bootstrap.min.js
vendored
Normal file
7
public/assets/js/libs/bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
public/assets/js/libs/confetti/info.txt
Normal file
12
public/assets/js/libs/confetti/info.txt
Normal 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.
|
||||
169
public/assets/js/libs/confetti/js/confetti.js
Normal file
169
public/assets/js/libs/confetti/js/confetti.js
Normal 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();
|
||||
21
public/assets/js/libs/emojipicker/LICENSE
Normal file
21
public/assets/js/libs/emojipicker/LICENSE
Normal 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.
|
||||
7953
public/assets/js/libs/emojipicker/vanillaEmojiPicker.js
Normal file
7953
public/assets/js/libs/emojipicker/vanillaEmojiPicker.js
Normal file
File diff suppressed because it is too large
Load Diff
7
public/assets/js/libs/jquery.form.js
Normal file
7
public/assets/js/libs/jquery.form.js
Normal file
File diff suppressed because one or more lines are too long
310
public/assets/js/libs/jquery.growl.js
Normal file
310
public/assets/js/libs/jquery.growl.js
Normal 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: "×",
|
||||
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);
|
||||
1784
public/assets/js/libs/jquery.nyroModal/js/jquery.nyroModal.custom.js
Normal file
1784
public/assets/js/libs/jquery.nyroModal/js/jquery.nyroModal.custom.js
Normal file
File diff suppressed because one or more lines are too long
1
public/assets/js/libs/jquery.tagsinput.min.js
vendored
Normal file
1
public/assets/js/libs/jquery.tagsinput.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
21
public/assets/js/libs/pomodoro/LICENSE
Normal file
21
public/assets/js/libs/pomodoro/LICENSE
Normal 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.
|
||||
10
public/assets/js/libs/pomodoro/README.md
Normal file
10
public/assets/js/libs/pomodoro/README.md
Normal 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
|
||||
BIN
public/assets/js/libs/pomodoro/audio/one_bell.mp3
Normal file
BIN
public/assets/js/libs/pomodoro/audio/one_bell.mp3
Normal file
Binary file not shown.
BIN
public/assets/js/libs/pomodoro/audio/one_chime.mp3
Normal file
BIN
public/assets/js/libs/pomodoro/audio/one_chime.mp3
Normal file
Binary file not shown.
BIN
public/assets/js/libs/pomodoro/audio/three_beeps.mp3
Normal file
BIN
public/assets/js/libs/pomodoro/audio/three_beeps.mp3
Normal file
Binary file not shown.
BIN
public/assets/js/libs/pomodoro/audio/three_boops.mp3
Normal file
BIN
public/assets/js/libs/pomodoro/audio/three_boops.mp3
Normal file
Binary file not shown.
BIN
public/assets/js/libs/pomodoro/images/green_tomato.ico
Normal file
BIN
public/assets/js/libs/pomodoro/images/green_tomato.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
public/assets/js/libs/pomodoro/images/red_tomato.ico
Normal file
BIN
public/assets/js/libs/pomodoro/images/red_tomato.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
104
public/assets/js/libs/pomodoro/pomodoro.css
Normal file
104
public/assets/js/libs/pomodoro/pomodoro.css
Normal 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;
|
||||
}
|
||||
99
public/assets/js/libs/pomodoro/pomodoro.html
Normal file
99
public/assets/js/libs/pomodoro/pomodoro.html
Normal 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>
|
||||
222
public/assets/js/libs/pomodoro/pomodoro.js
Normal file
222
public/assets/js/libs/pomodoro/pomodoro.js
Normal 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();
|
||||
}
|
||||
});
|
||||
};
|
||||
5
public/assets/js/libs/prism/prism.css
Normal file
5
public/assets/js/libs/prism/prism.css
Normal 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}
|
||||
55
public/assets/js/libs/prism/prism.js
Normal file
55
public/assets/js/libs/prism/prism.js
Normal file
File diff suppressed because one or more lines are too long
19
public/assets/js/libs/simple-color-picker-master/LICENSE
Normal file
19
public/assets/js/libs/simple-color-picker-master/LICENSE
Normal 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.
|
||||
43
public/assets/js/libs/simple-color-picker-master/README.md
Normal file
43
public/assets/js/libs/simple-color-picker-master/README.md
Normal 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); } });
|
||||
});
|
||||
7
public/assets/js/libs/simple-color-picker-master/TODO
Normal file
7
public/assets/js/libs/simple-color-picker-master/TODO
Normal 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
|
||||
104
public/assets/js/libs/simple-color-picker-master/example.html
Normal file
104
public/assets/js/libs/simple-color-picker-master/example.html
Normal 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>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
181
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/boot.js
vendored
Normal file
181
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/boot.js
vendored
Normal 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 & 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;
|
||||
}
|
||||
|
||||
}());
|
||||
160
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/console.js
vendored
Normal file
160
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/console.js
vendored
Normal 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;
|
||||
};
|
||||
359
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine-html.js
vendored
Normal file
359
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine-html.js
vendored
Normal 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;
|
||||
};
|
||||
55
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine.css
vendored
Normal file
55
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine.css
vendored
Normal 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; }
|
||||
2367
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine.js
vendored
Normal file
2367
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine_favicon.png
vendored
Normal file
BIN
public/assets/js/libs/simple-color-picker-master/vendor/jasmine-2.0.0-rc5/jasmine_favicon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
2299
public/assets/js/libs/simpleGantt/frappe-gantt.js
Normal file
2299
public/assets/js/libs/simpleGantt/frappe-gantt.js
Normal file
File diff suppressed because it is too large
Load Diff
21
public/assets/js/libs/simpleGantt/snap.svg-min.js
vendored
Normal file
21
public/assets/js/libs/simpleGantt/snap.svg-min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1970
public/assets/js/libs/slimselect.js
Normal file
1970
public/assets/js/libs/slimselect.js
Normal file
File diff suppressed because it is too large
Load Diff
1
public/assets/js/libs/slimselect.min.js
vendored
Normal file
1
public/assets/js/libs/slimselect.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
54
public/assets/js/libs/uppy/uppy.js
Normal file
54
public/assets/js/libs/uppy/uppy.js
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user