From 52192c9b2b5e99a0553a3a2fb63f05d7b2b11292 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 23 Jul 2026 08:39:33 +0200 Subject: [PATCH 1/4] refactor(dynamic): port drag/drop to pointerdrag toolkit --- js/dragdrop2.js | 753 ------------------------------------------------ js/kronolith.js | 346 ++++++++++++---------- lib/Ajax.php | 2 +- 3 files changed, 200 insertions(+), 901 deletions(-) delete mode 100644 js/dragdrop2.js diff --git a/js/dragdrop2.js b/js/dragdrop2.js deleted file mode 100644 index ffed904c..00000000 --- a/js/dragdrop2.js +++ /dev/null @@ -1,753 +0,0 @@ -/** - * dragdrop.js - A minimalist library to handle drag/drop actions. - * Requires prototype.js 1.6.0.2+ - * - * Adapted from SkyByte.js/SkyByteDD.js v1.0-beta, May 17 2007 - * (c) 2007 Aleksandras Ilarionovas (Alex) - * http://www.skybyte.net/scripts/ - * - * Scrolling and ghosting code adapted from script.aculo.us dragdrop.js v1.8.0 - * (c) 2005-2007 Thomas Fuchs (http://script.aculo.us, http://mir.aculo.us) - * (c) 2005-2007 Sammi Williams (http://www.oriontransfer.co.nz, sammi@oriontransfer.co.nz) - * - * The original scripts were freely distributable under the terms of an - * MIT-style license. - * - * Usage: - * ------ - * new Drag(element, { - * caption: '', // Either string or function to set caption - * // on mouse move. - * classname: '', // Class name of the drag element. - * // DEFAULT: 'drag' - * constraint: '', // Constrain movement to 'horizontal' or - * // 'vertical'. - * ghosting: false, // Show ghost outline when dragging. - * nodrop: false, // Don't do drop checking. Optimizes - * // movement speed. - * offset: { x:0, y:0 }, // An offset to apply to ghosted elements. - * parentElement: function(), // Function returns the parent element. - * scroll: element, // Scroll this element when above/below ( - * // only for vertical elements). - * snap: null, // If ghosting, snap allows to specify - * // coords at which the ghosted image will - * // "snap" into place. - * snapToParent: false // Keep image snapped inside the parent - * // element. - * threshold: 0 // Move threshold. - * }); - * - * Events fired for Drags: - * ----------------------- - * Custom events are triggered on the drag element. The 'memo' property of - * the Event object contains the original event object. - * - * 'DragDrop2:drag' - * Fired on mousemove. - * - * 'DragDrop2:end' - * Fired when dragging ends. - * - * 'DragDrop2:mousedown' - * Fired on mousedown. - * - * 'DragDrop2:mouseup' - * Fored on mouseup *if* the element was not dragged. - * - * 'DragDrop2:start' - * Fired when first moved more than 'threshold'. - * - * - * new Drop(element, { - * accept: [], // Accept filter by tag name(s) or leave empty to - * // accept all tags. - * caption: '', // Either string or function to set caption on - * // mouseover. - * hoverclass: '', // Change the drag element to this class when hovering - * // over an element. - * // DEFAULT: 'dragdrop' - * keypress: false // If true, will re-render caption if a keypress is - * // detected while a drop is active (useful for - * // CTRL/SHIFT actions). - * }); - * - * Events fired for Drops: - * ----------------------- - * Custom events are triggered on the drop element. The 'memo' property of - * the Event object contains the Drag object. The dragged element is available - * in 'memo.element'. The browser event that triggered the custom event is - * available in 'memo.dragevent'. - * - * 'DragDrop2:drop' - * Fired when mouse button released (a/k/a a drop event). - * - * 'DragDrop2:out' - * Fired when mouse leaves the drop zone. - * - * 'DragDrop2:over' - * Fired when mouse over drop zone. - * - * - * Permission is hereby granted, free of charge, to any person obtaining a - * copy of this software and associated documentation files (the "Software"), - * to deal in the Software without restriction, including without limitation - * the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the - * Software is furnished to do so, subject to the following conditions: - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING - * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER - * DEALINGS IN THE SOFTWARE. - * - * Copyright 2008-2017 Horde LLC (http://www.horde.org/) - * - * @author Michael Slusarz - * @package Horde - */ - -var DragDrop = { - - Drags: { - - drags: $H(), - - register: function(obj) - { - if (!this.div) { - this.div = new Element('DIV', { className: obj.options.classname }).setStyle({ position: 'absolute' }).hide(); - $(document.body).insert(this.div); - document.observe('mousedown', this._mouseHandler.bindAsEventListener(this)); - } - - this.drags.set(obj.element.identify(), obj); - obj.element.classList.add('DragElt'); - }, - - unregister: function(obj) - { - if (this.drag == obj.element) { - this.drag.deactivate(); - } - - this.drags.unset(obj.element.identify()); - obj.element.classList.remove('DragElt'); - }, - - getDrag: function(el) - { - return this.drags.get(el instanceof HTMLElement ? $(el).identify() : el); - }, - - activate: function(drag) - { - if (this.drag) { - this.deactivate(); - } - this.drag = drag; - this.mousemoveE = drag._mouseMove.bindAsEventListener(drag); - this.mouseupE = drag._mouseUp.bindAsEventListener(drag); - this.keypressE = drag._keyPress.bindAsEventListener(drag); - document.observe('mousemove', this.mousemoveE); - document.observe('mouseup', this.mouseupE); - document.observe('keydown', this.keypressE); - document.observe('keyup', this.keypressE); - }, - - deactivate: function() - { - if (this.drag) { - this.drag = DragDrop.Drops.drop = null; - document.stopObserving('mousemove', this.mousemoveE); - document.stopObserving('mouseup', this.mouseupE); - document.stopObserving('keydown', this.keypressE); - document.stopObserving('keyup', this.keypressE); - } - }, - - _mouseHandler: function(e) - { - var elt = e.findElement('.DragElt'); - if (this.drags.size() && elt) { - this.getDrag(elt).mouseDown(e); - } - } - - }, - - Drops: { - - drops: $H(), - - register: function(obj) - { - this.drops.set(obj.element.identify(), obj); - obj.element.classList.add('DropElt'); - }, - - unregister: function(obj) - { - if (this.drop == obj.element) { - this.drop = null; - } - - this.drops.unset(obj.element.identify()); - obj.element.classList.remove('DropElt'); - }, - - getDrop: function(el) - { - return this.drops.get(el instanceof HTMLElement ? $(el).identify() : el); - } - - }, - - validDrop: function(el) - { - var d = DragDrop.Drops.drop; - return (d && - el && - el != d.element && - (!d.options.accept.size() || - d.options.accept.include(el.tagName))); - } - -}; - -Drag = Class.create({ - - initialize: function(el) - { - this.dragevent = null; - this.element = $(el); - this.ghostOffset = [ 0, 0 ]; - this.options = Object.assign({ - caption: '', - classname: 'drag', - constraint: null, - ghosting: false, - nodrop: false, - parentElement: null, - scroll: null, - snap: null, - snapToParent: false, - threshold: 0 - }, arguments[1] || {}); - if (this.options.scroll) { - this.options.scroll = $(this.options.scroll); - } - DragDrop.Drags.register(this); - - // Disable text selection. - this.element.style.userSelect = 'none'; - }, - - destroy: function() - { - DragDrop.Drags.unregister(this); - }, - - mouseDown: function(e) - { - DragDrop.Drags.activate(this); - this.move = 0; - this.wasDragged = false; - this.wasMoved = false; - this.lastcaption = null; - this.clickEvent = e; - - this.element.fire('DragDrop2:mousedown', Object.clone(e)); - - if (this.options.ghosting || this.options.caption) { - if (!DragDrop.Drags.cover) { - DragDrop.Drags.cover = new Element('DIV', { id: 'dragdrop2Cover' }); - $(document.body).insert(DragDrop.Drags.cover); - DragDrop.Drags.cover.insert(new Element('DIV').setStyle({ position: 'absolute' }).hide()); - } - - $$('IFRAME').each(function(i) { - var z; - if (i.visible()) { - z = parseInt(i.getStyle('zIndex'), 10); - if (isNaN(z)) { - z = 2; - } - DragDrop.Drags.cover.insert(DragDrop.Drags.cover.down().clone(false).setStyle({ zIndex: z }).clonePosition(i).show()); - } - }, this); - } - - if (this.options.snapToParent) { - this.snap = this.options.parentElement - ? this.options.parentElement().getDimensions() - : this.element.parentNode.getDimensions(); - } - - // Text selection prevented by user-select: none in initialize(). - }, - - _mouseMove: function(e) - { - var go, eo, po, xy, p, delta; - - if (++this.move <= this.options.threshold) { - return; - } else if (!this.wasMoved) { - this.element.fire('DragDrop2:start', Object.clone(this.clickEvent)); - this.wasMoved = true; - } - - this.lastCoord = xy = [ e.pointerX(), e.pointerY() ]; - - if (!this.options.caption) { - if (!this.ghost) { - // Use the position of the original click event as the start - // coordinate. - xy = [ this.clickEvent.pointerX(), this.clickEvent.pointerY() ]; - - // Create the "ghost", i.e. the moving element, a clone of the - // original element, if it doesn't exist yet. - var layout = this.element.getLayout(); - this.ghost = $(this.element.clone(true)) - .writeAttribute('id', null) - .addClassName(this.options.classname) - .setStyle({ position: 'absolute', height: layout.get('height') + 'px', width: layout.get('width') + 'px' }); - - p = this.element.viewportOffset(); - delta = document.body.viewportOffset(); - delta[0] -= document.body.offsetLeft; - delta[1] -= document.body.offsetTop; - this.ghost.style.left = (p[0] - delta[0]) + 'px'; - this.ghost.style.top = (p[1] - delta[1]) + 'px'; - - // eo is the offset of the original element to the body. - eo = this.element.cumulativeOffset(); - - // Save external dimensions, i.e. height and width including - // padding and margins, for later usage. - this.dim = { - width: layout.get('margin-box-width'), - height: layout.get('margin-box-height') - }; - - if (this.options.ghosting) { - var z = parseInt(this.element.getStyle('zIndex'), 10); - if (isNaN(z)) { - z = 1; - } - this.ghost.setOpacity(0.7).setStyle({ zIndex: z + 1 }); - } else { - this.element.setStyle({ visibility: 'hidden' }); - } - - // Insert ghost into the parent, either specified by a - // function result, or using the original element's parent. - if (this.options.parentElement) { - this.options.parentElement().insert(this.ghost); - } else { - this.element.insert({ before: this.ghost }); - } - - // go is the offset of the ghost to the body. This might be - // different from the original element's offset because we - // used that element's position when cloning the ghost, but - // they might have different parents now. - go = this.ghost.cumulativeOffset(); - - // Calculate the difference between the ghost's offset and the - // orginal element's offset. - this.ghostOffset = [ go[0] - eo[0], go[1] - eo[1] ]; - - // Add the event coordinates to the offset, because we use the - // coordinates during later mousemove events as a basis for - // the new ghost position. But we don't want to position the - // ghost relative to the mouse pointer, but relative to where - // the mouse pointer clicked when the ghost was created. - // @todo: why do we subtract eo? - if (this.options.offset) { - this.mouseOffset = this.ghostOffset; - } else { - this.mouseOffset = [ this.ghostOffset[0] + xy[0] - eo[0], - this.ghostOffset[1] + xy[1] - eo[1] ]; - } - - if (!this.options.caption && this.options.constraint) { - // Because we later only set the left or top coordinates - // when using constraints, we have to set the correct - // "opposite" coordinates here. - po = this.ghost.getOffsetParent().cumulativeOffset(); - switch (this.options.constraint) { - case 'horizontal': - this.ghost.setStyle({ top: (eo[1] - po[1]) + 'px' }); - break; - - case 'vertical': - this.ghost.setStyle({ left: (eo[0] - po[0]) + 'px' }); - break; - } - } - } - - // Subtract the ghost's offset to the original mouse position and - // add any scrolling. - xy[0] -= this.mouseOffset[0]; - xy[1] -= this.mouseOffset[1]; - - this._setContents(this.ghost, xy[0], xy[1]); - } - - if (!this.options.nodrop) { - this._onMoveDrag(xy, e); - } - - this.wasDragged = true; - - this.element.fire('DragDrop2:drag', Object.clone(e)); - - if (this.options.scroll) { - this._onMoveScroll(); - } - }, - - _mouseUp: function(e) - { - var d = DragDrop.Drops.drop, tmp; - - this._stopScrolling(); - - if (this.ghost) { - if (!this.options.ghosting) { - this.element.setStyle({ visibility: 'visible' }); - } - try { - this.ghost.remove(); - } catch (ex) {} - this.ghost = null; - } - - DragDrop.Drags.div.hide(); - - if (DragDrop.validDrop(this.element)) { - this.dragevent = e; - d.element.fire('DragDrop2:drop', this); - } - - DragDrop.Drags.deactivate(); - - if ((this.options.ghosting || this.options.caption) && - DragDrop.Drags.cover) { - DragDrop.Drags.cover.down().siblings().invoke('remove'); - } - - if (!this.element.parentNode) { - tmp = new Element('DIV').insert(this.element); - } - - this.element.fire(this.wasMoved ? 'DragDrop2:end' : 'DragDrop2:mouseup', Object.clone(e)); - - tmp = null; - }, - - _onMoveDrag: function(xy, e) - { - var d = DragDrop.Drops.drop, - div = DragDrop.Drags.div, - d_update = true, - elt = this._findElement(e); - - /* elt will be null if we drag off the browser window. */ - if (!(elt instanceof HTMLElement)) { - return; - } - - if (this.lastelt == elt) { - this._setCaption(div, xy); - return; - } - - this.lastelt = elt; - - /* Do mouseover/mouseout-like detection here. Saves on observe calls - * and handles case where mouse moves over scrollbars. */ - if (DragDrop.Drops.drops.size()) { - if (!elt.classList.contains('DropElt')) { - elt = elt.up('.DropElt'); - } - - if (elt) { - /* Ignore if mouse is over an offset ghosted element. */ - if (elt == this.ghost) { - return; - } - - elt = DragDrop.Drops.getDrop(elt); - if (elt == d) { - d_update = false; - } else { - elt.mouseOver(e); - d = elt; - } - } else if (d) { - d.mouseOut(e); - d = null; - } - } - - if (d_update) { - this._updateCaption(d, div, e); - } - - this._setCaption(div, xy); - }, - - _updateCaption: function(d, div, e) - { - var caption, cname, c_opt; - - if (d && DragDrop.validDrop(this.element)) { - d_cap = d.options.caption; - if (!d_cap) { - return; - } - caption = typeof d_cap === 'function' ? d_cap(d.element, this.element, e) : d_cap; - if (caption && d.options.hoverclass) { - cname = d.options.hoverclass; - } - } - - if (!caption) { - c_opt = this.options.caption; - caption = typeof c_opt === 'function' ? c_opt(this.element) : c_opt; - } - - if (caption != this.lastcaption) { - this.lastcaption = caption; - div.update(caption).writeAttribute({ className: cname || this.options.classname }); - if (caption === '') { - div.hide(); - } - } - }, - - _findElement: function(e) - { - var drop, x, y; - - if (this.options.caption || - (this.options.offset && - (this.options.offset.x > 0 || this.options.offset.y > 0))) { - return e.element(); - } - - if (!DragDrop.Drops.drops.size()) { - return; - } - - Position.prepare(); - - x = e.pointerX(); - y = e.pointerY(); - - drop = DragDrop.Drops.drops.find(function(drop) { - return Position.within(drop.value.element, x, y); - }); - - if (drop) { - return drop.value.element; - } - }, - - _keyPress: function(e) - { - if (DragDrop.Drops.drop && - DragDrop.Drops.drop.options.keypress) { - this._updateCaption(DragDrop.Drops.drop, DragDrop.Drags.div, e); - } - }, - - _setCaption: function(div, xy) - { - if (this.lastcaption) { - this._setContents(div, xy[0] + 15, xy[1] + (this.ghost ? (this.ghost.getHeight() + 5) : 5)); - } - }, - - _onMoveScroll: function() - { - this._stopScrolling(); - - var delta, p, speed, vp, - s = this.options.scroll, - dim = s.getDimensions(); - - // No need to scroll if element is not current scrolling. - if (s.scrollHeight == dim.height) { - return; - } - - delta = document.viewport.getScrollOffsets(); - p = s.viewportOffset(); - speed = [ 0, 0 ]; - vp = document.viewport.getDimensions(); - - p[0] += s.scrollLeft + delta.left; - p[2] = p[0] + dim.width; - - // Only scroll if directly above/below element - if (this.lastCoord[0] > p[2] || - this.lastCoord[0] < p[0]) { - return; - } - - p[1] = vp.height - dim.height; - p[3] = vp.height - 10; - - // Left scroll - //if (this.lastCoord[0] < p[0]) { - // speed[0] = this.lastCoord[0] - p[0]; - //} - - // Top scroll - if (this.lastCoord[1] < p[1]) { - speed[1] = this.lastCoord[1] - p[1]; - } - - // Scroll right - //if (this.lastCoord[0] > p[2]) { - // speed[0] = this.lastCoord[0] - p[2]; - //} - - // Scroll left - if (this.lastCoord[1] > p[3]) { - speed[1] = this.lastCoord[1] - p[3]; - } - - if (speed[0] || speed[1]) { - this.lastScrolled = new Date(); - this.scrollInterval = setInterval(this._scroll.bind(this, speed[0] * 15, speed[1] * 15), 10); - } - }, - - _stopScrolling: function() - { - if (this.scrollInterval) { - clearInterval(this.scrollInterval); - this.scrollInterval = null; - } - }, - - _scroll: function(x, y) - { - var current = new Date(), - delta = current - this.lastScrolled, - s = this.options.scroll; - this.lastScrolled = current; - - //s.scrollLeft += x * delta / 1000; - s.scrollTop += y * delta / 1000; - }, - - _setContents: function(elt, x, y) - { - var e_pos, vp, so, xy, style; - - if (this.options.offset) { - x += this.options.offset.x; - y += this.options.offset.y; - } - - if (this.options.snapToParent) { - if (x < 0) { - x = 0; - } - if (y < 0) { - y = 0; - } - if (x + this.dim.width > this.snap.width) { - x = this.snap.width - this.dim.width; - } - if (y + this.dim.height > this.snap.height) { - y = this.snap.height - this.dim.height; - } - } else if (this.options.snap) { - xy = this.options.snap(x, y, this.element); - x = xy[0]; - y = xy[1]; - } else { - e_pos = elt.getDimensions(); - vp = document.viewport.getDimensions(); - so = document.viewport.getScrollOffsets(); - vp.width += so[0]; - vp.height += so[1]; - if (x + this.ghostOffset[0] < 0) { - x = -this.ghostOffset[0]; - } else if (x + e_pos.width + this.ghostOffset[0] > vp.width) { - x = vp.width - e_pos.width - this.ghostOffset[0]; - } - if (y + this.ghostOffset[1] < 0) { - y = -this.ghostOffset[1]; - } else if (y + e_pos.height + this.ghostOffset[1] > vp.height) { - y = vp.height - e_pos.height - this.ghostOffset[1]; - } - } - - if (!this.options.caption) { - switch (this.options.constraint) { - case 'horizontal': - style = { left: x + 'px' }; - break; - - case 'vertical': - style = { top: y + 'px' }; - break; - - default: - style = { left: x + 'px', top: y + 'px' }; - break; - } - } else { - style = { left: x + 'px', top: y + 'px' }; - } - - elt.setStyle(style).show(); - } - -}), - -Drop = Class.create({ - - initialize: function(el) - { - this.element = $(el); - this.options = Object.assign({ - accept: [], - caption: '', - hoverclass: 'dragdrop', - keypress: false - }, arguments[1] || {}); - DragDrop.Drops.register(this); - }, - - destroy: function() - { - DragDrop.Drops.unregister(this); - }, - - mouseOver: function(e) - { - DragDrop.Drops.drop = this; - DragDrop.Drags.drag.dragevent = e; - this.element.fire('DragDrop2:over', DragDrop.Drags.drag); - }, - - mouseOut: function(e) - { - this.element.fire('DragDrop2:out', DragDrop.Drags.drag); - DragDrop.Drags.drag.dragevent = e; - DragDrop.Drops.drop = null; - } - -}); diff --git a/js/kronolith.js b/js/kronolith.js index 4dc5092b..e7b15f87 100644 --- a/js/kronolith.js +++ b/js/kronolith.js @@ -520,7 +520,7 @@ KronolithCore = { td.classList.add('kronolith-today'); this.addTimeMarker('kronolithEvents' + what + dateString); } - new Drop(td.down('div')); + new HordeDroppable(td.down('div')); div = div.next('div'); th = th.next('td'); td = td.next('td'); @@ -693,7 +693,7 @@ KronolithCore = { if (dateString == today) { cell.classList.add('kronolith-today'); } - new Drop(cell); + new HordeDroppable(cell); cell.store('date', dateString) .down('.kronolith-day') .store('date', dateString) @@ -1840,24 +1840,26 @@ KronolithCore = { minLeft = weekHead.down('.kronolith-first-col').getWidth() + this[storage].spacing + (parseInt(div.getStyle('marginLeft'), 10) || 0), minTop = weekHead.down('thead').getHeight() + this[storage].spacing + (parseInt(div.getStyle('marginTop'), 10) || 0), maxLeft = weekHead.getWidth() - layout.get('margin-box-width'), - maxTop = weekHead.down('thead').getHeight() + weekHead.down('.kronolith-all-day').getHeight(), - opts = { - threshold: 5, - parentElement: function() { - return $('kronolithView' + what).down('.kronolith-view-head'); - }, - snap: function(x, y) { - return [Math.min(Math.max(x, minLeft), maxLeft), - Math.min(Math.max(y, minTop), maxTop - div.getHeight())]; - } - }; - var d = new Drag(event.value.nodeId, opts); - div.store('drags', []); - Object.extend(d, { + maxTop = weekHead.down('thead').getHeight() + weekHead.down('.kronolith-all-day').getHeight(); + /* Kronolith's drag/drop toolkit is a thin + * pointer-events wrapper. All coordinate + * math (snap, clamp, quantize) is done + * in the Drag:start/move handlers below. + * Consumer-side constants get stashed on + * the source element and copied into the + * drag's `state` bag at Drag:start. */ + $(event.value.nodeId).store('kronDrag', { + mode: 'allday-move', event: event, innerDiv: new Element('div'), - midnight: this.parseDate(date) + midnight: this.parseDate(date), + minLeft: minLeft, + maxLeft: maxLeft, + minTop: minTop, + maxTop: maxTop }); + var d = new HordeDraggable(event.value.nodeId, { threshold: 5 }); + div.store('drags', []); div.retrieve('drags').push(d); } } @@ -1959,43 +1961,41 @@ KronolithCore = { // Height of the whole event div var divHeight = div.getHeight(), // Maximum height of the whole event div - maxDiv = 24 * this[storage].height - divHeight, - // Whether the top dragger is dragged, vs. the bottom - // dragger - opts = { - threshold: 5, - constraint: 'vertical', - scroll: this.kronolithBody, - nodrop: true, - parentElement: function() { - return parentElement; - } - }; + maxDiv = 24 * this[storage].height - divHeight; if (draggerTop) { - opts.snap = function(x, y) { - y = Math.max(0, step * (Math.min(maxTop, y - this.scrollTop) / step | 0)); - return [0, y]; - }.bind(this); - var d = new Drag(event.value.nodeId + 'top', opts); - Object.extend(d, { + $(event.value.nodeId + 'top').store('kronDrag', { + mode: 'resize-top', event: event, innerDiv: innerDiv, - midnight: midnight + midnight: midnight, + step: step, + maxTop: maxTop + }); + var d = new HordeDraggable(event.value.nodeId + 'top', { + threshold: 5, + nodrop: true, + scroll: this.kronolithBody }); div.retrieve('drags').push(d); } if (draggerBottom) { - opts.snap = function(x, y) { - y = Math.min(maxBottom + dragBottomHeight + KronolithCore[storage].spacing, step * ((Math.max(minBottom, y - this.scrollTop) + dragBottomHeight + KronolithCore[storage].spacing) / step | 0)) - dragBottomHeight - KronolithCore[storage].spacing; - return [0, y]; - }.bind(this); - var d = new Drag(event.value.nodeId + 'bottom', opts); - Object.extend(d, { + $(event.value.nodeId + 'bottom').store('kronDrag', { + mode: 'resize-bottom', event: event, innerDiv: innerDiv, - midnight: midnight + midnight: midnight, + step: step, + minBottom: minBottom, + maxBottom: maxBottom, + dragBottomHeight: dragBottomHeight, + spacing: this[storage].spacing + }); + var d = new HordeDraggable(event.value.nodeId + 'bottom', { + threshold: 5, + nodrop: true, + scroll: this.kronolithBody }); div.retrieve('drags').push(d); } @@ -2006,24 +2006,23 @@ KronolithCore = { maxLeft = this.eventsWeek['kronolithEvents' + what + dates[1].dateString()].offsetLeft - this.eventsWeek['kronolithEvents' + what + date].offsetLeft; stepX = (maxLeft - minLeft) / (view == 'week' ? 6 : 4); } - var d = new Drag(div, { - threshold: 5, - nodrop: true, - parentElement: function() { return parentElement; }, - snap: function(x, y) { - x = (view == 'week' || view == 'workweek') - ? Math.max(minLeft, stepX * ((Math.min(maxLeft, x - (x < 0 ? stepX : 0)) + stepX / 2) / stepX | 0)) - : 0; - y = Math.max(0, step * (Math.min(maxDiv, y - this.scrollTop) / step | 0)); - return [x, y]; - }.bind(this) - }); - Object.extend(d, { - divHeight: divHeight, - startTop: div.offsetTop, + div.store('kronDrag', { + mode: 'move', event: event, + innerDiv: innerDiv, midnight: midnight, - stepX: stepX + step: step, + stepX: stepX, + minLeft: minLeft, + maxLeft: maxLeft, + maxDiv: maxDiv, + divHeight: divHeight, + startTop: div.offsetTop, + view: view + }); + var d = new HordeDraggable(div, { + threshold: 5, + nodrop: true }); div.retrieve('drags').push(d); } @@ -2112,7 +2111,14 @@ KronolithCore = { } if (event.value.pe) { div.setStyle({ cursor: 'move' }); - new Drag(event.value.nodeId, { threshold: 5, parentElement: function() { return $('kronolith-month-body'); }, snapToParent: true }); + $(event.value.nodeId).store('kronDrag', { + mode: 'month-move', + event: event + }); + new HordeDraggable(event.value.nodeId, { + threshold: 5, + ghosting: true + }); } if (Kronolith.conf.max_events) { var more = monthDay.down('.kronolithMore'); @@ -5538,8 +5544,8 @@ KronolithCore = { */ onDrop: function(e) { - var drop = e.element(), - el = e.memo.element; + var drop = e.detail.targetEl, + el = e.detail.source; if (drop == el.up()) { return; @@ -5636,19 +5642,57 @@ KronolithCore = { return; } - var elt = e.element(); - - if (elt.classList.contains('kronolithDragger')) { - elt.up().classList.add('kronolith-selected'); - DragDrop.Drags.getDrag(elt).top = elt.cumulativeOffset().top; - } else if (elt.classList.contains('kronolithEditable')) { - elt.addClassName('kronolith-selected').setStyle({ left: 0, width: (this.view == 'week' || this.view == 'workweek') ? '90%' : '95%', zIndex: 1 }); + var elt = e.detail.source, + state = e.detail.state, + kd = elt.retrieve('kronDrag'); + if (!kd) { + return; } - this.scrollTop = $('kronolithView' + this.view.capitalize()) - .down('.kronolithViewBody') - .scrollTop; - this.scrollLast = this.scrollTop; + /* Copy consumer-side constants into the drag's state bag. + * The state bag persists through Drag:move / Drag:end so we + * don't have to look up kronDrag again per move. */ + Object.extend(state, kd); + + /* Snapshot the source's initial geometry in its + * offsetParent frame (the events grid). Every Drag:move + * computes the new top/height from live clientX/clientY plus + * a fresh read of the events body's viewportOffset and + * scrollTop; only these snapshots are captured at start. */ + var body = $('kronolithView' + this.view.capitalize()).down('.kronolithViewBody'), + div = (state.mode == 'resize-top' || state.mode == 'resize-bottom') + ? elt.up() : elt, + bodyRect = body.getBoundingClientRect(); + state.div = div; + state.body = body; + state.startTop = div.offsetTop; + state.startLeft = div.offsetLeft; + state.startHeight = div.offsetHeight; + state.startBottom = state.startTop + state.startHeight; + /* Cursor's offset inside the drag source, expressed in + * events-grid coordinates. Fixed for the drag; the ghost + * (or the source itself, since kronolith writes styles on + * the source directly) tracks the cursor while preserving + * this offset. */ + state.grabInsideSource = + e.detail.clientY - bodyRect.top + body.scrollTop + - (state.mode == 'resize-top' || state.mode == 'resize-bottom' + ? elt.offsetTop + div.offsetTop + : state.startTop); + /* Same for X, needed by move mode in week/workweek. */ + state.grabInsideSourceX = + e.detail.clientX - bodyRect.left + body.scrollLeft + - state.startLeft; + + if (state.mode == 'resize-top' || state.mode == 'resize-bottom') { + div.classList.add('kronolith-selected'); + } else if (state.mode == 'move') { + div.addClassName('kronolith-selected').setStyle({ + left: 0, + width: (this.view == 'week' || this.view == 'workweek') ? '90%' : '95%', + zIndex: 1 + }); + } }, onDrag: function(e) @@ -5657,66 +5701,76 @@ KronolithCore = { return; } - var elt = e.element(), - drag = DragDrop.Drags.getDrag(elt); - storage = this.view + 'Sizes', - step = this[storage].height / 6; - - if (!drag.event) { - return; - } - - var event = drag.event.value; + var state = e.detail.state; + if (!state || !state.event) { + return; + } - if (elt.classList.contains('kronolithDragger')) { - // Resizing the event. - var div = elt.up(), - top = drag.ghost.cumulativeOffset().top, - scrollTop = $('kronolithView' + this.view.capitalize()).down('.kronolithViewBody').scrollTop, - offset = 0, - height; + /* Live coordinate reads. bodyRect is a fresh read each move + * so page-scroll or auto-scroll of the events body both + * compose correctly with no snapshot state to go stale. */ + var body = state.body, + bodyRect = body.getBoundingClientRect(), + cursorGridY = e.detail.clientY - bodyRect.top + body.scrollTop, + cursorGridX = e.detail.clientX - bodyRect.left + body.scrollLeft, + div = state.div, + event = state.event.value, + storage = this.view + 'Sizes', + step = state.step; - // Check if view has scrolled since last call. - if (scrollTop != this.scrollLast) { - offset = scrollTop - this.scrollLast; - this.scrollLast = scrollTop; - } - if (elt.classList.contains('kronolithDraggerTop')) { - offset += top - drag.top; - height = div.offsetHeight - offset; - div.setStyle({ - top: (div.offsetTop + offset) + 'px' - }); - offset = drag.ghost.offsetTop; - drag.top = top; - } else { - offset += top - drag.top; - height = div.offsetHeight + offset; - offset = div.offsetTop; - drag.top = top; - } + if (state.mode == 'resize-top') { + var newTop = cursorGridY - state.grabInsideSource; + newTop = Math.max(0, Math.min(state.maxTop, + Math.round(newTop / step) * step)); + var newHeight = state.startBottom - newTop; div.setStyle({ - height: height + 'px' + top: newTop + 'px', + height: newHeight + 'px' }); - - this.calculateEventDates(event, storage, step, offset, height); - drag.innerDiv.update('(' + event.start.toString(Kronolith.conf.time_format) + ' - ' + event.end.toString(Kronolith.conf.time_format) + ') ' + event.t.escapeHTML()); - } else if (elt.classList.contains('kronolithEditable')) { - // Moving the event. - if (typeof drag.innerDiv === 'undefined') { - drag.innerDiv = drag.ghost.down('.kronolith-event-info'); - } - if ((this.view == 'week') || (this.view == 'workweek')) { - var offsetX = Math.round(drag.ghost.offsetLeft / drag.stepX); - event.offsetDays = offsetX; - this.calculateEventDates(event, storage, step, drag.ghost.offsetTop, drag.divHeight, event.start.clone().addDays(offsetX), event.end.clone().addDays(offsetX)); + this.calculateEventDates(event, storage, step, newTop, newHeight); + state.innerDiv.update('(' + event.start.toString(Kronolith.conf.time_format) + ' - ' + event.end.toString(Kronolith.conf.time_format) + ') ' + event.t.escapeHTML()); + } else if (state.mode == 'resize-bottom') { + var newBottomEdge = cursorGridY - state.grabInsideSource; + /* Snap the bottom-handle top edge to the hour grid, in + * the same coordinate frame the old dragdrop2 snap + * function used. */ + newBottomEdge = Math.min( + state.maxBottom + state.dragBottomHeight + state.spacing, + Math.round((newBottomEdge + state.dragBottomHeight + state.spacing) / step) * step + ) - state.dragBottomHeight - state.spacing; + newBottomEdge = Math.max(state.minBottom, newBottomEdge); + var newHeight = newBottomEdge - state.startTop; + div.setStyle({ height: newHeight + 'px' }); + this.calculateEventDates(event, storage, step, state.startTop, newHeight); + state.innerDiv.update('(' + event.start.toString(Kronolith.conf.time_format) + ' - ' + event.end.toString(Kronolith.conf.time_format) + ') ' + event.t.escapeHTML()); + } else if (state.mode == 'move') { + var newTop = cursorGridY - state.grabInsideSource; + newTop = Math.max(0, Math.min(state.maxDiv, + Math.round(newTop / step) * step)); + var newLeft = 0, offsetX = 0; + if (state.view == 'week' || state.view == 'workweek') { + newLeft = cursorGridX - state.grabInsideSourceX; + newLeft = Math.max(state.minLeft, Math.min(state.maxLeft, + Math.round(newLeft / state.stepX) * state.stepX)); + offsetX = Math.round(newLeft / state.stepX); + } + div.setStyle({ top: newTop + 'px', left: newLeft + 'px' }); + event.offsetDays = offsetX; + event.offsetTop = newTop - state.startTop; + if (offsetX) { + this.calculateEventDates(event, storage, step, newTop, state.divHeight, event.start.clone().addDays(offsetX), event.end.clone().addDays(offsetX)); } else { - event.offsetDays = 0; - this.calculateEventDates(event, storage, step, drag.ghost.offsetTop, drag.divHeight); + this.calculateEventDates(event, storage, step, newTop, state.divHeight); } - event.offsetTop = drag.ghost.offsetTop - drag.startTop; - drag.innerDiv.update('(' + event.start.toString(Kronolith.conf.time_format) + ' - ' + event.end.toString(Kronolith.conf.time_format) + ') ' + event.t.escapeHTML()); - elt.clonePosition(drag.ghost, { offsetLeft: (this.view == 'week' || this.view == 'workweek') ? -2 : 0 }); + state.innerDiv.update('(' + event.start.toString(Kronolith.conf.time_format) + ' - ' + event.end.toString(Kronolith.conf.time_format) + ') ' + event.t.escapeHTML()); + } else if (state.mode == 'allday-move') { + /* All-day event bar: pure clamp inside the view header + * rectangle. No hour-grid snapping. */ + var newX = e.detail.clientX - bodyRect.left - state.grabInsideSourceX, + newY = e.detail.clientY - bodyRect.top - state.grabInsideSource; + newX = Math.max(state.minLeft, Math.min(state.maxLeft, newX)); + newY = Math.max(state.minTop, Math.min(state.maxTop - div.offsetHeight, newY)); + div.setStyle({ left: newX + 'px', top: newY + 'px' }); } }, @@ -5726,22 +5780,20 @@ KronolithCore = { return; } - if (!e.element().classList.contains('kronolithDragger') && - !e.element().classList.contains('kronolithEditable')) { + var state = e.detail.state; + if (!state || !state.event) { return; } - - var div = e.element(), - drag = DragDrop.Drags.getDrag(div), - event = drag.event; - + var event = state.event, + div = state.div, + source = e.detail.source; if (event.value.al) { return; } - var date = drag.midnight, + var date = state.midnight, storage = this.view + 'Sizes', - step = this[storage].height / 6, + step = state.step, dates = this.viewDates(date, this.view), start = dates[0].dateString(), end = dates[1].dateString(), @@ -5749,20 +5801,20 @@ KronolithCore = { element, attributes; div.classList.remove('kronolith-selected'); - if (typeof drag.innerDiv !== 'undefined') { - this.setEventText(drag.innerDiv, event.value); + if (state.innerDiv) { + this.setEventText(state.innerDiv, event.value); } this.startLoading(event.value.calendar, sig); - if (typeof event.value.offsetTop !== 'undefined') { + if (state.mode == 'move') { attributes = $H({ offDays: event.value.offsetDays, offMins: Math.round(event.value.offsetTop / step) * 10 }); element = div; - } else if (div.classList.contains('kronolithDraggerTop')) { + } else if (state.mode == 'resize-top') { attributes = $H({ start: event.value.start }); - element = div.up(); - } else if (div.classList.contains('kronolithDraggerBottom')) { + element = div; + } else if (state.mode == 'resize-bottom') { attributes = $H({ end: event.value.end }); - element = div.up(); + element = div; } else { attributes = $H({ start: event.value.start, end: event.value.end }); @@ -8087,10 +8139,10 @@ KronolithCore = { /* Initialize global event handlers. */ document.observe('dom:loaded', KronolithCore.onDomLoad.bind(KronolithCore)); -document.observe('DragDrop2:drag', KronolithCore.onDrag.bindAsEventListener(KronolithCore)); -document.observe('DragDrop2:drop', KronolithCore.onDrop.bindAsEventListener(KronolithCore)); -document.observe('DragDrop2:end', KronolithCore.onDragEnd.bindAsEventListener(KronolithCore)); -document.observe('DragDrop2:start', KronolithCore.onDragStart.bindAsEventListener(KronolithCore)); +document.addEventListener('Drag:move', KronolithCore.onDrag.bind(KronolithCore)); +document.addEventListener('Drag:drop', KronolithCore.onDrop.bind(KronolithCore)); +document.addEventListener('Drag:end', KronolithCore.onDragEnd.bind(KronolithCore)); +document.addEventListener('Drag:start', KronolithCore.onDragStart.bind(KronolithCore)); document.observe('Horde_Calendar:select', KronolithCore.datePickerHandler.bindAsEventListener(KronolithCore)); document.observe('FormGhost:reset', KronolithCore.searchReset.bindAsEventListener(KronolithCore)); document.observe('FormGhost:submit', KronolithCore.searchSubmit.bindAsEventListener(KronolithCore)); diff --git a/lib/Ajax.php b/lib/Ajax.php index 94dda0ca..56d866a1 100644 --- a/lib/Ajax.php +++ b/lib/Ajax.php @@ -21,7 +21,7 @@ public function init() { global $page_output; - $page_output->addScriptFile('dragdrop2.js'); + $page_output->addScriptFile('pointerdrag.js', 'horde'); $page_output->addScriptFile('redbox.js', 'horde'); $page_output->addScriptFile('tooltips.js', 'horde'); $page_output->addScriptFile('colorpicker.js', 'horde'); From c0739e1793b5dabf3d1952b2e22bcdd99fb605db Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 23 Jul 2026 10:30:24 +0200 Subject: [PATCH 2/4] fix(dynamic): send resize payload as naive local-time strings --- js/kronolith.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/js/kronolith.js b/js/kronolith.js index e7b15f87..00f59540 100644 --- a/js/kronolith.js +++ b/js/kronolith.js @@ -5810,14 +5810,22 @@ KronolithCore = { offMins: Math.round(event.value.offsetTop / step) * 10 }); element = div; } else if (state.mode == 'resize-top') { - attributes = $H({ start: event.value.start }); + /* Send a naive local-time string, not the Date object. + * JSON-serialising a Date yields UTC ISO, which the + * server double-converts if the event's stored timezone + * differs from the wall-clock timezone the user + * dragged in; result was event start/end shifted by the + * offset. Sending YYYY-MM-DD HH:MM:SS matches how the + * server's Horde_Date constructor accepts naive strings + * in date_default_timezone_get(). */ + attributes = $H({ start: event.value.start.toString('yyyy-MM-dd HH:mm:ss') }); element = div; } else if (state.mode == 'resize-bottom') { - attributes = $H({ end: event.value.end }); + attributes = $H({ end: event.value.end.toString('yyyy-MM-dd HH:mm:ss') }); element = div; } else { - attributes = $H({ start: event.value.start, - end: event.value.end }); + attributes = $H({ start: event.value.start.toString('yyyy-MM-dd HH:mm:ss'), + end: event.value.end.toString('yyyy-MM-dd HH:mm:ss') }); element = div; } if (event.value.r) { From d6a16f5c18a980e38802f0b8becde83986f3a3cc Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 24 Jul 2026 11:36:13 +0200 Subject: [PATCH 3/4] fix(dynamic): enable ghosting for all-day events so cross-day drops resolve --- js/kronolith.js | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/js/kronolith.js b/js/kronolith.js index 00f59540..14462617 100644 --- a/js/kronolith.js +++ b/js/kronolith.js @@ -1858,7 +1858,14 @@ KronolithCore = { minTop: minTop, maxTop: maxTop }); - var d = new HordeDraggable(event.value.nodeId, { threshold: 5 }); + /* ghosting: true so the source's position: + * relative anchor stays inside its cell + * (leaving elementFromPoint free to find + * the target day cell under the cursor) + * while a viewport-fixed clone provides + * the visible drag feedback. See + * horde/kronolith#76 review. */ + var d = new HordeDraggable(event.value.nodeId, { threshold: 5, ghosting: true }); div.store('drags', []); div.retrieve('drags').push(d); } @@ -5763,15 +5770,12 @@ KronolithCore = { this.calculateEventDates(event, storage, step, newTop, state.divHeight); } state.innerDiv.update('(' + event.start.toString(Kronolith.conf.time_format) + ' - ' + event.end.toString(Kronolith.conf.time_format) + ') ' + event.t.escapeHTML()); - } else if (state.mode == 'allday-move') { - /* All-day event bar: pure clamp inside the view header - * rectangle. No hour-grid snapping. */ - var newX = e.detail.clientX - bodyRect.left - state.grabInsideSourceX, - newY = e.detail.clientY - bodyRect.top - state.grabInsideSource; - newX = Math.max(state.minLeft, Math.min(state.maxLeft, newX)); - newY = Math.max(state.minTop, Math.min(state.maxTop - div.offsetHeight, newY)); - div.setStyle({ left: newX + 'px', top: newY + 'px' }); } + /* allday-move has no move handler: the ghost (ghosting:true + * on the Draggable) provides visual feedback, and onDrop's + * elementFromPoint + closest('.horde-drop-target') resolves + * the target day cell for persistence. The source stays + * pinned in its cell, which is what we want. */ }, onDragEnd: function(e) From b77db15a4a8caaf00f6e070c99c2f8e5319a4f42 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Fri, 24 Jul 2026 12:31:12 +0200 Subject: [PATCH 4/4] chore: Bump minimum activesync version to 3.3 (but still optional & dev dependency) --- .horde.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.horde.yml b/.horde.yml index b7caf908..3c918966 100644 --- a/.horde.yml +++ b/.horde.yml @@ -82,7 +82,7 @@ dependencies: composer: horde/nag: ^5 horde/timeobjects: ^3 - horde/activesync: ^3 + horde/activesync: ^3.3 horde/backup: ^2 horde/db: ^3 horde/openxchange: ^2 @@ -93,7 +93,7 @@ dependencies: composer: horde/nag: ^5 horde/timeobjects: ^3 - horde/activesync: ^3 + horde/activesync: ^3.3 horde/backup: ^2 horde/db: ^3 horde/openxchange: ^2