dolibarr/htdocs/quickmemo/js/QuickMemo.js
John BOTELLA 758f0a5482
Fix + New(but fix too) : FIX UI for memo text auto resize + form setup improvement (#38485)
* Fix langs cache reload spam

* Fix langs cache reload spam

* Fix UI font size auto resize

* Patch UI for memo and add setup options

* remove useless var

* fix var type

* fix php stan error

* fix php stan error

* fix php stan error
2026-06-10 19:24:14 +02:00

2882 lines
87 KiB
JavaScript

/**
* QuickMemo Class
* Manages interactive sticky notes (memos) with drag-and-drop, resizing,
* Markdown support, and server-side persistence.
*/
class QuickMemo {
/**
* @constructor
* @param {Object} param - Configuration parameters for the instance.
*/
constructor(param = {}) {
/**
* @type {Object} defaultParams - Default configuration values
*/
const defaultParams = {
interfaceUrl: '',
archivesUrl: false,
userWriteRight: false,
userDeleteRight: false,
elementId: 0,
autoResizeFontSize: true,
autoResizeFontMin: 1,
autoResizeFontMax: 1.2,
elementType: '',
context: '',
token: '',
shareBtnStatus: 0,
colors: ['#fff8a6', '#ffd6d6', '#d6ffd9', '#d6e6ff', '#f3d6ff', '#ffffff', '#f5f5f5'],
publicFontAIcon: 'far fa-eye',
privateFontAIcon: 'far fa-eye-slash',
archiveFontAIcon: 'far fa-trash-alt',
sharedFontAIcon: 'far fa-copy',
notSharedFontAIcon: 'far fa-file',
modelFontAIcon: 'far fa-share-square',
menuDropDownId: 'quickmemo-create-dropdown',
menuDropDownModelListContainerId: 'quickmemo-model-list',
menuDropDownPresetListContainerId: 'quickmemo-preset-list'
};
// Merge default parameters with user-provided parameters
this.param = {...defaultParams, ...param};
/** @type {number} countedArchivedMemo - Local counter for archived notes */
this.countedArchivedMemo = 0;
// Validation of required parameters
if (!this.param.interfaceUrl || (!this.param.elementId && this.param.elementType !== '')) {
console.warn('QuickMemo: missing required parameters');
return;
}
/** @type {HTMLElement} container - Global container (body) */
this.container = document.body;
/** @type {number} currentZ - Initial z-index for notes */
this.currentZ = 200;
/** @type {Array} memos - Stores objects containing {memoData, domElement} */
this.memos = [];
this.init();
}
/**
* Initializes the component by loading languages, fetching data, and setting up events.
* @async
*/
async init() {
// Load translation files via Dolibarr tools
const loadLangs = async function () {
await Dolibarr.tools.langs.load('quickmemo');
await Dolibarr.tools.langs.load('main');
await Dolibarr.tools.langs.load('other');
}
await loadLangs();
Dolibarr.on('tools:langs:CacheCleared', loadLangs);
// Load existing memos from server
this.loadMemos();
// Set initial z-indexes based on stored position
this.memos.forEach((m, i) => {
// Default pos_z if it does not exist
if (!('pos_z' in m.memo) || m.memo.pos_z < 1) m.memo.pos_z = i + 1;
// z-index CSS
m.el.style.zIndex = this.currentZ + m.memo.pos_z;
});
// Ensure notes stay within viewport on window resize
window.addEventListener('resize', () => this.ensureAllVisible());
// Initialize top-bar menu if user has write permissions
if (this.param.userWriteRight) {
this.initMenuMemoDropdown();
}
// Setup global shortcut behaviors (Escape key)
this.escapeKeyBehavior();
}
/**
* Manages the Escape key behavior to hide/show notes with animations.
*/
escapeKeyBehavior() {
let escTimer = null;
let escActivated = false;
/** Hides all notes by setting display to none */
function applyDisplayNone() {
document.querySelectorAll('.quickmemo-note').forEach(el => {
el.style.display = 'none';
});
}
/** Restores notes visibility */
function removeDisplayNone() {
document.querySelectorAll('.quickmemo-note').forEach(el => {
el.style.display = '';
});
}
// Keydown handler: triggers after a delay to hide notes
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !escTimer) {
escTimer = setTimeout(function () {
document.body.classList.add('hide-quickmemo');
escActivated = true;
// Single global listener
document.addEventListener('transitionend', function handler(ev) {
if (ev.target.classList.contains('quickmemo-note')) {
applyDisplayNone();
document.removeEventListener('transitionend', handler);
}
});
}, 300);
}
});
// Keyup handler: restores notes when Escape is released
document.addEventListener('keyup', function (e) {
if (e.key === 'Escape') {
clearTimeout(escTimer);
escTimer = null;
if (escActivated) {
removeDisplayNone();
requestAnimationFrame(() => {
document.body.classList.remove('hide-quickmemo');
});
escActivated = false;
}
}
});
}
/* ===============================
LOAD
================================= */
/**
* Fetches memos from the server and renders them.
* @async
*/
async loadMemos() {
try {
const res = await fetch(this.param.interfaceUrl + '?action=list', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
element_id: this.param.elementId,
element_type: this.param.elementType,
context: this.param.context,
token: this.param.token
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
}
// Render each fetched memo
json.data.memos.forEach(memo => this.renderMemo(memo));
// Update the UI with the archive count
this.setArchivesNb(json.data.nbArchives);
} catch (err) {
Dolibarr.tools.setEventMessage(Dolibarr.tools.langs.trans('QuickMemoLoadMemosError') + ' : ' + err, 'errors');
console.error('QuickMemo load error', err);
}
}
/**
* Updates the internal counter and the DOM badge for archived memos.
* @param {number|string} nb - Number of archived items.
*/
setArchivesNb(nb) {
this.countedArchivedMemo = parseInt(nb);
// Update badge
if (document.getElementById('quick-note-archives-link-mumber')) {
const nbArchiveBadge = document.getElementById('quick-note-archives-link-mumber');
nbArchiveBadge.textContent = this.countedArchivedMemo;
}
}
/**
* Finds the memo data object associated with a DOM element.
* @param {HTMLElement} el
* @returns {Object|boolean}
*/
getMemoFromElement(el) {
const m = this.memos.find(x => x.el === el);
if (!m) return false;
return m;
}
/**
* Places the selected note at the end of the stack and re-indexes all notes from 1 to N.
* @param {HTMLElement} el
*/
bringToTop(el) {
const m = this.getMemoFromElement(el);
if (!m) return;
// 1. Sort all notes by their current z-index
const sortedMemos = [...this.memos].sort((a, b) =>
(parseInt(a.memo.pos_z) || 0) - (parseInt(b.memo.pos_z) || 0)
);
// 2. Remove the current note from its position and push it to the end (the top)
const others = sortedMemos.filter(x => x !== m);
others.push(m);
// 3. Re-assign clean indexes (1 to N) to everyone
others.forEach((x, i) => {
const newZ = i + 1;
x.memo.pos_z = newZ;
x.el.style.zIndex = this.currentZ + newZ;
});
}
/* ===============================
RENDER
================================= */
/**
* Creates and appends the DOM structure for a memo.
* @async
* @param {Object} memo - The data object for the note.
* @returns {HTMLElement} The created note element.
*/
async renderMemo(memo) {
const note = document.createElement('div');
this.memos.push({memo, el: note});
note.className = 'quickmemo-note';
note.dataset.id = memo.id;
let posX = parseInt(memo.pos_x);
let posY = parseInt(memo.pos_y);
let posW = parseInt(memo.pos_w);
let posH = parseInt(memo.pos_h);
const containerWidth = this.container.clientWidth;
// Prevent note from appearing outside of the viewport
if (posX + posW > containerWidth) {
posX = Math.max(32, containerWidth - posW - 32);
}
note.style.position = 'absolute';
note.style.left = posX + 'px';
note.style.top = posY + 'px';
note.style.width = posW + 'px';
note.style.height = posH + 'px';
// note.style.background = memo.color || '#fff8a6';
// note.style.setProperty('--memo-color', memo.color || '#fff8a6');
this.setMemoColor(note, memo.color);
if (parseInt(memo.id) === 0) { // Uniquement pour les nouvelles notes en cours de création
const maxZ = this.memos.length > 0 ? Math.max(...this.memos.map(x => parseInt(x.memo.pos_z) || 0)) : 0;
memo.pos_z = maxZ + 1;
}
note.style.zIndex = (this.currentZ || 0) + parseInt(memo.pos_z);
const noteHeader = document.createElement('div');
noteHeader.className = 'quickmemo-header';
/* ================= MEMO FOOTER & ACTIONS ================= */
const noteFooter = document.createElement('div');
noteFooter.className = 'quickmemo-footer';
const meta = document.createElement('div');
meta.className = 'quickmemo-meta';
const hasDateChange = memo.date_change && memo.date_change.trim() !== '' && memo.date_creation !== memo.date_change;
const hasUserChange = memo.user_change_name && memo.user_change_name.trim() !== '' && memo.user_name !== memo.user_change_name;
let updateBlock = '';
// Template for "Modified on/by" information
updateBlock = `
<span class="quickmemo-info__date_update">
${hasDateChange ? `
<span class="quickmemo-info__date_update">
${await Dolibarr.tools.langs.trans('QuickMemoModified')} : ${memo.date_change}
</span>
` : ''}
${hasUserChange ? `
<span class="quickmemo-info__user-change-name">
${await Dolibarr.tools.langs.trans('QuickMemoBy')} ${memo.user_change_name}
</span>
` : ''}
</span>
`;
meta.innerHTML = `
<div class="quickmemo-info">
<span class="quickmemo-info__create">
<span class="quickmemo-info__user-create-name">${memo.user_name || ''}</span>
<span class="quickmemo-info__date_create">${memo.date_creation || ''}</span>
</span>
${updateBlock}
</div>
`;
const actions = document.createElement('div');
actions.className = 'quickmemo-actions';
/* ===== ARCHIVE ===== */
const btnArchiveTitleLangs = {
QuickMemoActionDelete : await Dolibarr.tools.langs.transNoEntities('QuickMemoActionDelete'),
QuickMemoActionDeleteEmpty : await Dolibarr.tools.langs.transNoEntities('QuickMemoActionDeleteEmpty'),
QuickMemoActionArchive : await Dolibarr.tools.langs.transNoEntities('QuickMemoActionArchive'),
}
const btnArchive = document.createElement('button');
btnArchive.className = 'btn-low-emphasis --btn-icon quickmemo-btn-archive';
btnArchive.innerHTML = `<i class="${this.param.archiveFontAIcon}"></i>`;
btnArchive.title = btnArchiveTitleLangs.QuickMemoActionArchive;
this.initTooltips(btnArchive);
/** Updates btn txt based onshift key */
const updateBtnArchiveTitle = async () => {
let isHoverArchive = false;
const updateArchiveTitle = (forceDelete = false) => {
const textarea = note.querySelector('textarea');
const textLength = textarea.value.trim().length;
if (forceDelete) {
btnArchive.title = btnArchiveTitleLangs.QuickMemoActionDelete;
} else if (textLength === 0) {
btnArchive.title = btnArchiveTitleLangs.QuickMemoActionDeleteEmpty;
} else {
btnArchive.title = btnArchiveTitleLangs.QuickMemoActionArchive;
}
// Refresh tooltip if needed
btnArchive._quickmemoTooltipSync?.();
};
btnArchive.addEventListener('mouseenter', async (e) => {
isHoverArchive = true;
updateArchiveTitle(e.shiftKey);
});
btnArchive.addEventListener('mouseleave', () => {
isHoverArchive = false;
});
document.addEventListener('keydown', async (e) => {
if (e.key !== 'Shift' || !isHoverArchive) {
return;
}
updateArchiveTitle(true);
});
document.addEventListener('keyup', async (e) => {
if (e.key !== 'Shift' || !isHoverArchive) {
return;
}
updateArchiveTitle(false);
});
};
updateBtnArchiveTitle();
btnArchive.addEventListener('click', async (e) => {
e.stopPropagation();
this.removeTooltips(btnArchive); // JQUERY tooltip need to be destroy because it active at this moment
try {
// If empty, delete permanently, otherwise archive
const textarea = note.querySelector('textarea');
const actionDelete = (e.shiftKey || textarea.value.length === 0) ? 'delete' : 'archive';
if (actionDelete === 'delete') {
this.deleteWithAnimation(note);
} else {
this.archiveWithAnimation(note);
}
const res = await fetch(this.param.interfaceUrl + '?action=' + actionDelete, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: note.dataset.id,
token: this.param.token
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
}
this.memos = this.memos.filter(m => m.el !== note);
} catch (err) {
Dolibarr.tools.setEventMessage(Dolibarr.tools.langs.trans('QuickMemoArchiveError') + ' : ' + err, 'errors');
console.error('QuickMemo archive error', err);
}
});
/* ===== TOGGLE SHARING ===== */
const btnShare = document.createElement('button');
btnShare.className = 'btn-low-emphasis --btn-icon quickmemo-btn-share';
btnShare.title = await Dolibarr.tools.langs.transNoEntities('QuickMemoActionShareMode');
this.initTooltips(btnShare);
/** Updates icon based on sharing status */
const updateShareIcon = async () => {
note.setAttribute('data-shared', parseInt(memo.shared_on_element));
btnShare.title = await Dolibarr.tools.langs.transNoEntities(memo.shared_on_element == 1 ? 'QuickMemoActionShareModeActive' : 'QuickMemoActionShareModeNotActive');
btnShare.innerHTML = `<i class="${
memo.shared_on_element == 1
? this.param.sharedFontAIcon
: this.param.notSharedFontAIcon
}"></i>`;
this.initTooltips(btnShare);
};
updateShareIcon();
btnShare.addEventListener('click', async (e) => {
e.stopPropagation();
memo.shared_on_element = memo.shared_on_element == 1 ? 0 : 1;
updateShareIcon();
await fetch(this.param.interfaceUrl + '?action=update-shared-on-element', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: note.dataset.id,
shared_on_element: memo.shared_on_element,
token: this.param.token
})
});
});
/* ===== SET AS TEMPLATE ===== */
const btnCreateModel = document.createElement('button');
btnCreateModel.className = 'btn-low-emphasis --btn-icon';
btnCreateModel.innerHTML = `<i class="${this.param.modelFontAIcon}"></i>`;
btnCreateModel.title = await Dolibarr.tools.langs.transNoEntities('QuickMemoCreateAModelFromThisMemo');
this.initTooltips(btnCreateModel);
btnCreateModel.addEventListener('click', () => this.openCreateModelDialog(memo, note));
/* ===== TOGGLE PRIVATE ===== */
const btnPrivate = document.createElement('button');
btnPrivate.className = 'btn-low-emphasis --btn-icon quickmemo-btn-private';
btnPrivate.title = await Dolibarr.tools.langs.transNoEntities('QuickMemoActionPrivateMode');
this.initTooltips(btnPrivate);
/** Updates icon based on privacy status */
const updatePrivateIcon = async () => {
note.setAttribute('data-private', parseInt(memo.private));
btnPrivate.title = await Dolibarr.tools.langs.transNoEntities(memo.private == 1 ? 'QuickMemoActionPrivateModeActive' : 'QuickMemoActionPrivateModeNotActive');
btnPrivate.innerHTML = `<i class="${
parseInt(memo.private) === 1
? this.param.privateFontAIcon
: this.param.publicFontAIcon
}"></i>`;
this.initTooltips(btnPrivate);
};
updatePrivateIcon();
btnPrivate.addEventListener('click', async (e) => {
e.stopPropagation();
memo.private = parseInt(memo.private) === 1 ? 0 : 1; // Toggle private status
updatePrivateIcon();
await fetch(this.param.interfaceUrl + '?action=update-private', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: note.dataset.id,
private: memo.private,
token: this.param.token
})
});
});
/* ===== COLOR PICKER ===== */
const colorListId = 'quickmemo-color-list';
// Create a datalist for predefined colors if not already existing
if (!document.getElementById(colorListId)) {
const datalist = document.createElement('datalist');
datalist.id = colorListId;
this.param.colors.forEach(c => {
const option = document.createElement('option');
option.value = c;
datalist.appendChild(option);
});
document.body.appendChild(datalist);
}
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.className = 'quickmemo-color-input';
colorInput.value = memo.color || '#fff8a6';
colorInput.setAttribute('list', colorListId);
colorInput.title = await Dolibarr.tools.langs.trans('QuickMemoActionChangeColor');
this.initTooltips(colorInput);
colorInput.addEventListener('click', e => {
e.stopPropagation(); // prevent triggering drag
});
colorInput.addEventListener('input', async (e) => {
e.stopPropagation();
const newColor = e.target.value;
// note.style.background = newColor;
// note.style.setProperty('--memo-color', newColor);
this.setMemoColor(note, newColor);
memo.color = newColor;
await fetch(this.param.interfaceUrl + '?action=update-color', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: note.dataset.id,
color: memo.color,
token: this.param.token
})
});
});
// Append buttons based on permissions
if (this.param.userWriteRight) {
actions.appendChild(btnPrivate);
if(this.param.shareBtnStatus > 0) {
actions.appendChild(btnShare);
}
actions.appendChild(colorInput);
actions.appendChild(btnCreateModel);
}
if (this.param.userDeleteRight) {
actions.appendChild(btnArchive);
}
noteHeader.appendChild(actions);
note.appendChild(noteHeader);
noteFooter.appendChild(meta);
/* ================= TEXTAREA ================= */
const textarea = document.createElement('textarea');
textarea.value = memo.note || '';
if (!this.param.userWriteRight) {
textarea.readOnly = true;
}
textarea.placeholder = await Dolibarr.tools.langs.transNoEntities('QuickMemoEnterNote');
textarea.addEventListener('input', () => this.adjustFontSize(note));
note.appendChild(textarea);
// MARKDOWN PREVIEW
const preview = document.createElement('div');
preview.className = 'quickmemo-markdown-preview';
note.appendChild(preview);
if (!textarea.value) {
// Dynamically retrieve the placeholder
const ph = textarea.getAttribute('placeholder') || '';
await this.renderPreview(preview, `<span class="quickmemo-placeholder">${ph}</span>`, false);
} else {
await this.renderPreview(preview, textarea.value);
}
/** Toggles between edit mode (textarea) and view mode (markdown) */
const setEditMode = (edit) => {
note.classList.toggle('--edit-mode', edit);
// set focus on textarea on edit mode
if (edit) textarea.focus();
};
textarea.addEventListener('focus', () => setEditMode(true));
textarea.addEventListener('blur', async (e) => {
// Check if the focus is moving to a toolbar button
if (e.relatedTarget && e.relatedTarget.closest('.quickmemo-editor-toolbar')) {
return; // Do nothing, keep --edit-mode active
}
// Otherwise, close edit mode
setTimeout(async () => {
// ... votre logique de preview ...
setEditMode(false); // This will automatically hide the toolbar via CSS
}, 150);
if (!textarea.value) {
// Dynamically retrieve the placeholder
const ph = textarea.getAttribute('placeholder') || '';
await this.renderPreview(preview, `<span class="quickmemo-placeholder">${ph}</span>`, false);
} else {
await this.renderPreview(preview, textarea.value);
}
});
// Interaction with the preview (checkboxes and links)
preview.addEventListener('click', async (e) => {
const target = e.target;
// If a link is clicked or inside a link
if (target.closest('a')) {
e.stopPropagation(); // Prevent setEditMode
return;
} else if (target.classList.contains('markdown-checkbox')) {
const lineNumber = target.closest('[data-line]').dataset.line;
const lines = textarea.value.split('\n');
const line = lines[lineNumber];
const todoMatch = line.match(/^\[([ xX])\] (.*)$/);
if (!todoMatch) return;
const checked = todoMatch[1].toLowerCase() === 'x';
lines[lineNumber] = `[${checked ? ' ' : 'x'}] ${todoMatch[2]}`;
textarea.value = lines.join('\n');
// Re-render preview
await this.renderPreview(preview, textarea.value);
// trigger envent input for bindSave
const inputEvent = new Event('input', {bubbles: true});
textarea.dispatchEvent(inputEvent);
e.stopPropagation(); // Prevent setEditMode
} else {
setEditMode(true)
}
});
const editorToolbar = document.createElement('div');
editorToolbar.className = 'quickmemo-editor-toolbar';
const btnBold = this.createToolbarButton(textarea,editorToolbar,'bold', 'fa-bold', Dolibarr.tools.langs.transNoEntities('Bold'));
const btnItalic = this.createToolbarButton(textarea,editorToolbar,'italic', 'fa-italic', Dolibarr.tools.langs.transNoEntities('Italic'));
const btnUnderline = this.createToolbarButton(textarea,editorToolbar,'underline', 'fa-underline', Dolibarr.tools.langs.transNoEntities('Underline'));
const btnH1 = this.createToolbarButton(textarea,editorToolbar,'h1', 'fa-heading', Dolibarr.tools.langs.transNoEntities('H1'), '1');
const btnH2 = this.createToolbarButton(textarea,editorToolbar,'h2', 'fa-heading', Dolibarr.tools.langs.transNoEntities('H2'), '1');
const btnH3 = this.createToolbarButton(textarea,editorToolbar,'h3', 'fa-heading', Dolibarr.tools.langs.transNoEntities('H3'), '1');
const btnCheck = this.createToolbarButton(textarea,editorToolbar,'task', 'fa-check-square', Dolibarr.tools.langs.transNoEntities('Checkbox'));
this.createToolbarSeparator(editorToolbar)
const btnEditorHelp = document.createElement('button');
btnEditorHelp.type = 'button';
btnEditorHelp.className = 'quickmemo-toolbar-btn';
btnEditorHelp.innerHTML = `<i class="fa fa-question-circle" style="pointer-events: none;"></i>`;
btnEditorHelp.title = Dolibarr.tools.langs.transNoEntities('Help');
//<div class="data-tooltip" >${this.getMarkDownHelp()}</div>
editorToolbar.appendChild(btnEditorHelp);
btnEditorHelp.addEventListener('click', () => {
const confirmOptions = {
title: Dolibarr.tools.langs.trans('QuickMemoEnterNoteMdCompabible'),
confirmLabel: Dolibarr.tools.langs.trans('CloseDialog'),
customClass: 'dialog-lg',
};
this.openSimpleDialog(this.getMarkDownHelp(), confirmOptions);
});
note.appendChild(editorToolbar);
textarea.addEventListener('keyup', () => this.updateToolbarState(textarea, editorToolbar));
textarea.addEventListener('click', () => this.updateToolbarState(textarea, editorToolbar));
textarea.addEventListener('select', () => this.updateToolbarState(textarea, editorToolbar));
textarea.addEventListener('focus', () => this.updateToolbarState(textarea, editorToolbar));
// END MARKDOWN
note.appendChild(noteFooter);
this.container.appendChild(note);
this.adjustFontSize(note);
const state = {isResizing: false};
// Enable drag, resize and auto-save
this.makeDraggable(note, state);
this.makeResizable(note, memo, state);
this.bindSave(textarea, note);
return note;
}
createToolbarSeparator(toolbar, autoAppend = true) {
const btn = document.createElement('hr');
btn.className = 'quickmemo-toolbar-separator';
if(autoAppend) {
toolbar.appendChild(btn);
}
return btn;
}
createToolbarButton(textarea,toolbar, type, iconClass, title, autoAppend = true) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'quickmemo-toolbar-btn';
btn.dataset.type = type;
let sub = '';
if(type === 'h1') {
sub = '<sub style="pointer-events: none;" >1</sub>';
}else if(type === 'h2'){
sub = '<sub style="pointer-events: none;">2</sub>';
}else if(type === 'h3'){
sub = '<sub style="pointer-events: none;">3</sub>';
}
btn.innerHTML = `<i class="fa ${iconClass}" style="pointer-events: none;"></i>${sub}`;
btn.title = title;
// On empêche le bouton de voler le focus définitivement,
// mais on le laisse être le "relatedTarget"
btn.addEventListener('mousedown', (e) => {
e.preventDefault(); // Empêche le curseur de quitter le textarea
this.applyMarkdown(textarea, type, toolbar);
});
if(autoAppend) {
toolbar.appendChild(btn);
}
return btn;
}
/**
* Converts raw text into Markdown HTML and updates the preview area.
* @async
* @param {HTMLElement} preview - The preview container.
* @param {string} txt - The source text.
* @param {boolean} convertMd - Whether to apply markdown conversion.
*/
async renderPreview(preview, txt, convertMd = true) {
// Re-render preview
preview.innerHTML = convertMd ? await this.simpleMarkdown(txt) : txt;
const badge = document.createElement('span');
badge.classList.add('quickmemo-markdown-preview__badge');
const checkboxes = preview.querySelectorAll('input[type="checkbox"]');
const total = checkboxes.length;
if (total === 0) {
return;
}
const checked = preview.querySelectorAll('input[type="checkbox"]:checked').length;
badge.textContent = checked + " / " + total;
if (checked === total) {
badge.classList.add("success");
} else {
badge.classList.remove("success");
}
preview.prepend(badge);
}
/**
* Visual effect: note flies towards the archive button and disappears.
* @param {HTMLElement} note
*/
archiveWithAnimation(note) {
const target = document.getElementById(this.param.menuDropDownId);
if (!target) {
note.remove();
return;
}
const noteRect = note.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const deltaX = targetRect.left - noteRect.left;
const deltaY = targetRect.top - noteRect.top;
// Set current size to prevent jumps effect
note.style.width = noteRect.width + 'px';
note.style.height = noteRect.height + 'px';
note.style.position = 'fixed';
note.style.left = noteRect.left + 'px';
note.style.top = noteRect.top + 'px';
note.style.margin = 0;
note.style.zIndex = 99999;
note.style.transition = 'transform 400ms ease, opacity 400ms ease';
note.style.transformOrigin = 'top left';
requestAnimationFrame(() => {
note.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(0.2)
`;
note.style.opacity = '0';
});
note.addEventListener('transitionend', () => {
note.remove();
this.setArchivesNb(this.countedArchivedMemo + 1);
}, {once: true});
}
/**
* Visual effect: note flies towards the template list when a model is created.
* @param {Object} memo
* @param {HTMLElement} note
*/
convertToModelWithAnimation(memo, note) {
const dropDown = document.getElementById(this.param.menuDropDownId);
if (!dropDown) {
note.remove();
return;
}
const modelListDiv = document.getElementById(this.param.menuDropDownModelListContainerId);
if (!modelListDiv) {
note.remove();
return;
}
if (!memo) {
note.remove();
return;
}
setTimeout(async () => {
await this.fetchMenuDropDown();
dropDown.classList.add('open');
const target = document.createElement('div');
target.className = 'quickmemo-model-placeholder';
modelListDiv.appendChild(target);
const noteRect = note.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
const deltaX = targetRect.left - noteRect.left;
const deltaY = targetRect.top - noteRect.top;
// Set current size to prevent jumps effect
note.style.width = noteRect.width + 'px';
note.style.height = noteRect.height + 'px';
note.style.position = 'fixed';
note.style.left = noteRect.left + 'px';
note.style.top = noteRect.top + 'px';
note.style.margin = 0;
note.style.zIndex = 99999;
note.style.transition = 'transform 400ms ease, opacity 400ms ease';
note.style.transformOrigin = 'top left';
requestAnimationFrame(() => {
note.style.transform = `
translate(${deltaX}px, ${deltaY}px)
scale(0.2)
`;
note.style.opacity = '0';
});
note.addEventListener('transitionend', () => {
note.remove();
target.remove();// remove placeholder
// Check if same btn already exists caused by menu open loading new model before animation ends
const existingBtn = modelListDiv.querySelector(`.quickmemo-model-btn[data-model-id="${memo.id}"]`);
if (!existingBtn) {
const newBtn = this.generateAddMemoBtn(memo);
this.attachDragEventsForModelReorder(newBtn);
modelListDiv.appendChild(newBtn);
}
}, {once: true});
}, 10); // hack to avoid close dropdown on click event
}
/**
* Triggers the deletion animation.
* @param {HTMLElement} note
*/
deleteWithAnimation(note) {
note.classList.add('--note-delete-animation');
note.addEventListener('animationend', () => {
note.remove();
}, {once: true});
}
/**
* Applies background color and calculates brightness to set a dark/light font class.
* @param {HTMLElement} note
* @param {string} color - Hex color string.
*/
setMemoColor(note, color) {
// Default color if not valide
if (!color || typeof color !== 'string') color = '#fff8a6';
note.style.setProperty('--memo-color', color);
// Check that it is a 6-character hex
let c = color.startsWith('#') ? color.substring(1) : color;
if (c.length !== 6 || !/^[0-9a-fA-F]{6}$/.test(c)) {
// invalide → considère clair par défaut
note.classList.remove('--memo-is-dark');
return;
}
// Conversion hex -> RGB
const r = parseInt(c.substr(0, 2), 16);
const g = parseInt(c.substr(2, 2), 16);
const b = parseInt(c.substr(4, 2), 16);
// Calculate brightness
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
if (brightness < 128) {
note.classList.add('--memo-is-dark');
} else {
note.classList.remove('--memo-is-dark');
}
}
/**
* Dynamically resizes the font based on the content density within the note's dimensions.
* @param {HTMLElement} noteEl
*/
async adjustFontSize(noteEl) {
if (!this.param.autoResizeFontSize) {
return;
}
const textarea = noteEl.querySelector('textarea');
const preview = noteEl.querySelector('.quickmemo-markdown-preview');
const footerHeight = noteEl.querySelector('.quickmemo-footer')?.offsetHeight || 0;
const headerHeight = noteEl.querySelector('.quickmemo-header')?.offsetHeight || 0;
const contentHeight = noteEl.clientHeight - headerHeight - footerHeight;
const contentWidth = noteEl.clientWidth;
const textLength = textarea.value.length;
const btnArchive = noteEl.querySelector('.quickmemo-btn-archive');
if (btnArchive) {
btnArchive.title = await Dolibarr.tools.langs.transNoEntities(textLength > 0 ? 'QuickMemoActionArchive' : 'QuickMemoActionDeleteEmpty');
// Refresh tooltip if needed
btnArchive._quickmemoTooltipSync?.();
}
// Calculate density
const density = textLength / (contentWidth * contentHeight);
// Linearly map density to font-size
const densityMin = 0.0002;
const densityMax = 0.005;
const fontMin = this.param.autoResizeFontMin;
const fontMax = this.param.autoResizeFontMax;
// Clamp density between min & max
const clampedDensity = Math.max(densityMin, Math.min(densityMax, density));
// Linear interpolation
let fontSize = fontMax - ((clampedDensity - densityMin) / (densityMax - densityMin)) * (fontMax - fontMin);
// Round to the nearest 0.1 em
fontSize = Math.round(fontSize * 10) / 10;
textarea.style.fontSize = fontSize + 'em';
preview.style.fontSize = fontSize + 'em';
}
// /**
// * Brings the element to the foreground on mouse click.
// * @param {HTMLElement} el
// */
// bindFocusZ(el) {
// el.addEventListener('mousedown', () => {
// el.style.zIndex = ++this.currentZ;
// });
// }
/* ===============================
DRAG
================================= */
/**
* Makes a note element draggable. Handles selection vs drag logic on textareas.
* @param {HTMLElement} el
* @param {Object} state - Reference to shared resizing state.
*/
makeDraggable(el, state) {
let offsetX, offsetY, dragging = false;
let hasMoved = false;
let startX, startY;
let dragTimeout = null;
const startDragDelay = 300; // ms : temps d'attente au click avant de passer en mode drag
const selectionThreshold = 5; // px : distance en pixels qui désactive le drag pour passer en mode selection
const textarea = el.querySelector('textarea');
// Auto-scroll configuration
const scrollSpeed = 15;
const scrollThreshold = 50;
let scrollInterval = null;
const stopAutoScroll = () => {
if (scrollInterval) {
clearInterval(scrollInterval);
scrollInterval = null;
}
};
const onMouseMove = (ev) => {
if (!dragging) return;
// 1. Calculate new positions
let newX = ev.clientX + window.scrollX - offsetX;
let newY = ev.clientY + window.scrollY - offsetY;
// 2. Boundaries logic
const boundaryW = Math.max(document.documentElement.scrollWidth, window.innerWidth);
const boundaryH = Math.max(document.documentElement.scrollHeight, window.innerHeight);
const maxX = boundaryW - el.offsetWidth;
const maxY = boundaryH - el.offsetHeight;
newX = Math.max(0, Math.min(newX, maxX));
newY = Math.max(0, Math.min(newY, maxY));
el.style.left = newX + 'px';
el.style.top = newY + 'px';
hasMoved = true;
// 3. Auto-scroll logic
const viewportY = ev.clientY;
const viewportHeight = window.innerHeight;
stopAutoScroll();
// Scroll down if mouse is near bottom edge
if (viewportY > viewportHeight - scrollThreshold) {
scrollInterval = setInterval(() => {
window.scrollBy(0, scrollSpeed);
// Update position while scrolling even if mouse doesn't move
let currentY = parseInt(el.style.top) + scrollSpeed;
const currentMaxY = document.documentElement.scrollHeight - el.offsetHeight;
el.style.top = Math.min(currentY, currentMaxY) + 'px';
}, 16);
}
// Scroll up if mouse is near top edge
else if (viewportY < scrollThreshold) {
scrollInterval = setInterval(() => {
window.scrollBy(0, -scrollSpeed);
let currentY = parseInt(el.style.top) - scrollSpeed;
el.style.top = Math.max(0, currentY) + 'px';
}, 16);
}
};
const cancelDrag = () => {
if (dragTimeout) {
clearTimeout(dragTimeout);
dragTimeout = null;
}
dragging = false;
el.classList.remove('--dragging');
};
const onMouseUp = () => {
cancelDrag();
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (hasMoved) this.saveAllPositions()
hasMoved = false;
};
el.addEventListener('mousedown', e => {
if (e.button !== 0) return; // Left click only
if (state.isResizing) return;
// if (el.style.cursor && el.style.cursor.includes('resize')) return;
// DO NOT DRAG if mouse hover .quickmemo-actions
if (e.target.closest('.quickmemo-actions')) return;
//if (e.target.tagName === 'TEXTAREA') return;
const isOnTextarea = e.target === textarea;
this.bringToTop(el);
hasMoved = false;
const rect = el.getBoundingClientRect();
startX = e.clientX + window.scrollX;
startY = e.clientY + window.scrollY;
offsetX = startX - (rect.left + window.scrollX);
offsetY = startY - (rect.top + window.scrollY);
if (isOnTextarea && !textarea.disabled && !textarea.readOnly) {
// Delayed drag to allow text selection
dragTimeout = setTimeout(() => {
// Check if there is no selection
const sel = window.getSelection();
if (sel && sel.toString().length === 0) {
dragging = true;
el.classList.add('--dragging');
// Cancel selection when dragging
if (sel.rangeCount > 0) sel.removeAllRanges();
// Remove focus so the cursor stops moving
if (document.activeElement === textarea) textarea.blur();
}
}, startDragDelay);
// Detect movement for text selection
const onTempMove = (ev) => {
const dx = ev.clientX - e.clientX;
const dy = ev.clientY - e.clientY;
if (Math.sqrt(dx * dx + dy * dy) > selectionThreshold) {
cancelDrag(); // Cancel selection when selected
cleanupTempMove();
}
};
const cleanupTempMove = () => {
document.removeEventListener('mousemove', onTempMove);
dragTimeout = null;
};
document.addEventListener('mousemove', onTempMove, {once: true});
} else {
dragging = true;
el.classList.add('--dragging');
e.preventDefault();
}
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
}
/* ===============================
RESIZE
================================= */
/**
* Makes a note resizable from all edges and corners.
* @param {HTMLElement} el
* @param {Object} memo - Data object to update.
* @param {Object} state - Shared resizing state.
*/
makeResizable(el, memo, state) {
const margin = 10;
let direction = null;
let startX, startY, startW, startH, startLeft, startTop;
const onMouseMove = (e) => {
if (!state.isResizing) return;
let dx = e.clientX - startX;
let dy = e.clientY - startY;
let newW = startW;
let newH = startH;
let newLeft = startLeft;
let newTop = startTop;
// Handle different directions
if (direction.includes('e')) {
newW = startW + dx;
newW = Math.min(newW, document.documentElement.scrollWidth - startLeft);
}
if (direction.includes('s')) {
newH = startH + dy;
newH = Math.min(newH, document.documentElement.scrollHeight - startTop);
}
if (direction.includes('w')) {
newW = startW - dx;
newLeft = startLeft + dx;
if (newLeft < 0) {
newW += newLeft; // Reduce width to avoid overflow
newLeft = 0;
}
}
if (direction.includes('n')) {
newH = startH - dy;
newTop = startTop + dy;
if (newTop < 0) {
newH += newTop; // Reduce height to avoid overflow
newTop = 0;
}
}
// Minimum dimensions constraint
newW = Math.max(250, newW);
newH = Math.max(100, newH);
el.style.width = newW + 'px';
el.style.height = newH + 'px';
el.style.left = newLeft + 'px';
el.style.top = newTop + 'px';
this.adjustFontSize(el);
memo.pos_w = newW;
memo.pos_h = newH;
memo.pos_x = newLeft;
memo.pos_y = newTop;
};
const onMouseUp = () => {
if (!state.isResizing) return;
state.isResizing = false;
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
this.savePosition(el);
};
// Mouse movement detection for cursor change
el.addEventListener('mousemove', (e) => {
if (state.isResizing) return;
const rect = el.getBoundingClientRect();
const x = e.clientX;
const y = e.clientY;
const onLeft = x >= rect.left - margin && x <= rect.left + margin;
const onRight = x >= rect.right - margin && x <= rect.right + margin;
const onTop = y >= rect.top - margin && y <= rect.top + margin;
const onBottom = y >= rect.bottom - margin && y <= rect.bottom + margin;
direction = null;
if (onRight && onBottom) direction = 'se';
else if (onLeft && onBottom) direction = 'sw';
else if (onRight && onTop) direction = 'ne';
else if (onLeft && onTop) direction = 'nw';
else if (onRight) direction = 'e';
else if (onLeft) direction = 'w';
else if (onBottom) direction = 's';
else if (onTop) direction = 'n';
el.style.cursor = getCursor(direction);
});
el.addEventListener('mousedown', (e) => {
if (!direction) return;
// DO NOT RESIZE if mouse hover .quickmemo-actions
if (e.target.closest('.quickmemo-actions')) return;
this.bringToTop(el);
state.isResizing = true;
const rect = el.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startW = rect.width;
startH = rect.height;
startLeft = rect.left + window.scrollX;
startTop = rect.top + window.scrollY;
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
/** Helper to return CSS cursor string */
function getCursor(dir) {
switch (dir) {
case 'se':
case 'nw':
return 'nwse-resize';
case 'ne':
case 'sw':
return 'nesw-resize';
case 'e':
case 'w':
return 'ew-resize';
case 'n':
case 's':
return 'ns-resize';
default:
return 'default';
}
}
}
/* ===============================
SAVE
================================= */
/**
* Binds auto-save with debounce logic on textarea input.
* @param {HTMLTextAreaElement} textarea
* @param {HTMLElement} noteEl
*/
bindSave(textarea, noteEl) {
let timeout;
let m = this.getMemoFromElement(noteEl);
textarea.addEventListener('input', () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
const id = noteEl.dataset.id;
if (!id || id == 0) return; // Note not yet created in DB
m.memo.note = textarea.value;
this.saveNote(id, m.memo.note, noteEl);
}, 600);
});
}
/**
* Persists note position and dimensions to the server.
* @async
* @param {HTMLElement} el
*/
async savePosition(el) {
const m = this.memos.find(x => x.el === el);
if (!m) return;
if (!el.dataset.id || parseInt(el.dataset.id) === 0) return; // not created yet in database
try {
// TODO send all others memo pos_z for update
const res = await fetch(this.param.interfaceUrl + '?action=update_position', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: el.dataset.id,
x: parseInt(el.style.left),
y: parseInt(el.style.top),
w: parseInt(el.style.width),
h: parseInt(el.style.height),
z: parseInt(m.memo.pos_z) || 1,
token: this.param.token
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
}
Dolibarr.executeHook('QuickMemo:savePosition:saved', {el, json});
} catch (err) {
console.error('QuickMemo save position error', err);
Dolibarr.tools.setEventMessage(Dolibarr.tools.langs.trans('QuickMemoSavePositionError') + ' : ' + err, 'errors');
}
}
/**
* Saves the coordinates and Z-index of ALL memos on the page.
*/
saveAllPositions() {
const memosData = this.memos.map(m => ({
id: m.el.dataset.id,
x: parseInt(m.el.style.left) || 0,
y: parseInt(m.el.style.top) || 0,
w: parseInt(m.el.style.width) || 0,
h: parseInt(m.el.style.height) || 0,
z: parseInt(m.memo.pos_z) || 1
}));
fetch(this.param.interfaceUrl + '?action=update_all_positions&token=' + this.param.token, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
memos: memosData
})
})
.then(response => response.json())
.then(data => {
if (!data.result) console.error("Error saving all positions:", data.msg);
})
.catch(err => console.error("Fetch error:", err));
}
/**
* Persists note content to the server.
* @async
* @param {number|string} id
* @param {string} note
* @param {HTMLElement} noteEl
*/
async saveNote(id, note, noteEl) {
let hookRes = await Dolibarr.executeHookAwait('QuickMemo:saveNote', {id, note});
if (hookRes > 0) {
return;
}
try {
const res = await fetch(this.param.interfaceUrl + '?action=update_note', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: id,
note: note,
token: this.param.token
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
}
if (json.data && json.data.memo) {
await this.updateMemoTxt(json.data.memo, noteEl);
}
Dolibarr.executeHook('QuickMemo:saveNote:saved', {id, note, json});
} catch (err) {
console.error('QuickMemo save note error', err);
Dolibarr.tools.setEventMessage(Dolibarr.tools.langs.trans('QuickMemoSaveError') + ' : ' + err, 'errors');
}
}
/* ===============================
VISIBILITY
================================= */
/**
* Checks all notes and ensures they are visible in the current container.
*/
ensureAllVisible() {
this.memos.forEach(({el}) => this.ensureVisible(el));
}
/**
* Clamps a note's position to keep it within the horizontal boundaries.
* Vertical clamping is disabled to allow notes to be placed anywhere
* in long documents, as vertical scrolling is standard.
* @param {HTMLElement} el - The memo DOM element to check.
*/
ensureVisible(el) {
// Get current CSS positions and actual element dimensions
const posX = parseInt(el.style.left) || 0;
const posW = el.offsetWidth;
/**
* We use the maximum between the container width and the window width
* to determine the right boundary.
*/
const boundaryW = Math.max(this.container.scrollWidth, window.innerWidth);
// 1. Horizontal constraint (Right border)
// We only force the note back if it exceeds the right edge.
if (posX + posW > boundaryW) {
// Calculate a new position with a 32px safety margin
let newLeft = boundaryW - posW - 32;
// Ensure we don't push the note into negative space on the left
el.style.left = Math.max(32, newLeft) + 'px';
}
// 2. Vertical constraint
// Disabled: Let the note stay where it is, even if it's below the current body height.
// The browser will naturally handle the scroll if needed.
}
/**
* Updates the metadata text (User/Date) in the memo DOM element.
* @async
* @param {Object} memo
* @param {HTMLElement} note
*/
async updateMemoTxt(memo, note) {
// Sélectionne les éléments existants
const userCreateEl = note.querySelector('.quickmemo-info__user-create-name');
const dateCreateEl = note.querySelector('.quickmemo-info__date_create');
const dateUpdateEl = note.querySelector('.quickmemo-info__date_update');
const userChangeEl = note.querySelector('.quickmemo-info__user-change-name');
// Mets à jour la création
if (userCreateEl) userCreateEl.textContent = memo.user_name || '';
if (dateCreateEl) dateCreateEl.textContent = memo.date_creation || '';
// Détermine si des changements existent
const hasDateChange = memo.date_change && memo.date_change.trim() !== '' && memo.date_creation !== memo.date_change;
const hasUserChange = memo.user_change_name && memo.user_change_name.trim() !== '' && memo.user_name !== memo.user_change_name;
// Mets à jour ou vide les infos de mise à jour
if (dateUpdateEl) {
dateUpdateEl.textContent = hasDateChange ? `${await Dolibarr.tools.langs.transNoEntities('QuickMemoModified')} : ${memo.date_change}` : '';
}
if (userChangeEl) {
userChangeEl.textContent = hasUserChange ? `${await Dolibarr.tools.langs.transNoEntities('QuickMemoBy')} ${memo.user_change_name}` : '';
}
}
/**
* Generates a button for the template list based on a model object.
* @param {Object} model
* @returns {HTMLElement} The created button.
*/
generateAddMemoBtn(model) {
const dropdown = document.getElementById(this.param.menuDropDownId);
const defaultModel = {
id: false,
name: "",
color: null,
note: "",
pos_x: null,
pos_y: null,
pos_w: null,
pos_h: null,
pos_z: null,
rank_tpl: 0,
shared_on_element: "0",
private: 0,
date_creation: "",
fk_user_creat: "",
user_name: "",
date_change: "",
user_change_name: ""
};
model = {...defaultModel, ...model};
const btn = document.createElement('button');
btn.className = 'quickmemo-model-btn';
btn.style.background = model.color || '#fff8a6';
btn.dataset.modelId = model.id;
btn.title = Dolibarr.tools.langs.transNoEntities('QuickMemoAdd')
+ (model.name ? ` : ${model.name}` : '');
// if(model.name) {
// btn.title = `${model.name}`
// this.initTooltips(btn);
// }
// ===== création du bouton delete =====
const deleteBtn = document.createElement('span');
deleteBtn.className = 'quickmemo-model-delete';
deleteBtn.title = Dolibarr.tools.langs.transNoEntities('QuickMemoDeleteModel')
btn.dataset.rank = model.rank_tpl || 0;
btn.appendChild(deleteBtn);
// DELETE
if (this.param.userDeleteRight && model.id) {
// Hover delay
let hoverTimer = null;
btn.addEventListener('mouseenter', (e) => {
// If shiftKey is pressed, direct show
if (e.shiftKey) {
clearTimeout(hoverTimer); // In case of a timer already active
deleteBtn.classList.add('--show');
} else {
// instead run timer
hoverTimer = setTimeout(() => {
deleteBtn.classList.add('--show');
}, 800);
}
});
btn.addEventListener('mouseleave', () => {
clearTimeout(hoverTimer);
deleteBtn.classList.remove('--show');
});
// Delete model
deleteBtn.addEventListener('click', async (e) => {
e.stopPropagation();
e.preventDefault();
// Fast delete if shiftKey is pressed, otherwise ask for confirmation
if (!e.shiftKey) {
const confirm = await this.openConfirmDialog(
await Dolibarr.tools.langs.trans('QuickMemoConfirmDeleteModel') + (model.name ? ' <br/> <strong>' + this.escapeHTML(model.name) + '</strong>' : ''),
{
dialogClass: '--dialog-danger',
btnConfirmClass: 'dialog-btn-destructive'
}
);
if (!confirm) return;
}
try {
const res = await fetch(this.param.interfaceUrl + '?action=delete_model', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: model.id,
token: this.param.token
})
});
const json = await res.json();
if (!json.result) throw new Error(json.msg);
btn.remove();
} catch (err) {
Dolibarr.tools.setEventMessage(
Dolibarr.tools.langs.trans('QuickMemoDeleteModelError') + ' : ' + err,
'errors'
);
}
});
}
// Create memo on template click
btn.addEventListener('click', e => {
e.preventDefault();
this.createMemoFromModel(model);
dropdown.classList.remove('open');
});
return btn;
}
/**
* Initialize standard tooltips.
*
* @param {HTMLElement|jQuery} root
*/
initTooltips(root) {
if (!root) return;
root.classList.add('dol-tooltip', '--dark-mode', '--tooltip-top');
// Convert title -> data-title au moment de l'init
const title = root.getAttribute('title');
if (title) {
root.setAttribute('data-title', title);
root.setAttribute('aria-label', title);
root.removeAttribute('title');
}
// Mise à jour à chaque survol (si le title a été modifié dynamiquement)
const syncTitle = () => {
const currentTitle = root.getAttribute('title');
if (currentTitle) {
root.setAttribute('data-title', currentTitle);
root.setAttribute('aria-label', currentTitle);
root.removeAttribute('title');
}
};
root.addEventListener('mouseenter', syncTitle);
root.addEventListener('focus', syncTitle);
// Stocker les handlers pour pouvoir les retirer proprement
root._quickmemoTooltipSync = syncTitle;
// JQUERY STYLE
// const tooltipClass = 'quick-memo-tooltip'
// const $el = jQuery(root);
// if ($el.data("ui-tooltip")) {
// $el.tooltip("destroy");
// }
//
// $el.tooltip({
// tooltipClass: tooltipClass,
// show: { collision: "flipfit", effect: "toggle", delay: 50, duration: 20 },
// hide: { delay: 250, duration: 20 },
// content: function () {
// return $el.prop("title");
// }
// });
}
/**
* remove tooltips.
*
* @param {HTMLElement|jQuery} root
*/
removeTooltips(root) {
if (!root) return;
root.classList.remove('dol-tooltip', '--dark-mode', '--tooltip-top');
// Restaurer title si data-title existe
const dataTitle = root.getAttribute('data-title');
if (dataTitle) {
root.setAttribute('title', dataTitle);
}
root.removeAttribute('data-title');
// Remove listeners
if (root._quickmemoTooltipSync) {
root.removeEventListener('mouseenter', root._quickmemoTooltipSync);
root.removeEventListener('focus', root._quickmemoTooltipSync);
delete root._quickmemoTooltipSync;
}
// JQUERY STYLE
// const $el = jQuery(root);
// if ($el.data("ui-tooltip")) {
// $el.tooltip("destroy");
// }
}
/**
* Initializes drag-and-drop reordering for the template list.
*/
initModelReorder() {
if (!this.param.userWriteRight) return;
const container = document.getElementById(this.param.menuDropDownModelListContainerId);
if (!container) return;
this._modelReorderContainer = container;
// Placeholder unique pour tout le container
if (!this._modelReorderPlaceholder) {
this._modelReorderPlaceholder = document.createElement('div');
this._modelReorderPlaceholder.className = 'quickmemo-model-placeholder';
}
container.querySelectorAll('.quickmemo-model-btn')
.forEach(btn => this.attachDragEventsForModelReorder(btn));
}
/**
* Attaches native drag events to a template button for sorting.
* @param {HTMLElement} btn
*/
attachDragEventsForModelReorder(btn) {
if (btn.dataset.reorderInit) return; // évite double bind
btn.dataset.reorderInit = '1';
const container = this._modelReorderContainer;
const placeholder = this._modelReorderPlaceholder; // utiliser le placeholder unique
btn.setAttribute('draggable', true);
let dragged = null;
btn.addEventListener('dragstart', (e) => {
dragged = btn;
btn.classList.add('--dragging');
placeholder.style.height = btn.offsetHeight + 'px';
container.insertBefore(placeholder, btn.nextSibling);
setTimeout(() => btn.style.display = 'none', 0);
});
btn.addEventListener('dragend', () => {
btn.classList.remove('--dragging');
btn.style.display = '';
if (!placeholder.parentNode) return;
container.insertBefore(dragged, placeholder);
placeholder.remove();
this.saveModelOrder(dragged.dataset.modelId);
});
btn.addEventListener('dragover', (e) => {
e.preventDefault();
const rect = btn.getBoundingClientRect();
const offset = e.clientY - rect.top;
if (offset < rect.height / 2) {
container.insertBefore(placeholder, btn);
} else {
container.insertBefore(placeholder, btn.nextSibling);
}
});
}
/**
* Persists the new sorting order of templates to the server.
* @async
* @param {number|string|null} movedId - ID of the element that was just moved.
*/
async saveModelOrder(movedId = null) {
const container = document.getElementById(this.param.menuDropDownModelListContainerId);
const buttons = [...container.querySelectorAll('.quickmemo-model-btn')];
// recalcul complet des positions à partir du DOM
const orderData = buttons.map((btn, index) => {
const newRank = buttons.length - index; // cohérent avec ton ORDER BY rank DESC
// on met à jour le dataset pour rester cohérent en mémoire
btn.dataset.rank = newRank;
return {
id: btn.dataset.modelId,
rank: newRank
};
});
const payload = {order: orderData};
// si on veut envoyer moved, on calcule sa vraie position
if (movedId !== null) {
const newIndex = buttons.findIndex(
btn => btn.dataset.modelId == movedId
);
if (newIndex !== -1) {
payload.moved = {
id: movedId,
newPos: newIndex + 1 // position visuelle 1 based
};
}
}
try {
const res = await fetch(
this.param.interfaceUrl + '?action=update_model_rank&token=' + this.param.token,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}
);
const json = await res.json();
if (!json.result) throw new Error(json.msg);
} catch (err) {
console.error('QuickMemo save model order error', err);
Dolibarr.tools.setEventMessage(
Dolibarr.tools.langs.trans('QuickMemoSaveModelOrderError') + ' : ' + err,
'errors'
);
}
}
/**
* Builds and injects the top menu dropdown into the Dolibarr interface.
* @async
*/
async initMenuMemoDropdown() {
const DOL_VERSION = parseInt(Dolibarr.getContextVar('DOL_VERSION', false));
const menuContainer = document.querySelector(DOL_VERSION > 20 || !DOL_VERSION ? '#id-top .login_block_tools .inline-block' : '#id-top .inline-block .login_block_elem');
if (!menuContainer) return;
const dropdown = document.createElement('div');
dropdown.className = 'dropdown inline-block mod-quick-memo-menu';
dropdown.id = 'quickmemo-create-dropdown'
let textLoading = await Dolibarr.tools.langs.trans('Loading');
let textNewNote = await Dolibarr.tools.langs.trans('NewNote');
dropdown.innerHTML = `
<a class="dropdown-toggle login-dropdown-a nofocusvisible" data-toggle="dropdown" href="#" title="${textNewNote}" >
<i class="fa fa-sticky-note" aria-hidden="true"></i>
</a>
<div class="dropdown-menu dropdown-quickmemo-create">
<div class="dropdown-header">
<span class="dropdown-header-title">${await Dolibarr.tools.langs.trans('QuickMemoDropDownTitle')}</span>
<span class="dropdown-header-actions"></span>
</div>
<div class="dropdown-body ">
<div class="quickmemo-tpl-title">
${await Dolibarr.tools.langs.trans('QuickMemoColorsPresets')}
</div>
<div id="${this.param.menuDropDownPresetListContainerId}" class="quickmemo-tpl-list">
<div class="loading">${textLoading}...</div>
</div>
<div class="quickmemo-tpl-title">
${await Dolibarr.tools.langs.trans('QuickMemoSavedTemplate')}
</div>
<div id="${this.param.menuDropDownModelListContainerId}" class="quickmemo-tpl-list">
<div class="loading">${textLoading}...</div>
</div>
${this.param.archivesUrl ? `
<div class="quickmemo-archive-list">
<a id="quick-note-archives-link" class="top-menu-dropdown-quicknote-link" href="${this.param.archivesUrl}" >${await Dolibarr.tools.langs.trans('QuickMemoShowArchives')}</a>
<span id="quick-note-archives-link-mumber" class="badge badge-pill badge-light" >${this.countedArchivedMemo}</span>
</div>
` : ''}
</div>
<div class="dropdown-footer">
</div>
</div>
`;
menuContainer.prepend(dropdown);
const dropDownMenuToggleBtn = dropdown.querySelector('.dropdown-toggle');
const presetListDiv = document.getElementById(this.param.menuDropDownPresetListContainerId);
presetListDiv.innerHTML = '';
const modelListDiv = document.getElementById(this.param.menuDropDownModelListContainerId);
modelListDiv.innerHTML = '';
dropdown.setAttribute('data-loaded', 0);
// Toggle ouverture / fermeture
dropDownMenuToggleBtn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
// LOAD DATA IN DROPDOWN MENU
this.fetchMenuDropDown();
// OPEN DROP DOWN
dropdown.classList.toggle('open');
});
document.addEventListener('click', e => {
if (!dropdown.contains(e.target)) {
dropdown.classList.remove('open');
}
});
const btnHelp = document.createElement('button');
btnHelp.type = 'button';
btnHelp.className = 'quickmemo-dropdown-btn btn-low-emphasis --btn-icon';
btnHelp.innerHTML = `<i class="fa fa-question-circle" ></i>`;
btnHelp.title = Dolibarr.tools.langs.transNoEntities('Help');
const dropDownMenuHeader = dropdown.querySelector('.dropdown-quickmemo-create .dropdown-header .dropdown-header-actions');
dropDownMenuHeader.appendChild(btnHelp);
btnHelp.addEventListener('click', () => {
const confirmOptions = {
title: Dolibarr.tools.langs.trans('QuickMemoHelp_Title'),
confirmLabel: Dolibarr.tools.langs.trans('CloseDialog'),
customClass: 'dialog-lg',
};
this.openSimpleDialog(this.getDropDownHelp(), confirmOptions);
});
Dolibarr.executeHook('QuickMemo:initMenuMemoDropdown', {dropdown});
}
/**
* Fetches available presets and user-created models from the server for the dropdown menu.
* @async
* @param {boolean} force - Force refresh even if already loaded.
*/
async fetchMenuDropDown(force = false) {
const dropDown = document.getElementById(this.param.menuDropDownId);
const dropDownMenuToggleBtn = dropDown.querySelector('.dropdown-toggle');
const presetListDiv = document.getElementById(this.param.menuDropDownPresetListContainerId);
const modelListDiv = document.getElementById(this.param.menuDropDownModelListContainerId);
// Check attribute flag, used to check if data already loaded ONCE
if (dropDown.getAttribute('data-loaded') === "1" && !force) {
return;
}
// Fetch models
try {
const res = await fetch(this.param.interfaceUrl + '?action=list_models', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
element_type: this.param.elementType,
context: this.param.context,
token: this.param.token
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
}
if (!json.data) {
throw new Error('QuickMemo list models get empty data');
}
let models = json.data.modelTemplate.length ? json.data.modelTemplate : [];
let presets = json.data.presetTemplate.length ? json.data.presetTemplate : [];
// CREATE BTN TEMPLATE LIST
if (!models.length) {
modelListDiv.innerHTML = '<div class="no-models">' + await Dolibarr.tools.langs.trans('NoTemplatesFound') + '</div>';
} else {
models.forEach(model => {
const btn = this.generateAddMemoBtn(model);
modelListDiv.appendChild(btn);
});
}
this.initModelReorder();
// CREATE BTN FOR COLOR PRESETS LIST
if (!presets.length) {
modelListDiv.innerHTML = '<div class="no-models">' + await Dolibarr.tools.langs.trans('NoPresetsFound') + '</div>';
} else {
presets.forEach(preset => {
const btn = this.generateAddMemoBtn(preset);
presetListDiv.appendChild(btn);
});
// Quick add default note on double click
dropDownMenuToggleBtn.addEventListener('dblclick', e => {
e.preventDefault();
this.createMemoFromModel(presets[0]);
dropdown.classList.remove('open');
});
}
// Set loaded data flag
dropDown.setAttribute('data-loaded', 1);
} catch (err) {
Dolibarr.tools.setEventMessage(Dolibarr.tools.langs.trans('QuickMemoLoadModelError') + ' : ' + err, 'errors');
console.error('QuickMemo load models error', err);
}
}
/* ===============================
CREATION MEMO DEPUIS MODELE
================================= */
/**
* Creates a new note based on a specific template (model).
* @async
* @param {Object} model - The template data.
*/
async createMemoFromModel(model) {
// 1. Trouver le z-index maximum actuel parmi les notes affichées
const allMemos = this.container.querySelectorAll('.quick-memo-note'); // Adaptez le sélecteur si besoin
let maxZ = 0;
allMemos.forEach(el => {
const z = parseInt(el.style.zIndex) || 0;
if (z > maxZ) maxZ = z;
});
// La nouvelle note doit être au-dessus de tout le monde
const newZ = maxZ + 1;
// Crée un objet memo minimal à partir du modèle
const memo = {
id: 0, // rowid temporaire avant sauvegarde
deleteBtn: false,
note: model.note || '',
pos_w: model.pos_w || 250, // largeur par défaut
pos_h: model.pos_h || 250, // hauteur par défaut
pos_x: model.pos_x || 0,
pos_y: model.pos_y || 0,
pos_z: newZ || 0,
color: model.color || '#fff8a6',
shared_on_element: model.shared_on_element !== undefined ? model.shared_on_element : 0,
private: model.private !== undefined ? parseInt(model.private) : 0
};
// Center the new note in the current viewport
const containerWidth = this.container.clientWidth;
const containerHeight = this.container.clientHeight;
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY || window.pageYOffset;
memo.pos_x = Math.max(32, (containerWidth - memo.pos_w) / 2);
memo.pos_y = scrollY + Math.max(32, (viewportHeight - memo.pos_h) / 2);
// Render le memo
const noteEl = await this.renderMemo(memo);
this.bringToTop(noteEl);
// Focus sur le textarea
const textarea = noteEl.querySelector('textarea');
if (textarea) {
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
}
try {
const res = await fetch(this.param.interfaceUrl + '?action=create', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
element_id: this.param.elementId,
element_type: this.param.elementType,
context: this.param.context,
note: memo.note,
color: memo.color,
private: memo.private,
shared_on_element: memo.shared_on_element,
token: this.param.token,
x: parseInt(noteEl.style.left),
y: parseInt(noteEl.style.top),
w: parseInt(noteEl.style.width),
h: parseInt(noteEl.style.height),
z: parseInt(memo.pos_z)
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
return;
}
if (json.data && json.data.id) {
memo.id = json.data.id;
noteEl.dataset.id = json.data.id;
}
if (json.data && json.data.memo) {
await this.updateMemoTxt(json.data.memo, noteEl);
}
Dolibarr.executeHook('QuickMemo:createMemoFromModel', {memo, noteEl, json});
} catch (err) {
Dolibarr.tools.setEventMessage(Dolibarr.tools.langs.trans('QuickMemoCreateError') + ' : ' + err, 'errors');
console.error('QuickMemo create error', err);
}
}
/**
* Escapes special HTML characters for safe rendering.
* @param {string} str
* @returns {string}
*/
escapeHTML(str) {
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* A basic markdown parser that handles titles, checkboxes, links, bold and italic.
* @async
* @param {string} md - The raw markdown text.
* @returns {string} The resulting HTML string.
*/
async simpleMarkdown(md) {
/**
* Handles inline styles like Bold, Italic, Underline, and Links.
* Can be used inside headers and checkboxes.
*/
const parseInline = (text) => {
if (!text) return '';
return this.escapeHTML(text)
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') // **Bold**
.replace(/__(.*?)__/g, '<u>$1</u>') // __Underline__
.replace(/\*(.*?)\*/g, '<em>$1</em>') // *Italique*
.replace(/\[(.*?)\]\((.*?)\)/g, (full, txt, url) => { // [Link](url)
const safeUrl = /^(https?:\/\/|#|\/)/.test(url) ? url : '#';
return `<a href="${this.escapeHTML(safeUrl)}" target="_blank" rel="noopener">${this.escapeHTML(txt)}</a>`;
});
};
const lines = md.split('\n');
// Use .map to ensure every input line creates exactly one .markdown-line output
let res = lines.map((line, idx) => {
let html = '';
let isEmpty = false;
let match;
const nextLine = (lines[idx + 1] || "").trim();
const prevLine = (lines[idx - 1] || "").trim();
const trimmedLine = line.trim();
// CASE 1: Setext H1 - Current line is text followed by "==="
if (trimmedLine !== "" && nextLine.match(/^={3,}$/)) {
html = `<h1>${parseInline(line)}</h1>`;
}
// CASE 2: Setext H2 - Current line is text followed by "---"
else if (trimmedLine !== "" && nextLine.match(/^-{3,}$/)) {
html = `<h2>${parseInline(line)}</h2>`;
}
// CASE 3: The Underline line itself (=== or ---)
// We check if the line above was a title to decide if we hide this line
else if (trimmedLine.match(/^={3,}$/) && prevLine !== "") {
html = ''; // Hide the syntax line
isEmpty = true;
} else if (trimmedLine.match(/^-{3,}$/) && prevLine !== "") {
html = ''; // Hide the syntax line
isEmpty = true;
}
// CASE 4: Standard ATX Titles (#, ##, ###)
else if (match = line.match(/^(#{1,3})\s+(.*)$/)) {
const level = match[1].length;
html = `<h${level}>${parseInline(match[2])}</h${level}>`;
}
// CASE 5: Checkboxes [ ] or [x]
else if (match = line.match(/^\[([ xX])\]\s+(.*)$/)) {
const checked = match[1].toLowerCase() === 'x' ? 'checked' : '';
html = `<input class="markdown-checkbox" type="checkbox" ${checked}> ${parseInline(match[2])}`;
}
// CASE 6: Regular Text or actually empty line
else {
if (trimmedLine === "") {
html = '&nbsp;';
isEmpty = true;
} else {
html = parseInline(line);
}
}
// Maintain the div structure for scrolling/indexing consistency
const classes = `markdown-line ${isEmpty ? 'empty-line' : ''}`;
return `<div class="${classes}" data-line="${idx}">${html}</div>`;
}).join('');
// Hook for external extensions
if (Dolibarr && Dolibarr.executeHookAwait) {
await Dolibarr.executeHookAwait('QuickMemo:simpleMarkdown', {md, res});
}
return res;
}
/**
* Handles Toggle for Bold and Italic without infinite accumulation.
* @param {string} text - The selected text
* @param {string} type - 'bold' or 'italic'
* @returns {string} Processed text
*/
/**
* Toggles inline markdown with support for overlapping styles (***bold italic***)
*/
toggleInlineMarkdown(text, type) {
const patterns = {
bold: { tag: '**', regex: /^\*\*(.*)\*\*$/ },
italic: { tag: '*', regex: /^(\*\*\*|(?<!\*)\*(?!\*))(.*)(\*\*\*|(?<!\*)\*(?!\*))$/ },
underline: { tag: '__', regex: /^__(.*)__$/ }
};
const { tag, regex } = patterns[type];
// Special case for Italic to not conflict with Bold
if (type === 'italic') {
// If it starts with *** (Bold+Italic) or exactly * (Italic only)
if ((text.startsWith('***') && text.endsWith('***')) ||
(text.startsWith('*') && !text.startsWith('**') && text.endsWith('*') && !text.endsWith('**'))) {
return text.substring(1, text.length - 1);
}
return `*${text}*`;
}
// Standard case for Bold and Underline
if (text.startsWith(tag) && text.endsWith(tag)) {
return text.substring(tag.length, text.length - tag.length);
}
return tag + text + tag;
}
/**
* Inserts text at cursor position while trying to preserve Undo/Redo history.
* Falls back to setRangeText if execCommand is not supported.
* * @param {HTMLTextAreaElement} textarea
* @param {string} text - The text to insert
* @param {number} selectionMode - 'select', 'start', 'end', or 'preserve'
*/
/**
* Future-proof text insertion that respects the Undo stack.
* @param {HTMLTextAreaElement} textarea
* @param {string} text - The text to insert
* @param {string} selectionMode - 'select', 'start', 'end', or 'preserve' (for setRangeText)
* @param {boolean} forceReselect - Manual override to keep selection after execCommand
*/
safeInsertText(textarea, text, selectionMode = 'select', forceReselect = false) {
textarea.focus();
const start = textarea.selectionStart;
let success = false;
// 1. Try native command for Undo/Redo support
if (document.queryCommandSupported && document.queryCommandSupported('insertText')) {
success = document.execCommand('insertText', false, text);
}
// 2. Fallback to modern API (SelectionMode is used here)
if (!success) {
// selectionMode 'select' keeps the new text highlighted
textarea.setRangeText(text, start, textarea.selectionEnd, selectionMode);
// We must manually trigger input for the preview to update
textarea.dispatchEvent(new Event('input', { bubbles: true }));
}
// 3. Manual override for execCommand
// Because execCommand usually moves the cursor to the end,
// we force a re-selection if requested (very useful for Bold/Italic)
if (forceReselect !== false) {
if (forceReselect === 'select') {
textarea.setSelectionRange(start, start + text.length);
return;
}
else if (forceReselect === 'start') {
textarea.setSelectionRange(start, start);
return;
}
else if (forceReselect === 'end') {
textarea.setSelectionRange(start + text.length, start + text.length);
return;
}
}
if (forceReselect === 'select' || (success && selectionMode === 'select')) {
textarea.setSelectionRange(start, start + text.length);
}
}
/**
* Applies Markdown formatting while preserving Undo history.
* Supports inline toggling (bold, italic, underline) and block toggling (h1, h2, h3, task).
* * @param {HTMLTextAreaElement} textarea - The source textarea
* @param {string} type - Formatting type (bold, italic, underline, h1, h2, h3, task)
* @param {HTMLElement} toolbar - Toolbar element for state updates
*/
applyMarkdown(textarea, type, toolbar) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const val = textarea.value;
const selectedText = val.substring(start, end);
const inlineTags = { bold: '**', italic: '*', underline: '__' };
// --- 1. Inline Formatting Logic ---
if (inlineTags[type]) {
const tag = inlineTags[type];
let replacementText = "";
let newCursorPos = start;
if (selectedText.length > 0) {
// Toggle existing formatting on selected text
replacementText = this.toggleInlineMarkdown(selectedText, type);
} else {
// No selection: create a pair of tags and place cursor inside
replacementText = tag + tag;
newCursorPos = start + tag.length;
}
this.safeInsertText(textarea, replacementText, 'select');
// Adjust cursor position if we just inserted empty tags (e.g., **|**)
if (selectedText.length === 0) {
textarea.setSelectionRange(newCursorPos, newCursorPos);
}
}
// --- 2. Block/Line-Based Formatting Logic ---
else {
// Find line boundaries
const lineStart = val.lastIndexOf('\n', start - 1) + 1;
let lineEnd = val.indexOf('\n', end);
if (lineEnd === -1) lineEnd = val.length;
const lineText = val.substring(lineStart, lineEnd);
const prefixes = { h1: '# ', h2: '## ', h3: '### ', task: '[ ] ' };
const currentPrefix = prefixes[type];
// Regex to match existing headers or checkboxes to allow "swapping" styles
const prefixRegex = /^(\s*#{1,3}\s+|\[[ xX]\]\s+)/;
let newLineText;
if (lineText.startsWith(currentPrefix)) {
// Toggle OFF: Remove the prefix
newLineText = lineText.replace(currentPrefix, '');
} else {
// Toggle ON: Strip any existing block prefix and add the new one
const cleanLine = lineText.replace(prefixRegex, '');
newLineText = currentPrefix + cleanLine;
}
// Select the whole line and replace it via safeInsert to keep Undo history
textarea.setSelectionRange(lineStart, lineEnd);
this.safeInsertText(textarea, newLineText, 'end', (lineStart === lineEnd ? 'end' : 'start'));
// Special UX: If it's a new empty task, put cursor after the checkbox
if (type === 'task' && lineText.trim() === "") {
const newCursorPos = lineStart + currentPrefix.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}
}
// Update Toolbar & UI
if (this.updateToolbarState) {
this.updateToolbarState(textarea, toolbar);
}
}
/**
* Updates toolbar buttons' active state based on the current selection or cursor position.
* Optimized for Post-it notes with support for Setext and Underline.
*/
updateToolbarState(textarea, toolbar) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = textarea.value;
const selectedText = text.substring(start, end);
/**
* Helper to detect if the current context is formatted.
* It checks both the selection and the immediate surrounding characters.
*/
const isFormatActive = (type) => {
// 1. Check if selection is wrapped
if (selectedText.length > 0) {
if (type === 'bold') return selectedText.startsWith('**') && selectedText.endsWith('**');
if (type === 'underline') return selectedText.startsWith('__') && selectedText.endsWith('__');
if (type === 'italic') {
return (selectedText.startsWith('***') && selectedText.endsWith('***')) ||
(selectedText.startsWith('*') && !selectedText.startsWith('**'));
}
}
// 2. Check cursor context (peek 3 chars around)
const before = text.substring(start - 3, start);
const after = text.substring(end, end + 3);
const context = before + "|" + after;
if (type === 'bold') return context.includes('**|**') || context.includes('***|***');
if (type === 'italic') return context.includes('*|*') || context.includes('***|***');
if (type === 'underline') return context.includes('__|__');
return false;
};
// Update UI
const states = {
bold: isFormatActive('bold'),
italic: isFormatActive('italic'),
underline: isFormatActive('underline')
};
Object.keys(states).forEach(type => {
toolbar.querySelector(`[data-type="${type}"]`)?.classList.toggle('--active', states[type]);
});
// --- Line-based logic (H1, H2, Tasks) remains the same ---
const lineStart = text.substring(0, start).lastIndexOf('\n') + 1;
const currentLine = text.substring(lineStart, text.indexOf('\n', start) === -1 ? text.length : text.indexOf('\n', start)).trim();
toolbar.querySelector('[data-type="h1"]')?.classList.toggle('--active', currentLine.startsWith('# '));
toolbar.querySelector('[data-type="h2"]')?.classList.toggle('--active', currentLine.startsWith('## '));
toolbar.querySelector('[data-type="h3"]')?.classList.toggle('--active', currentLine.startsWith('### '));
toolbar.querySelector('[data-type="task"]')?.classList.toggle('--active', /^\[[ xX]\] /.test(currentLine));
}
getDropDownHelp() {
return `
<div class="quickmemo-help-content">
<div class="help-section">
<h4><i class="fa fa-eye-slash"></i> ${Dolibarr.tools.langs.trans('QuickMemoHelp_Visibility')}</h4>
<p>${Dolibarr.tools.langs.trans('QuickMemoHelp_EscKey')}</p>
</div>
<div class="help-section">
<h4><i class="far fa-copy"></i> ${Dolibarr.tools.langs.trans('QuickMemoHelp_Management')}</h4>
<ul>
<li>${Dolibarr.tools.langs.trans('QuickMemoHelp_DeleteHover')}</li>
<li>${Dolibarr.tools.langs.trans('QuickMemoHelp_ShiftKeyDelete')}</li>
</ul>
</div>
<div class="help-section">
<h4><i class="fa fa-sort-numeric-down"></i> ${Dolibarr.tools.langs.trans('QuickMemoHelp_Organization')}</h4>
<p>${Dolibarr.tools.langs.trans('QuickMemoHelp_DragDrop')}</p>
</div>
<p class="help-footer"><em>${Dolibarr.tools.langs.trans('QuickMemoHelp_Footer')}</em></p>
</div>
`;
}
/**
* Returns a help string describing the supported markdown syntax.
* @returns {string}
*/
getMarkDownHelp() {
return `
<div class="quickmemo-help-content">
<div class="help-section universal-logic">
<h4><i class="fa fa-globe"></i> ${Dolibarr.tools.langs.trans('QuickMemoMd_SectionUniversal')}</h4>
<p>${Dolibarr.tools.langs.trans('QuickMemoMd_UniversalDesc')}</p>
<p class="help-intro"><strong>${Dolibarr.tools.langs.trans('QuickMemoMd_HelpIntro')}</strong></p>
</div>
<div class="help-section">
<h4><i class="fa fa-text-height"></i> ${Dolibarr.tools.langs.trans('QuickMemoMd_SectionStyle')}</h4>
<code>*${Dolibarr.tools.langs.trans('QuickMemoMd_Italic')}*</code> => <em>${Dolibarr.tools.langs.trans('QuickMemoMd_Italic')}</em><br/>
<code>**${Dolibarr.tools.langs.trans('QuickMemoMd_Bold')}**</code> => <strong>${Dolibarr.tools.langs.trans('QuickMemoMd_Bold')}</strong>
</div>
<div class="help-section">
<h4><i class="fa fa-heading"></i> ${Dolibarr.tools.langs.trans('QuickMemoMd_SectionTitles')}</h4>
<code># ${Dolibarr.tools.langs.trans('QuickMemoMd_Title')} 1</code><br/>
<code>## ${Dolibarr.tools.langs.trans('QuickMemoMd_Title')} 2</code><br/>
<code>### ${Dolibarr.tools.langs.trans('QuickMemoMd_Title')} 3</code>
</div>
<div class="help-section">
<h4><i class="fa fa-check-square"></i> ${Dolibarr.tools.langs.trans('QuickMemoMd_SectionTasks')}</h4>
<code>[ ] ${Dolibarr.tools.langs.trans('QuickMemoMd_TaskNotDone')}</code> => <input type="checkbox" disabled> ...<br/>
<code>[x] ${Dolibarr.tools.langs.trans('QuickMemoMd_TaskDone')}</code> => <input type="checkbox" checked disabled> ...
</div>
<div class="help-section">
<h4><i class="fa fa-link"></i> ${Dolibarr.tools.langs.trans('QuickMemoMd_SectionLinks')}</h4>
<code>[${Dolibarr.tools.langs.trans('QuickMemoMd_LinkText')}](url)</code> => <a href="#" onclick="return false;">${Dolibarr.tools.langs.trans('QuickMemoMd_LinkText')}</a>
</div>
<p class="help-footer"><em>${Dolibarr.tools.langs.trans('QuickMemoMd_HelpFooter')}</em></p>
</div>
`;
}
/**
* Opens a modal dialog to define parameters before creating a new template from a note.
* @param {Object} memo
* @param {HTMLElement} note
*/
openCreateModelDialog(memo, note) {
const dialog = document.createElement('dialog');
dialog.className = 'quickmemo-modal noselect';
dialog.innerHTML = `
<form>
<header>
<h3 class="quickmemo-modal-title">${Dolibarr.tools.langs.trans('QuickMemoAskCreateAModelFromThisMemo')}</h3>
</header>
<section>
<div class="quickmemo-modal-input-group">
<label class="quickmemo-modal-label">
${Dolibarr.tools.langs.trans('QuickMemoModelName')}
</label>
<input
class="quickmemo-modal-text-input"
type="text"
name="modelName"
required
placeholder="${Dolibarr.tools.langs.transNoEntities('QuickMemoModelNamePlaceHolder')}"
/>
</div>
${this.param.elementId > 0 ? `
<div class="quickmemo-modal-input-group">
<label class="quickmemo-modal-label">
<input type="checkbox" name="commonElementType" value="1" />
${Dolibarr.tools.langs.trans('QuickMemoAskLabelModelMemoIsCommon')}
</label>
</div>
` : `<input type="hidden" name="commonElementType" value="0" />` }
<div class="quickmemo-modal-input-group">
<label class="quickmemo-modal-label">
<input type="checkbox" name="privateModel" checked />
${Dolibarr.tools.langs.trans('QuickMemoAskLabelModelMemoIsPrivate')}
</label>
</div>
</section>
<footer class="quickmemo-modal-actions">
<button type="button" class="dialog-btn dialog-btn-secondary quickmemo-modal-actions-cancel-btn">
${Dolibarr.tools.langs.trans('Cancel')}
</button>
<button type="button" class="dialog-btn dialog-btn-primary quickmemo-modal-actions-confirm-btn">
${Dolibarr.tools.langs.trans('Confirm')}
</button>
</footer>
</form>
`;
document.body.appendChild(dialog);
// Quand le dialog se ferme (Esc, cancel, close()), on le détruit
dialog.addEventListener('close', () => {
dialog.remove();
});
// Empêche le close automatique
dialog.addEventListener('cancel', (e) => {
e.preventDefault(); // optionnel
dialog.close();
});
// Bouton Annuler
dialog.querySelector('.quickmemo-modal-actions-cancel-btn')
.addEventListener('click', () => {
dialog.close();
dialog.remove();
});
// Confirmer
dialog.querySelector('.quickmemo-modal-actions-confirm-btn')
.addEventListener('click', async () => {
const commonInput = dialog.querySelector('[name=commonElementType]');
const TplCommon = commonInput
? (commonInput.type === 'checkbox' ? commonInput.checked : commonInput.value === '1')
: false;
const TplPrivate = dialog.querySelector('[name=privateModel]').checked;
const TplModelName = dialog.querySelector('[name=modelName]').value || '';
const elementType = TplCommon ? '' : this.param.elementType || '';
let hookRes = await Dolibarr.executeHookAwait('QuickMemo:openCreateModelDialog:confirm', {
dialog,
memo,
note,
TplCommon,
TplPrivate,
TplModelName,
elementType
});
if (hookRes > 0) {
return;
}
try {
const res = await fetch(this.param.interfaceUrl + '?action=create_model', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams({
id: memo.id,
element_type: elementType,
token: this.param.token,
tpl_private: TplPrivate,
tpl_name: TplModelName
})
});
const json = await res.json();
if (!json.result) {
throw new Error(json.msg);
}
Dolibarr.executeHook('QuickMemo:openCreateModelDialog:ModelCreated', {
dialog,
memo,
note,
TplCommon,
TplPrivate,
TplModelName,
elementType,
json
});
} catch (err) {
Dolibarr.tools.setEventMessage(
Dolibarr.tools.langs.trans('QuickMemoCreateError') + ' : ' + err,
'errors'
);
console.error('Erreur fetch création modèle', err);
} finally {
dialog.close();
this.convertToModelWithAnimation(memo, note);
}
});
Dolibarr.executeHook('QuickMemo:openCreateModelDialog', {dialog, memo, note});
dialog.showModal();
}
/**
* Specific confirmation dialog wrapper.
* @param {string} message - HTML content.
* @param {Object} param - Overrides for the confirmation UI.
* @returns {Promise<boolean>}
*/
openConfirmDialog(message, param = {}) {
// Map old param names to the new config names if necessary,
// or just set confirmation defaults.
const confirmOptions = {
title: param.dialogTitle || Dolibarr.tools.langs.trans('DialogAskConfirm'),
customClass: 'noselect' + ( param.dialogClass || ''),
confirmLabel: param.btnConfirmTxt || Dolibarr.tools.langs.trans('Confirm'),
confirmClass: param.btnConfirmClass || 'btn-primary',
btn: {
confirm: true,
cancel: true,
close: true
},
};
return this.openSimpleDialog(message, confirmOptions);
}
/**
* Generic dialog engine based on HTML5 <dialog>.
* @param {string} message - HTML content to display.
* @param {Object} options - UI parameters.
* @returns {Promise<boolean>} Resolves to true if confirmed, false otherwise.
*/
async openSimpleDialog(message, options = {}) {
const config = {
title: '',
customClass: '',
confirmLabel: Dolibarr.tools.langs.trans('Confirm'),
confirmClass: 'btn-primary',
cancelLabel: Dolibarr.tools.langs.trans('Cancel'),
...options,
btn: {
close: true,
confirm: true,
cancel: false,
...(options.btn || {}) // Merge seulement les boutons précisés dans options
}
};
return new Promise((resolve) => {
const dialog = document.createElement('dialog');
dialog.className = `quickmemo-modal fade-dialog ${config.customClass}`;
dialog.innerHTML = `
<div class="dialog-content">
<header >
<h3 class="quickmemo-modal-title">${config.title}</h3>
<div class="header-actions noselect">
${config.btn.close ? `
<button type="button" value="cancel" class="dialog-close-icon">
&times;
</button>` : ''}
</div>
</header>
<section class="dialog-body">
${message}
</section>
<footer class="quickmemo-modal-actions noselect">
${config.btn.cancel ? `
<button type="button" value="cancel" class="dialog-btn btn-secondary">
${config.cancelLabel}
</button>` : ''}
${config.btn.confirm ? `
<button type="button" value="confirm" class="dialog-btn ${config.confirmClass}">
${config.confirmLabel}
</button>` : ''}
</footer>
</div>
`;
document.body.appendChild(dialog);
dialog.showModal();
const handleClose = (result) => {
dialog.close();
resolve(result);
};
// Unified event listener
dialog.addEventListener('click', (e) => {
const btn = e.target.closest('button[value]');
if (btn) return handleClose(btn.value === 'confirm');
if (e.target === dialog) handleClose(false); // Backdrop click
});
dialog.addEventListener('cancel', (e) => {
e.preventDefault();
handleClose(false);
});
dialog.addEventListener('close', () => dialog.remove());
});
}
}