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;
|
||||
Reference in New Issue
Block a user