diff --git a/CHANGELOG.md b/CHANGELOG.md index eae61f0..55a04c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ IMPORTANT NOTE: This version only works on CACTI 1.x++! --- develop --- +-issue#308: Clear hotspot pointer cursors when entering placement mode + +* issue#308: Refresh saved editor snapshots reliably, restore Safari node/note selection and return to properties when cancelling raw configuration edits + * Add traffic colour presets and optional high-load link pulsing * Fix Safari rendering of high-traffic link glows using native SVG filters diff --git a/js/editor.js b/js/editor.js index 8a49f88..283325a 100644 --- a/js/editor.js +++ b/js/editor.js @@ -544,7 +544,44 @@ function wmEditorReturnUrl(target) { return 'weathermap-cacti-plugin.php' + (match ? '?action=viewmap&id=' + encodeURIComponent(match[1]) : ''); } +// Some WebKit layouts route image-map clicks to the image after scrolling. +// Resolve those clicks against the same ordered, logical-coordinate regions. +function wmEditorAreaAt(image, event) { + var rect = image.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + var x = (event.clientX - rect.left) * Number(image.getAttribute('data-width')) / rect.width; + var y = (event.clientY - rect.top) * Number(image.getAttribute('data-height')) / rect.height; + var map = document.getElementById((image.getAttribute('usemap') || '').split('#').pop()); + if (!map) return null; + return Array.from(map.querySelectorAll('area')).find(function(area) { + var c = area.coords.split(',').map(Number), inside = false; + if (!c.length || !c.every(Number.isFinite)) return false; + if (area.shape.toLowerCase() === 'rect' && c.length === 4) { + return x >= c[0] && x <= c[2] && y >= c[1] && y <= c[3]; + } + if (area.shape.toLowerCase() === 'circle' && c.length === 3) { + return Math.pow(x - c[0], 2) + Math.pow(y - c[1], 2) <= c[2] * c[2]; + } + if (area.shape.toLowerCase() === 'poly' && c.length >= 6 && c.length % 2 === 0) { + for (var i = 0, j = c.length - 2; i < c.length; j = i, i += 2) { + if ((c[i + 1] > y) !== (c[j + 1] > y) && x < (c[j] - c[i]) * (y - c[i + 1]) / (c[j + 1] - c[i + 1]) + c[i]) inside = !inside; + } + } + return inside; + }) || null; +} + function attach_click_events() { + $('#existingdata').off('.wmhit').on('click.wmhit', function(event) { + if (event.target !== this) return; + var area = wmEditorAreaAt(this, event); + if (area) { + event.preventDefault(); + $(area).triggerHandler('click'); + } + }).on('mousemove.wmhit', function(event) { + if (!$(this).hasClass('wm-placement-mode')) this.style.cursor = wmEditorAreaAt(this, event) ? 'pointer' : ''; + }); $('#xycapture').off('click.wmnote').on('click.wmnote', function(event) { if ($('#action').val() === 'place_note' || ($('#action').val() === 'move_node' && typeof WMNotes !== 'undefined' && WMNotes[$('#param').val()])) coord_update(event); }); @@ -627,7 +664,11 @@ function attach_click_events() { } // used by the cancel button on each of the properties dialogs -function cancel_op() { +function cancel_op(event) { + if (event && event.currentTarget && event.currentTarget.id === 'tb_textedit_cancel') { + $('#dlgTextEdit').dialog('close'); + return; + } hide_all_dialogs(); $('#action').val(''); @@ -761,7 +802,7 @@ function wmResetActionHelp() { function mapmode(m) { var placing = m == 'xy' || ['add_link', 'add_link2'].indexOf($('#action').val()) !== -1; - $('#existingdata, .mapData').toggleClass('wm-placement-mode', placing); + $('#existingdata, .mapData').toggleClass('wm-placement-mode', placing).css('cursor', ''); $('#tb_help').toggleClass('wm-action-help', placing); if (!placing) wmResetActionHelp(); if (m == 'xy') { @@ -934,11 +975,20 @@ function wmEditorReloadUrl() { url.searchParams.set('action', 'nothing'); url.searchParams.delete('config_revision'); url.searchParams.delete('__csrf_magic'); + url.hash = ''; return url.href; } function wmReloadEditor() { - window.location.replace(wmEditorReloadUrl()); + var url = wmEditorReloadUrl(); + var current = new URL(window.location.href); + current.hash = ''; + // Same-document fragment navigation does not fetch a fresh map snapshot. + if (current.href === url) { + window.location.reload(); + } else { + window.location.replace(url); + } } function form_submit(event) { @@ -1073,6 +1123,8 @@ function real_position_legend(scalename) { } function show_itemtext(itemtype,name) { + var parent = itemtype === 'link' ? 'dlgLinkProperties' : 'dlgNodeProperties'; + var returnTo = $('#' + parent).dialog('instance') && $('#' + parent).dialog('isOpen') ? parent : null; mapmode('existing'); hide_all_dialogs(); @@ -1087,7 +1139,7 @@ function show_itemtext(itemtype,name) { $('#action').val('set_link_config'); } - $('#dlgTextEdit').data('wm-config-action', $('#action').val()); + $('#dlgTextEdit').data('wm-config-action', $('#action').val()).data('wm-config-parent', returnTo); show_dialog('dlgTextEdit'); $.ajax({ @@ -1334,6 +1386,19 @@ function show_dialog(dlg) { modal: false, resizable: false, draggable: true, + close: function() { + $('#action').val(''); + mapmode('existing'); + if (dlg === 'dlgTextEdit') { + var parent = $(this).data('wm-config-parent'); + $(this).removeData('wm-config-parent'); + if (parent) { + show_dialog(parent); + $('#action').val(parent === 'dlgLinkProperties' ? 'set_link_properties' : 'set_node_properties'); + $(parent === 'dlgLinkProperties' ? '#link_edit' : '#node_edit').attr('tabindex', '-1').trigger('focus'); + } + } + }, open: function() { $(this).closest('.ui-dialog').addClass('wm-editor-dialog'); $(this).find('.ui-autocomplete-input').each(function() { @@ -1357,6 +1422,8 @@ function hide_dialog(dlg) { } function hide_all_dialogs() { + // Navigation to another task must not reopen the configuration editor's parent. + $('#dlgTextEdit').removeData('wm-config-parent'); hide_dialog('dlgMapProperties'); hide_dialog('dlgMapStyle'); hide_dialog('dlgLinkProperties'); @@ -2071,3 +2138,8 @@ function show_note(name, x, y) { $('#note_font,#note_align').each(function() { if ($(this).data('ui-selectmenu')) $(this).selectmenu('refresh'); }); $('#note_text').trigger('focus'); } + +// Allow the navigation regression to load these helpers directly under Node.js. +if (typeof module !== 'undefined' && module.exports) { + module.exports = {wmEditorReloadUrl: wmEditorReloadUrl, wmReloadEditor: wmReloadEditor}; +} diff --git a/tests/Browser/ConfigCancelTest.cjs b/tests/Browser/ConfigCancelTest.cjs new file mode 100644 index 0000000..7dd88bb --- /dev/null +++ b/tests/Browser/ConfigCancelTest.cjs @@ -0,0 +1,30 @@ +const {JSDOM}=require('jsdom'), jquery=require('jquery'), fs=require('node:fs'), path=require('node:path'), assert=require('node:assert/strict'); +const dom=new JSDOM('
',{runScripts:'outside-only',pretendToBeVisual:true}); +const w=dom.window,$=jquery(w);w.$=w.jQuery=$; +w.eval(fs.readFileSync(path.join(process.env.CACTI_JS_DIR,'jquery-ui.js'),'utf8')); +const source=fs.readFileSync(path.join(__dirname,'../../js/editor.js'),'utf8'); +w.eval(source.slice(source.indexOf('function cancel_op('),source.indexOf('function help_handler('))); +w.eval(source.slice(source.indexOf('function show_itemtext('),source.indexOf('function prime_node_form('))); +w.eval(source.slice(source.indexOf('function show_dialog('),source.indexOf('function coord_capture('))); +w.mapmode=()=>{};w.wmResetActionHelp=()=>{};w.editor_url='/editor';$.ajax=()=>{}; +$('#tb_textedit_cancel').on('click',w.cancel_op); +for(const [type,parent,action,edit] of [['link','dlgLinkProperties','set_link_properties','link_edit'],['node','dlgNodeProperties','set_node_properties','node_edit']]){ + w.hide_all_dialogs();w.show_dialog(parent);w.show_itemtext(type,'test'); + assert.equal($('#'+parent).dialog('isOpen'),false); + $('#item_configtext').val('discard this config draft'); + $('#tb_textedit_cancel').trigger('click'); + assert.equal($('#dlgTextEdit').dialog('isOpen'),false); + assert.equal($('#'+parent).dialog('isOpen'),true); + assert.equal($('#action').val(),action); + assert.equal(w.document.activeElement.id,edit); + assert.equal($('#unsaved').val(),'draft'); + // Title-bar close and Escape use this same close callback. + w.show_itemtext(type,'test');$('#dlgTextEdit').dialog('close'); + assert.equal($('#'+parent).dialog('isOpen'),true); + w.show_itemtext(type,'test');w.hide_all_dialogs(); + assert.equal($('#'+parent).dialog('isOpen'),false,'Navigation must not reopen the parent'); +} +w.show_itemtext('link','test');$('#tb_textedit_cancel').trigger('click'); +assert.equal($('#dlgLinkProperties').dialog('isOpen'),false,'Direct configuration editing has no parent to restore'); +console.log('PASS: Cancel and close return to properties, preserve drafts and action, while navigation stays closed'); +w.close(); diff --git a/tests/Browser/EditorContextMenuTest.cjs b/tests/Browser/EditorContextMenuTest.cjs index a770e7d..632e38d 100644 --- a/tests/Browser/EditorContextMenuTest.cjs +++ b/tests/Browser/EditorContextMenuTest.cjs @@ -70,7 +70,7 @@ $('#action').val('add_link');w.mapmode('existing');assert.ok($('#existingdata'). w.click_execute({},'LINK:1:0');assert.equal($('#action').val(),'');assert.ok(!$('#existingdata').hasClass('wm-placement-mode'));assert.equal($('area').css('cursor'),'pointer'); w.eval(src.slice(src.indexOf('function form_submit('),src.indexOf('function map_properties('))); $('body').append(''); -let saveRequest;$.ajax=opts=>saveRequest=opts;w.editor_url='/editor'; +let saveRequest;$.ajax=opts=>saveRequest=opts;w.editor_url='/editor';w.wmSyncLinkHoverParts=()=>{}; for (const kind of ['node','link']) for (const exit of ['cancel','close']) { const dialog=$('#dlg'+(kind==='node'?'Node':'Link')+'Properties');dialog.dialog({modal:false}); $('#action').val('add_link2');$('#param').val('first-endpoint');w.mapmode('existing'); @@ -83,5 +83,27 @@ for (const kind of ['node','link']) for (const exit of ['cancel','close']) { assert.equal(submitted.get('param'),$(kind==='node'?'#node_name':'#link_name').val()); assert.ok(!$('#existingdata').hasClass('wm-placement-mode'));dialog.dialog('close'); } +// Programmatic deletion must not turn into Save when focus returns to a property field. +for (const kind of ['node','link']) { + const dialog=$('#dlg'+(kind==='node'?'Node':'Link')+'Properties'); + dialog.append('').dialog({modal:false}).dialog('open'); + dialog.find('input.focused-delete-test')[0].focus(); + assert.equal($(w.document.activeElement).closest('.ui-dialog-content').attr('id'),dialog.attr('id')); + $('#action').val('delete_'+kind);$('#param').val(kind==='node'?'dc':'uplink'); + w.form_submit(); + const deletion=new URLSearchParams(saveRequest.data); + assert.equal(deletion.get('action'),'delete_'+kind); + assert.equal(deletion.get('param'),kind==='node'?'dc':'uplink'); + dialog.dialog('close'); +} +w.eval(src.slice(src.indexOf('function show_dialog('),src.indexOf('function hide_dialog('))); +for (const kind of ['Node', 'Link']) { + const id = 'dlg' + kind + 'Properties'; + w.show_dialog(id); + $('#action').val('set_' + kind.toLowerCase() + '_properties'); + $('#' + id).dialog('close'); + assert.equal($('#action').val(), '', 'title-bar close must allow selecting the next object'); + assert.ok(!$('#existingdata').hasClass('wm-placement-mode')); +} dom.window.close(); console.log('PASS: context menu cancels placement; node/link confirmations and closing restore normal map mode'); diff --git a/tests/Browser/EditorHotspotTest.cjs b/tests/Browser/EditorHotspotTest.cjs new file mode 100644 index 0000000..1873d8d --- /dev/null +++ b/tests/Browser/EditorHotspotTest.cjs @@ -0,0 +1,25 @@ +const {JSDOM}=require('jsdom'), jquery=require('jquery'), fs=require('node:fs'), path=require('node:path'), assert=require('node:assert/strict'); +const dom=new JSDOM('