moderation_note-8.x-1.0-beta3/js/moderation_note.js
js/moderation_note.js
/**
* @file
* Contains all Moderation Note behaviors.
*/
(function scope(Drupal, $, once) {
// Local variable to track when the view tooltip fades.
let viewTooltipTimeout;
/**
* Initializes the tooltip used to add new notes.
*
* @return {Object}
* The tooltip.
*/
function initializeAddTooltip() {
const $tooltip = $(
`<a class="moderation-note-tooltip use-ajax" href="javascript;" data-dialog-type="dialog" data-dialog-renderer="off_canvas">${Drupal.t(
'Add note',
)}</a>`,
).hide();
$('body').append($tooltip);
return $tooltip;
}
/**
* Wraps a given range in a <span> tag with the provided classes.
*
* @param {Range} range
* The given range.
* @param {String} classes
* Classes you want to add to the highlight, separated by a space.
* @return {Object}
* The jQuery object for the wrap (could contain multiple elements).
*/
function highliteRange(range, classes) {
const selection = window.getSelection();
document.designMode = 'on';
const { spellcheck } = document.body;
document.body.spellcheck = false;
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('hilitecolor', false, 'yellow');
document.designMode = 'off';
document.body.spellcheck = spellcheck;
const wrapRange = selection.getRangeAt(0);
const $wrap = $(wrapRange.startContainer.parentNode).add(
wrapRange.endContainer.parentNode,
);
// This is not a new span element.
if ($wrap[0].attributes.length > 1) {
$wrap.addClass('moderation-note-highlight-existing');
}
$wrap.removeAttr('style').addClass(classes);
selection.collapseToEnd();
return $wrap;
}
/**
* Finds the offset of a range relative to a given parent element.
*
* Modified from http://stackoverflow.com/a/11358084, written by benjamin-rögner.
*
* @param {Node} element
* The element to compare against. Defaults to body.
* @param {Range} range
* The range that requires comparison.
* @return {Number}
* The offset of the range.
*/
function getCursorPositionInTextOf(element, range) {
element = element || document.body;
const parentRange = document.createRange();
parentRange.setStart(element, 0);
parentRange.setEnd(range.startContainer, range.startOffset);
// Measure the length of the text from the start of the given element to
// the start of the current range (position of the cursor).
return parentRange.cloneContents().textContent.length;
}
/**
* Performs a text search within the page based on a given string.
*
* Modified from http://stackoverflow.com/a/5887719, written by @tpdown.
*
* @param {String} text
* The string to search for. Should not contain HTML.
* @param {Node} element
* The parent element to perform the search within. Defaults to body.
* @param {Number} offset
* The text offset from the start of the element to start the search.
* @param {String} id
* The unique ID of this search. Used to track fuzzy matches.
* @return {Range}
* The Range object of the successful search.
*/
function doSearch(text, element, offset, id) {
const scroll = $(window).scrollTop();
element = element || document.body;
offset = offset || 0;
let match;
let selection;
let range;
let currentOffset;
let currentDifference;
if (window.find && window.getSelection) {
text = text.replace('\r\n', '\n');
selection = window.getSelection();
selection.collapse(element, 0);
let offsetDifference = element.innerHTML.length;
while (window.find(text) && selection.rangeCount) {
range = selection.getRangeAt(0);
const $ancestor = $(range.commonAncestorContainer);
if ($ancestor.closest(element).length) {
currentOffset = getCursorPositionInTextOf(element, range);
currentDifference = Math.abs(currentOffset - offset);
if (currentDifference < offsetDifference) {
offsetDifference = currentDifference;
match = range;
}
} else {
break;
}
selection.collapseToEnd();
}
// If the match can't be found, select a similar text range.
if (!match) {
selection.collapse(element, 0);
const fuzzy = element.textContent.substr(offset, text.length);
if (window.find(fuzzy)) {
match = selection.getRangeAt(0);
Drupal.moderation_note.fuzzy_matches.push(id);
}
}
}
if (selection.rangeCount) {
selection.collapseToEnd();
}
$(window).scrollTop(scroll);
return match;
}
/**
* Removes the highlight and note data for a given wrapper element.
*
* @param {Object} $wrap
* The jQuery object for the wrap (could contain multiple elements).
*/
function removeHighlight($wrap) {
if ($wrap.is('.moderation-note-highlight-existing')) {
$wrap.removeClass(
'moderation-note-contextual-highlight moderation-note-highlight moderation-note-highlight-existing existing new',
);
$wrap.removeAttr('data-moderation-note-highlight-id');
$wrap.removeData('moderation-note-highlight-id');
$wrap.removeData('moderation-note');
$wrap.off('mouseover.moderation_note');
$wrap.off('mouseleave.moderation_note');
} else {
$wrap.contents().unwrap();
}
}
/**
* Removes all contextual highlights from the page.
*/
function removeContextHighlights() {
$('.moderation-note-contextual-highlight').each(
function removeEachContextualHighlight() {
if ($(this).data('moderation-note-highlight-id')) {
$(this).removeClass('moderation-note-contextual-highlight existing');
} else {
removeHighlight($(this));
}
},
);
}
/**
* Highlights focused text while the sidebar is open.
*
* @param {Object} note
* An objects representing a Moderation Note.
*/
function showContextHighlight(note) {
// Remove all existing context highlights.
removeContextHighlights();
// If this note is already highlighted, simply add a class.
if (note.id) {
const $note = $(`[data-moderation-note-highlight-id="${note.id}"]`);
if ($note.length) {
$note.addClass('moderation-note-contextual-highlight existing');
}
}
// Otherwise, we need to create a new highlight.
else {
const $field = $(`[data-moderation-note-field-id="${note.field_id}"]`);
const match = doSearch(note.quote, $field[0], note.quote_offset, note.id);
if (match) {
highliteRange(match, 'moderation-note-contextual-highlight new');
}
}
}
/**
* Initializes the tooltip used to view existing notes.
*
* @return {Object}
* The tooltip.
*/
function initializeViewTooltip() {
const $tooltip = $(
`<a class="moderation-note-tooltip use-ajax" href="javascript;" data-dialog-type="dialog" data-dialog-renderer="off_canvas">${Drupal.t(
'View note',
)}</a>`,
).hide();
$('body').append($tooltip);
// Click callback.
$tooltip.on('click', function onToolTipClick() {
$tooltip.hide();
showContextHighlight($tooltip.data('moderation-note'));
});
$tooltip.on('mouseleave', function onToolTipMouseLeave() {
clearTimeout(viewTooltipTimeout);
viewTooltipTimeout = setTimeout(function toolTipMouseLeaveSetTimeout() {
$tooltip.fadeOut('fast');
}, 500);
});
$tooltip.on('mousemove', function onToolTipMouseOver() {
$tooltip.finish().fadeIn();
clearTimeout(viewTooltipTimeout);
});
return $tooltip;
}
Drupal.moderation_note = Drupal.moderation_note || {
selection: {
quote: false,
quote_offset: false,
field_id: false,
},
notes: [],
add_tooltip: initializeAddTooltip(),
view_tooltip: initializeViewTooltip(),
fuzzy_matches: [],
};
/**
* Command to remove a Moderation Note.
*
* @param {Drupal.Ajax} [ajax]
* The ajax object.
* @param {Object} response
* Object holding the server response.
* @param {String} response.id
* The ID for the moderation note.
*/
Drupal.AjaxCommands.prototype.remove_moderation_note =
function removeModerationNote(ajax, response) {
const { id } = response;
const $wrap = $(`[data-moderation-note-highlight-id="${id}"]`);
if (Drupal.moderation_note.notes[response.id]) {
delete Drupal.moderation_note.notes[response.id];
}
removeHighlight($wrap);
};
/**
* Changes the URL for an ajaxified element.
*
* @param {Object} $element
* The ajaxified element you need to change the url for.
* @param {string} url
* The new url, without query params.
*/
function changeAjaxUrl($element, url) {
Object.values(Drupal.ajax.instances).forEach((instance) => {
if (instance && $element.is(instance.element)) {
instance.options.url = instance.options.url.replace(/.*\?/, `${url}?`);
}
});
}
/**
* Displays the tooltip at a position relative to the given element.
*
* @param {Object} $tooltip
* The tooltip.
* @param {Object} $element
* The element to display to tooltip on.
*/
function showViewTooltip($tooltip, $element) {
const widthOffset = $element.outerWidth() / 2 - $tooltip.outerWidth() / 2;
const offset = $element.offset();
$tooltip.css('left', offset.left + widthOffset);
$tooltip.css('top', offset.top - ($tooltip.outerHeight() + 5));
const id = $element.data('moderation-note-highlight-id');
const url = Drupal.formatString(Drupal.url('moderation-note/!id'), {
'!id': id,
});
$tooltip.attr('href', url);
changeAjaxUrl($tooltip, url);
$tooltip.data('moderation-note', $element.data('moderation-note'));
$tooltip.fadeIn('fast');
}
/**
* Shows the given moderation note as a highlighted range.
*
* @param {Object} note
* An objects representing a Moderation Note.
*/
function showModerationNote(note) {
// Remove all existing context highlights.
removeContextHighlights();
const $field = $(`[data-moderation-note-field-id="${note.field_id}"]`);
if ($field.length) {
const match = doSearch(note.quote, $field[0], note.quote_offset, note.id);
if (match) {
const $wrap = highliteRange(match, 'moderation-note-highlight');
// This allows notes to be found by their ID.
$wrap.attr('data-moderation-note-highlight-id', note.id);
$wrap.data('moderation-note', note);
const $viewTooltip = Drupal.moderation_note.view_tooltip;
$wrap.on('mouseover.moderation_note', function onMouseOverNote() {
showViewTooltip($viewTooltip, $(this));
$viewTooltip.stop().fadeIn();
clearTimeout(viewTooltipTimeout);
});
$wrap.on('mouseleave.moderation_note', function onMouseLeaveNote() {
clearTimeout(viewTooltipTimeout);
viewTooltipTimeout = setTimeout(function viewToolTipSetTimeout() {
$viewTooltip.fadeOut('fast');
}, 500);
});
}
}
}
/**
* Command to add a Moderation Note.
*
* @param {Drupal.Ajax} [ajax]
* The ajax object.
* @param {Object} response
* Object holding the server response.
* @param {Object} response.note
* An object representing a moderation note.
*/
Drupal.AjaxCommands.prototype.add_moderation_note =
function addModerationNote(ajax, response) {
const { note } = response;
Drupal.moderation_note.notes[note.id] = note;
showModerationNote(note);
};
/**
* Makes another AJAX call after the reply form is submitted to re-load it.
*
* @param {Drupal.Ajax} [ajax]
* The ajax object.
* @param {Object} response
* Object holding the server response.
* @param {String} response.id
* The ID for the moderation note.
*/
Drupal.AjaxCommands.prototype.reply_moderation_note =
function replyModerationNote(ajax, response) {
const replyAjax = Drupal.ajax({
url: Drupal.formatString(Drupal.url('moderation-note/!id/reply'), {
'!id': response.id,
}),
dialogType: 'dialog.off_canvas',
progress: { type: 'fullscreen' },
});
replyAjax.execute();
};
/**
* Builds a URL based on a given field ID.
*
* Identical to Drupal.quickedit.utils.buildUrl.
*
* @param {Number} id
* A field ID, as provided by moderation_note_preprocess_field().
* @param {String} urlFormat
* A string with placeholders matching field ID parts.
* @return {String}
* The built URL.
*/
function buildUrl(id, urlFormat) {
const parts = id.split('/');
return Drupal.formatString(decodeURIComponent(urlFormat), {
'!entity_type': parts[0],
'!id': parts[1],
'!field_name': parts[2],
'!langcode': parts[3],
'!view_mode': parts[4],
});
}
/**
* Displays the tooltip at a position relative to the current Range.
*
* @param {Object} $tooltip
* The tooltip.
* @param {String} fieldId
* The field ID.
*/
function showAddTooltip($tooltip, fieldId) {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
const top = rect.top - ($tooltip.outerHeight() + 5);
const left = rect.left + rect.width / 2 - $tooltip.outerWidth() / 2;
$tooltip.css(
'left',
left + document.documentElement.scrollLeft || document.body.scrollLeft,
);
$tooltip.css(
'top',
top + document.documentElement.scrollTop || document.body.scrollTop,
);
const url = buildUrl(
fieldId,
Drupal.url(
'moderation-note/add/!entity_type/!id/!field_name/!langcode/!view_mode',
),
);
$tooltip.attr('href', url);
changeAjaxUrl($tooltip, url);
$tooltip.fadeIn('fast');
}
/**
* Removes all moderation notes from the page.
*/
function removeModerationNotes() {
$('.moderation-note-highlight').each(function removeEachHighlight() {
removeHighlight($(this));
});
}
// We use timeouts to throttle calls to this event.
let timeout;
$(document).on('selectionchange', function documentSelectionChanged() {
clearTimeout(timeout);
const $addTooltip = Drupal.moderation_note.add_tooltip;
$addTooltip.fadeOut('fast');
timeout = setTimeout(function changeTimeoutToolTip() {
if (window.getSelection) {
const selection = window.getSelection();
const text = selection.toString();
if (text.length) {
// Ensure that this selection is contained inside a field wrapper.
const range = selection.getRangeAt(0);
const $ancestor = $(range.commonAncestorContainer);
const $field = $ancestor.closest(
'[data-moderation-note-field-id][data-moderation-note-can-create]',
);
if ($field.length) {
// Show the tooltip.
showAddTooltip(
$addTooltip,
$field.data('moderation-note-field-id'),
);
// Store the current selection so that it can be added to the form
// later.
const offset = getCursorPositionInTextOf($field[0], range);
Drupal.moderation_note.selection.quote = text;
Drupal.moderation_note.selection.quote_offset = offset;
Drupal.moderation_note.selection.field_id = $field.data(
'moderation-note-field-id',
);
}
}
}
}, 500);
});
$(document).on('dialogclose', function dialogCloseRemoveHighlights() {
removeContextHighlights();
});
/**
* Contains all Moderation Note behaviors.
*
* @type {Drupal~behavior}
*/
Drupal.behaviors.moderation_note = {
attach(context, settings) {
// Auto-fill the new note form with the current selection.
const $newForm = $('[data-moderation-note-new-form]', context);
if ($newForm.length) {
const { selection } = Drupal.moderation_note;
$newForm.find('.field-moderation-note-quote').val(selection.quote);
$newForm
.find('.field-moderation-note-quote-offset')
.val(selection.quote_offset);
showContextHighlight(selection);
}
// On page load, display all notes given to us.
if (settings.moderation_notes) {
const notes = settings.moderation_notes;
delete settings.moderation_notes;
Object.keys(notes).forEach((i) => {
Drupal.moderation_note.notes[i] = notes[i];
showModerationNote(notes[i]);
});
}
if (Drupal.quickedit && Drupal.quickedit.collections.entities) {
once('moderation-note-quickedit', 'body').forEach(
function eachNoteQuickEdit() {
// Toggle moderation note visibility based on Quick Edit's status.
Drupal.quickedit.collections.entities.on(
'change:isActive',
function quickEditIsActive(model, isActive) {
if (isActive) {
removeModerationNotes();
} else {
Object.values(Drupal.moderation_note.notes).forEach(
(note) => {
showModerationNote(note);
},
);
}
$('body').toggleClass(
'moderation-note-quickedit-active',
isActive,
);
},
);
// After a Quick Edit entity is saved, show moderation notes.
Drupal.quickedit.collections.entities.on(
'change:isCommitting',
function quickEditCommitting(model, isCommitting) {
if (!isCommitting) {
removeModerationNotes();
Object.values(Drupal.moderation_note.notes).forEach(
(note) => {
showModerationNote(note);
},
);
}
},
);
},
);
}
// Reveal the normally hidden quote context if a fuzzy match was made.
$('[data-moderation-note-id]').each(function eachNoteId() {
if (
Drupal.moderation_note.fuzzy_matches.indexOf(
this.dataset.moderationNoteId,
) !== -1
) {
$(this)
.find('.moderation-note-quote-information')
.css('display', 'block');
}
});
// Auto-open a note, if applicable.
once('moderation-note-open', 'body').forEach(function eachOpenNote() {
if (typeof URLSearchParams !== 'undefined') {
const query = new URLSearchParams(window.location.search);
if (query.has('open-moderation-note')) {
const id = query.get('open-moderation-note');
const $element = $(`[data-moderation-note-highlight-id="${id}"]`);
if ($element.length) {
showViewTooltip(Drupal.moderation_note.view_tooltip, $element);
Drupal.moderation_note.view_tooltip
.stop()
.hide()
.trigger('click');
$('html, body').animate(
{
scrollTop: $element.offset().top - $(window).height() / 2,
},
1000,
);
}
}
}
});
},
};
$(document).ready(function documentOnReady() {
$(window).on({
'dialog:beforecreate': function dialogBeforeCreate(
event,
dialog,
$element,
settings,
) {
if (
$element.find(
'.moderation-note-form-wrapper,[data-moderation-note-id]',
).length
) {
settings.dialogClass += ' ui-dialog-off-canvas';
}
},
});
});
})(Drupal, jQuery, once);
