main
Raw Download raw file
  1/*
  2 * noVNC: HTML5 VNC client
  3 * Copyright (C) 2019 The noVNC authors
  4 * Licensed under MPL 2.0 or any later version (see LICENSE.txt)
  5 */
  6
  7import { supportsCursorURIs, isTouchDevice } from './browser.js';
  8
  9const useFallback = !supportsCursorURIs || isTouchDevice;
 10
 11export default class Cursor {
 12    constructor() {
 13        this._target = null;
 14
 15        this._canvas = document.createElement('canvas');
 16
 17        if (useFallback) {
 18            this._canvas.style.position = 'fixed';
 19            this._canvas.style.zIndex = '65535';
 20            this._canvas.style.pointerEvents = 'none';
 21            // Safari on iOS can select the cursor image
 22            // https://bugs.webkit.org/show_bug.cgi?id=249223
 23            this._canvas.style.userSelect = 'none';
 24            this._canvas.style.WebkitUserSelect = 'none';
 25            // Can't use "display" because of Firefox bug #1445997
 26            this._canvas.style.visibility = 'hidden';
 27        }
 28
 29        this._position = { x: 0, y: 0 };
 30        this._hotSpot = { x: 0, y: 0 };
 31
 32        this._eventHandlers = {
 33            'mouseover': this._handleMouseOver.bind(this),
 34            'mouseleave': this._handleMouseLeave.bind(this),
 35            'mousemove': this._handleMouseMove.bind(this),
 36            'mouseup': this._handleMouseUp.bind(this),
 37        };
 38    }
 39
 40    attach(target) {
 41        if (this._target) {
 42            this.detach();
 43        }
 44
 45        this._target = target;
 46
 47        if (useFallback) {
 48            document.body.appendChild(this._canvas);
 49
 50            const options = { capture: true, passive: true };
 51            this._target.addEventListener('mouseover', this._eventHandlers.mouseover, options);
 52            this._target.addEventListener('mouseleave', this._eventHandlers.mouseleave, options);
 53            this._target.addEventListener('mousemove', this._eventHandlers.mousemove, options);
 54            this._target.addEventListener('mouseup', this._eventHandlers.mouseup, options);
 55        }
 56
 57        this.clear();
 58    }
 59
 60    detach() {
 61        if (!this._target) {
 62            return;
 63        }
 64
 65        if (useFallback) {
 66            const options = { capture: true, passive: true };
 67            this._target.removeEventListener('mouseover', this._eventHandlers.mouseover, options);
 68            this._target.removeEventListener('mouseleave', this._eventHandlers.mouseleave, options);
 69            this._target.removeEventListener('mousemove', this._eventHandlers.mousemove, options);
 70            this._target.removeEventListener('mouseup', this._eventHandlers.mouseup, options);
 71
 72            if (document.contains(this._canvas)) {
 73                document.body.removeChild(this._canvas);
 74            }
 75        }
 76
 77        this._target = null;
 78    }
 79
 80    change(rgba, hotx, hoty, w, h) {
 81        if ((w === 0) || (h === 0)) {
 82            this.clear();
 83            return;
 84        }
 85
 86        this._position.x = this._position.x + this._hotSpot.x - hotx;
 87        this._position.y = this._position.y + this._hotSpot.y - hoty;
 88        this._hotSpot.x = hotx;
 89        this._hotSpot.y = hoty;
 90
 91        let ctx = this._canvas.getContext('2d');
 92
 93        this._canvas.width = w;
 94        this._canvas.height = h;
 95
 96        let img = new ImageData(new Uint8ClampedArray(rgba), w, h);
 97        ctx.clearRect(0, 0, w, h);
 98        ctx.putImageData(img, 0, 0);
 99
100        if (useFallback) {
101            this._updatePosition();
102        } else {
103            let url = this._canvas.toDataURL();
104            this._target.style.cursor = 'url(' + url + ')' + hotx + ' ' + hoty + ', default';
105        }
106    }
107
108    clear() {
109        this._target.style.cursor = 'none';
110        this._canvas.width = 0;
111        this._canvas.height = 0;
112        this._position.x = this._position.x + this._hotSpot.x;
113        this._position.y = this._position.y + this._hotSpot.y;
114        this._hotSpot.x = 0;
115        this._hotSpot.y = 0;
116    }
117
118    // Mouse events might be emulated, this allows
119    // moving the cursor in such cases
120    move(clientX, clientY) {
121        if (!useFallback) {
122            return;
123        }
124        // clientX/clientY are relative the _visual viewport_,
125        // but our position is relative the _layout viewport_,
126        // so try to compensate when we can
127        if (window.visualViewport) {
128            this._position.x = clientX + window.visualViewport.offsetLeft;
129            this._position.y = clientY + window.visualViewport.offsetTop;
130        } else {
131            this._position.x = clientX;
132            this._position.y = clientY;
133        }
134        this._updatePosition();
135        let target = document.elementFromPoint(clientX, clientY);
136        this._updateVisibility(target);
137    }
138
139    _handleMouseOver(event) {
140        // This event could be because we're entering the target, or
141        // moving around amongst its sub elements. Let the move handler
142        // sort things out.
143        this._handleMouseMove(event);
144    }
145
146    _handleMouseLeave(event) {
147        // Check if we should show the cursor on the element we are leaving to
148        this._updateVisibility(event.relatedTarget);
149    }
150
151    _handleMouseMove(event) {
152        this._updateVisibility(event.target);
153
154        this._position.x = event.clientX - this._hotSpot.x;
155        this._position.y = event.clientY - this._hotSpot.y;
156
157        this._updatePosition();
158    }
159
160    _handleMouseUp(event) {
161        // We might get this event because of a drag operation that
162        // moved outside of the target. Check what's under the cursor
163        // now and adjust visibility based on that.
164        let target = document.elementFromPoint(event.clientX, event.clientY);
165        this._updateVisibility(target);
166
167        // Captures end with a mouseup but we can't know the event order of
168        // mouseup vs releaseCapture.
169        //
170        // In the cases when releaseCapture comes first, the code above is
171        // enough.
172        //
173        // In the cases when the mouseup comes first, we need wait for the
174        // browser to flush all events and then check again if the cursor
175        // should be visible.
176        if (this._captureIsActive()) {
177            window.setTimeout(() => {
178                // We might have detached at this point
179                if (!this._target) {
180                    return;
181                }
182                // Refresh the target from elementFromPoint since queued events
183                // might have altered the DOM
184                target = document.elementFromPoint(event.clientX,
185                                                   event.clientY);
186                this._updateVisibility(target);
187            }, 0);
188        }
189    }
190
191    _showCursor() {
192        if (this._canvas.style.visibility === 'hidden') {
193            this._canvas.style.visibility = '';
194        }
195    }
196
197    _hideCursor() {
198        if (this._canvas.style.visibility !== 'hidden') {
199            this._canvas.style.visibility = 'hidden';
200        }
201    }
202
203    // Should we currently display the cursor?
204    // (i.e. are we over the target, or a child of the target without a
205    // different cursor set)
206    _shouldShowCursor(target) {
207        if (!target) {
208            return false;
209        }
210        // Easy case
211        if (target === this._target) {
212            return true;
213        }
214        // Other part of the DOM?
215        if (!this._target.contains(target)) {
216            return false;
217        }
218        // Has the child its own cursor?
219        // FIXME: How can we tell that a sub element has an
220        //        explicit "cursor: none;"?
221        if (window.getComputedStyle(target).cursor !== 'none') {
222            return false;
223        }
224        return true;
225    }
226
227    _updateVisibility(target) {
228        // When the cursor target has capture we want to show the cursor.
229        // So, if a capture is active - look at the captured element instead.
230        if (this._captureIsActive()) {
231            target = document.captureElement;
232        }
233        if (this._shouldShowCursor(target)) {
234            this._showCursor();
235        } else {
236            this._hideCursor();
237        }
238    }
239
240    _updatePosition() {
241        this._canvas.style.left = this._position.x + "px";
242        this._canvas.style.top = this._position.y + "px";
243    }
244
245    _captureIsActive() {
246        return document.captureElement &&
247            document.documentElement.contains(document.captureElement);
248    }
249}