mirror of
https://github.com/m1k1o/neko.git
synced 2024-07-24 14:40:50 +12:00
994 lines
26 KiB
Vue
994 lines
26 KiB
Vue
<template>
|
|
<div class="neko-overlay-wrap">
|
|
<canvas ref="overlay" class="neko-overlay" tabindex="0" />
|
|
<textarea
|
|
ref="textarea"
|
|
class="neko-overlay"
|
|
:style="{ cursor }"
|
|
v-model="textInput"
|
|
@click.stop.prevent="control.emit('overlay.click', $event)"
|
|
@contextmenu.stop.prevent="control.emit('overlay.contextmenu', $event)"
|
|
@wheel.stop.prevent="onWheel"
|
|
@mousemove.stop.prevent="onMouseMove"
|
|
@mousedown.stop.prevent="onMouseDown"
|
|
@mouseenter.stop.prevent="onMouseEnter"
|
|
@mouseleave.stop.prevent="onMouseLeave"
|
|
@dragenter.stop.prevent="onDragEnter"
|
|
@dragleave.stop.prevent="onDragLeave"
|
|
@dragover.stop.prevent="onDragOver"
|
|
@drop.stop.prevent="onDrop"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
/* hide elements around textarea if added by browsers extensions */
|
|
.neko-overlay-wrap *:not(.neko-overlay) {
|
|
display: none;
|
|
}
|
|
|
|
.neko-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
font-size: 16px; /* at least 16px to avoid zooming on mobile */
|
|
resize: none; /* hide textarea resize corner */
|
|
caret-color: transparent; /* hide caret */
|
|
outline: 0;
|
|
border: 0;
|
|
color: transparent;
|
|
background: transparent;
|
|
}
|
|
</style>
|
|
|
|
<script lang="ts" setup>
|
|
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
|
|
|
import type { KeyboardInterface } from './utils/keyboard'
|
|
import type { GestureHandler } from './utils/gesturehandler'
|
|
import type { Session, Scroll } from './types/state'
|
|
import type { CursorPosition, CursorImage } from './types/webrtc'
|
|
import type { CursorDrawFunction, Dimension, KeyboardModifiers } from './types/cursors'
|
|
|
|
import { NewKeyboard } from './utils/keyboard'
|
|
import GestureHandlerInit from './utils/gesturehandler'
|
|
import { KeyTable, keySymsRemap } from './utils/keyboard-remapping'
|
|
import { getFilesFromDataTansfer } from './utils/file-upload'
|
|
import { NekoControl } from './internal/control'
|
|
import { NekoWebRTC } from './internal/webrtc'
|
|
|
|
// Wheel thresholds
|
|
const WHEEL_STEP = 53 // Pixels needed for one step
|
|
const WHEEL_LINE_HEIGHT = 19 // Assumed pixels for one line step
|
|
|
|
// Gesture thresholds
|
|
const GESTURE_ZOOMSENS = 75
|
|
const GESTURE_SCRLSENS = 50
|
|
const DOUBLE_TAP_TIMEOUT = 1000
|
|
const DOUBLE_TAP_THRESHOLD = 50
|
|
|
|
const MOUSE_MOVE_THROTTLE = 1000 / 60 // in ms, 60fps
|
|
const INACTIVE_CURSOR_INTERVAL = 1000 / 4 // in ms, 4fps
|
|
|
|
// refs
|
|
|
|
const overlay = ref<HTMLCanvasElement | null>(null)
|
|
const textarea = ref<HTMLTextAreaElement | null>(null)
|
|
|
|
let ctx: CanvasRenderingContext2D | null = null
|
|
let canvasScale = window.devicePixelRatio
|
|
|
|
let keyboard: KeyboardInterface = NewKeyboard()
|
|
let gestureHandler: GestureHandler = new GestureHandlerInit()
|
|
|
|
const focused = ref(false)
|
|
const textInput = ref('')
|
|
|
|
// props and emits
|
|
|
|
const props = defineProps<{
|
|
control: NekoControl
|
|
sessions: Record<string, Session>
|
|
hostId: string
|
|
webrtc: NekoWebRTC
|
|
scroll: Scroll
|
|
screenSize: Dimension
|
|
canvasSize: Dimension
|
|
cursorDraw: CursorDrawFunction | null
|
|
isControling: boolean
|
|
implicitControl: boolean
|
|
inactiveCursors: boolean
|
|
fps: number
|
|
hasMobileKeyboard: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits(['uploadDrop', 'updateKeyboardModifiers', 'mobileKeyboardOpen'])
|
|
|
|
// computed
|
|
|
|
const cursor = computed(() => {
|
|
if (!props.isControling || !cursorImage.value) {
|
|
return 'default'
|
|
}
|
|
|
|
const { uri, x, y } = cursorImage.value
|
|
return 'url(' + uri + ') ' + x + ' ' + y + ', default'
|
|
})
|
|
|
|
// lifecycle
|
|
|
|
onMounted(() => {
|
|
// register mouseup globally as user can release mouse button outside of overlay
|
|
window.addEventListener('mouseup', onMouseUp, true)
|
|
|
|
// get canvas overlay context
|
|
ctx = overlay.value!.getContext('2d')
|
|
|
|
// synchronize intrinsic with extrinsic dimensions
|
|
const { width, height } = overlay.value?.getBoundingClientRect() || { width: 0, height: 0 }
|
|
canvasResize({ width, height })
|
|
|
|
// react to pixel ratio changes
|
|
onPixelRatioChange()
|
|
|
|
let ctrlKey = 0
|
|
let noKeyUp = {} as Record<number, boolean>
|
|
|
|
// Initialize Keyboard
|
|
keyboard.onkeydown = (key: number) => {
|
|
key = keySymsRemap(key)
|
|
|
|
if (!props.isControling) {
|
|
noKeyUp[key] = true
|
|
return true
|
|
}
|
|
|
|
// ctrl+v is aborted
|
|
if (ctrlKey != 0 && key == KeyTable.XK_v) {
|
|
keyboard!.release(ctrlKey)
|
|
noKeyUp[key] = true
|
|
return true
|
|
}
|
|
|
|
// save information if it is ctrl key event
|
|
const isCtrlKey = key == KeyTable.XK_Control_L || key == KeyTable.XK_Control_R
|
|
if (isCtrlKey) ctrlKey = key
|
|
|
|
props.control.keyDown(key)
|
|
return isCtrlKey
|
|
}
|
|
keyboard.onkeyup = (key: number) => {
|
|
key = keySymsRemap(key)
|
|
|
|
if (key in noKeyUp) {
|
|
delete noKeyUp[key]
|
|
return
|
|
}
|
|
|
|
const isCtrlKey = key == KeyTable.XK_Control_L || key == KeyTable.XK_Control_R
|
|
if (isCtrlKey) ctrlKey = 0
|
|
|
|
props.control.keyUp(key)
|
|
}
|
|
keyboard.listenTo(textarea.value!)
|
|
|
|
// bind touch handler using @Watch on supportedTouchEvents
|
|
// because we need to know if touch events are supported
|
|
// by the server before we can bind touch handler
|
|
|
|
// default value is false, so we can bind touch handler
|
|
bindGestureHandler()
|
|
|
|
props.webrtc.addListener('cursor-position', onCursorPosition)
|
|
props.webrtc.addListener('cursor-image', onCursorImage)
|
|
props.webrtc.addListener('disconnected', canvasClear)
|
|
cursorElement.onload = canvasRequestRedraw
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('mouseup', onMouseUp, true)
|
|
keyboard.removeListener()
|
|
|
|
// unbind touch handler
|
|
unbindTouchHandler()
|
|
|
|
// unbind gesture handler
|
|
unbindGestureHandler()
|
|
|
|
props.webrtc.removeListener('cursor-position', onCursorPosition)
|
|
props.webrtc.removeListener('cursor-image', onCursorImage)
|
|
props.webrtc.removeListener('disconnected', canvasClear)
|
|
cursorElement.onload = null
|
|
|
|
// stop inactive cursor interval if exists
|
|
clearInactiveCursorInterval()
|
|
|
|
// stop pixel ratio change listener
|
|
if (unsubscribePixelRatioChange) {
|
|
unsubscribePixelRatioChange()
|
|
}
|
|
})
|
|
|
|
//
|
|
// touch handler for native touch events
|
|
//
|
|
|
|
function bindTouchHandler() {
|
|
textarea.value?.addEventListener('touchstart', onTouchHandler, { passive: false })
|
|
textarea.value?.addEventListener('touchmove', onTouchHandler, { passive: false })
|
|
textarea.value?.addEventListener('touchend', onTouchHandler, { passive: false })
|
|
textarea.value?.addEventListener('touchcancel', onTouchHandler, { passive: false })
|
|
}
|
|
|
|
function unbindTouchHandler() {
|
|
textarea.value?.removeEventListener('touchstart', onTouchHandler)
|
|
textarea.value?.removeEventListener('touchmove', onTouchHandler)
|
|
textarea.value?.removeEventListener('touchend', onTouchHandler)
|
|
textarea.value?.removeEventListener('touchcancel', onTouchHandler)
|
|
}
|
|
|
|
function onTouchHandler(ev: TouchEvent) {
|
|
// we cannot use implicitControlRequest because we don't have mouse event
|
|
if (!props.isControling) {
|
|
// if implicitControl is enabled, request control
|
|
if (props.implicitControl) {
|
|
props.control.request()
|
|
}
|
|
// otherwise, ignore event
|
|
return
|
|
}
|
|
|
|
ev.stopPropagation()
|
|
ev.preventDefault()
|
|
|
|
for (let i = 0; i < ev.changedTouches.length; i++) {
|
|
const touch = ev.changedTouches[i]
|
|
const pos = getMousePos(touch.clientX, touch.clientY)
|
|
// force is float value between 0 and 1
|
|
// pressure is integer value between 0 and 255
|
|
const pressure = Math.round(touch.force * 255)
|
|
|
|
switch (ev.type) {
|
|
case 'touchstart':
|
|
props.control.touchBegin(touch.identifier, pos, pressure)
|
|
break
|
|
case 'touchmove':
|
|
props.control.touchUpdate(touch.identifier, pos, pressure)
|
|
break
|
|
case 'touchend':
|
|
case 'touchcancel':
|
|
props.control.touchEnd(touch.identifier, pos, pressure)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
//
|
|
// gesture handler for emulated mouse events
|
|
//
|
|
|
|
function bindGestureHandler() {
|
|
gestureHandler.attach(textarea.value!)
|
|
textarea.value?.addEventListener('gesturestart', onGestureHandler)
|
|
textarea.value?.addEventListener('gesturemove', onGestureHandler)
|
|
textarea.value?.addEventListener('gestureend', onGestureHandler)
|
|
}
|
|
|
|
function unbindGestureHandler() {
|
|
gestureHandler.detach()
|
|
textarea.value?.removeEventListener('gesturestart', onGestureHandler)
|
|
textarea.value?.removeEventListener('gesturemove', onGestureHandler)
|
|
textarea.value?.removeEventListener('gestureend', onGestureHandler)
|
|
}
|
|
|
|
let gestureLastTapTime: number | null = null
|
|
let gestureFirstDoubleTapEv: any | null = null
|
|
let gestureLastMagnitudeX = 0
|
|
let gestureLastMagnitudeY = 0
|
|
|
|
function _handleTapEvent(ev: any, code: number) {
|
|
let pos = getMousePos(ev.detail.clientX, ev.detail.clientY)
|
|
|
|
// If the user quickly taps multiple times we assume they meant to
|
|
// hit the same spot, so slightly adjust coordinates
|
|
|
|
if (
|
|
gestureLastTapTime !== null &&
|
|
Date.now() - gestureLastTapTime < DOUBLE_TAP_TIMEOUT &&
|
|
gestureFirstDoubleTapEv?.detail.type === ev.detail.type
|
|
) {
|
|
const dx = gestureFirstDoubleTapEv.detail.clientX - ev.detail.clientX
|
|
const dy = gestureFirstDoubleTapEv.detail.clientY - ev.detail.clientY
|
|
const distance = Math.hypot(dx, dy)
|
|
|
|
if (distance < DOUBLE_TAP_THRESHOLD) {
|
|
pos = getMousePos(gestureFirstDoubleTapEv.detail.clientX, gestureFirstDoubleTapEv.detail.clientY)
|
|
} else {
|
|
gestureFirstDoubleTapEv = ev
|
|
}
|
|
} else {
|
|
gestureFirstDoubleTapEv = ev
|
|
}
|
|
gestureLastTapTime = Date.now()
|
|
|
|
props.control.buttonDown(code, pos)
|
|
props.control.buttonUp(code, pos)
|
|
}
|
|
|
|
function onGestureHandler(ev: any) {
|
|
// we cannot use implicitControlRequest because we don't have mouse event
|
|
if (!props.isControling) {
|
|
// if implicitControl is enabled, request control
|
|
if (props.implicitControl) {
|
|
props.control.request()
|
|
}
|
|
// otherwise, ignore event
|
|
return
|
|
}
|
|
|
|
const pos = getMousePos(ev.detail.clientX, ev.detail.clientY)
|
|
|
|
let magnitude
|
|
switch (ev.type) {
|
|
case 'gesturestart':
|
|
switch (ev.detail.type) {
|
|
case 'onetap':
|
|
_handleTapEvent(ev, 1)
|
|
break
|
|
case 'twotap':
|
|
_handleTapEvent(ev, 3)
|
|
break
|
|
case 'threetap':
|
|
_handleTapEvent(ev, 2)
|
|
break
|
|
case 'drag':
|
|
props.control.buttonDown(1, pos)
|
|
break
|
|
case 'longpress':
|
|
props.control.buttonDown(3, pos)
|
|
break
|
|
|
|
case 'twodrag':
|
|
gestureLastMagnitudeX = ev.detail.magnitudeX
|
|
gestureLastMagnitudeY = ev.detail.magnitudeY
|
|
props.control.move(pos)
|
|
break
|
|
case 'pinch':
|
|
gestureLastMagnitudeX = Math.hypot(ev.detail.magnitudeX, ev.detail.magnitudeY)
|
|
props.control.move(pos)
|
|
break
|
|
}
|
|
break
|
|
|
|
case 'gesturemove':
|
|
switch (ev.detail.type) {
|
|
case 'onetap':
|
|
case 'twotap':
|
|
case 'threetap':
|
|
break
|
|
case 'drag':
|
|
case 'longpress':
|
|
props.control.move(pos)
|
|
break
|
|
case 'twodrag':
|
|
// Always scroll in the same position.
|
|
// We don't know if the mouse was moved so we need to move it
|
|
// every update.
|
|
props.control.move(pos)
|
|
while (ev.detail.magnitudeY - gestureLastMagnitudeY > GESTURE_SCRLSENS) {
|
|
props.control.scroll({ delta_x: 0, delta_y: 1 })
|
|
gestureLastMagnitudeY += GESTURE_SCRLSENS
|
|
}
|
|
while (ev.detail.magnitudeY - gestureLastMagnitudeY < -GESTURE_SCRLSENS) {
|
|
props.control.scroll({ delta_x: 0, delta_y: -1 })
|
|
gestureLastMagnitudeY -= GESTURE_SCRLSENS
|
|
}
|
|
while (ev.detail.magnitudeX - gestureLastMagnitudeX > GESTURE_SCRLSENS) {
|
|
props.control.scroll({ delta_x: 1, delta_y: 0 })
|
|
gestureLastMagnitudeX+= GESTURE_SCRLSENS
|
|
}
|
|
while (ev.detail.magnitudeX - gestureLastMagnitudeX < -GESTURE_SCRLSENS) {
|
|
props.control.scroll({ delta_x: -1, delta_y: 0 })
|
|
gestureLastMagnitudeX-= GESTURE_SCRLSENS
|
|
}
|
|
break
|
|
case 'pinch':
|
|
// Always scroll in the same position.
|
|
// We don't know if the mouse was moved so we need to move it
|
|
// every update.
|
|
props.control.move(pos)
|
|
magnitude = Math.hypot(ev.detail.magnitudeX, ev.detail.magnitudeY)
|
|
if (Math.abs(magnitude - gestureLastMagnitudeX) > GESTURE_ZOOMSENS) {
|
|
while (magnitude - gestureLastMagnitudeX > GESTURE_ZOOMSENS) {
|
|
props.control.scroll({ delta_x: 0, delta_y: 1, control_key: true })
|
|
gestureLastMagnitudeX+= GESTURE_ZOOMSENS
|
|
}
|
|
while (magnitude - gestureLastMagnitudeX < -GESTURE_ZOOMSENS) {
|
|
props.control.scroll({ delta_x: 0, delta_y: -1, control_key: true })
|
|
gestureLastMagnitudeX-= GESTURE_ZOOMSENS
|
|
}
|
|
}
|
|
break
|
|
}
|
|
break
|
|
|
|
case 'gestureend':
|
|
switch (ev.detail.type) {
|
|
case 'onetap':
|
|
case 'twotap':
|
|
case 'threetap':
|
|
case 'pinch':
|
|
case 'twodrag':
|
|
break
|
|
case 'drag':
|
|
props.control.buttonUp(1, pos)
|
|
break
|
|
case 'longpress':
|
|
props.control.buttonUp(3, pos)
|
|
break
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
|
|
//
|
|
// touch and gesture handlers cannot be used together
|
|
//
|
|
|
|
function onTouchEventsChange() {
|
|
unbindGestureHandler()
|
|
unbindTouchHandler()
|
|
|
|
if (!props.control.enabledTouchEvents) {
|
|
return
|
|
}
|
|
|
|
if (props.control.supportedTouchEvents) {
|
|
bindTouchHandler()
|
|
} else {
|
|
bindGestureHandler()
|
|
}
|
|
}
|
|
|
|
watch(() => props.control.enabledTouchEvents, onTouchEventsChange)
|
|
watch(() => props.control.supportedTouchEvents, onTouchEventsChange)
|
|
|
|
|
|
function getModifierState(e: MouseEvent): KeyboardModifiers {
|
|
// we can only use locks, because when someone holds key outside
|
|
// of the renderer, and releases it inside, keyup event is not fired
|
|
// by guacamole keyboard and modifier state is not updated
|
|
|
|
return {
|
|
//shift: e.getModifierState('Shift'),
|
|
capslock: e.getModifierState('CapsLock'),
|
|
//control: e.getModifierState('Control'),
|
|
//alt: e.getModifierState('Alt'),
|
|
numlock: e.getModifierState('NumLock'),
|
|
//meta: e.getModifierState('Meta'),
|
|
//super: e.getModifierState('Super'),
|
|
//altgr: e.getModifierState('AltGraph'),
|
|
}
|
|
}
|
|
|
|
function getMousePos(clientX: number, clientY: number) {
|
|
const rect = overlay.value!.getBoundingClientRect()
|
|
|
|
return {
|
|
x: Math.round((props.screenSize.width / rect.width) * (clientX - rect.left)),
|
|
y: Math.round((props.screenSize.height / rect.height) * (clientY - rect.top)),
|
|
}
|
|
}
|
|
|
|
function sendMousePos(e: MouseEvent) {
|
|
const pos = getMousePos(e.clientX, e.clientY)
|
|
// not using NekoControl here because we want to avoid
|
|
// sending mousemove events over websocket
|
|
if (props.webrtc.connected) {
|
|
props.webrtc.send('mousemove', pos)
|
|
} // otherwise, no events are sent
|
|
cursorPosition = pos
|
|
}
|
|
|
|
let wheelX = 0
|
|
let wheelY = 0
|
|
let wheelTimeStamp = 0
|
|
|
|
// negative sensitivity can be acheived using increased step value
|
|
const wheelStep = computed(() => {
|
|
let x = WHEEL_STEP
|
|
|
|
if (props.scroll.sensitivity < 0) {
|
|
x *= Math.abs(props.scroll.sensitivity) + 1
|
|
}
|
|
|
|
return x
|
|
})
|
|
|
|
|
|
// sensitivity can only be positive
|
|
const wheelSensitivity = computed(() => {
|
|
let x = 1
|
|
|
|
if (props.scroll.sensitivity > 0) {
|
|
x = Math.abs(props.scroll.sensitivity) + 1
|
|
}
|
|
|
|
if (props.scroll.inverse) {
|
|
x *= -1
|
|
}
|
|
|
|
return x
|
|
})
|
|
|
|
// use v-model instead of @input because v-model
|
|
// doesn't get updated during IME composition
|
|
function onTextInputChange() {
|
|
if (textInput.value == '') return
|
|
props.control.paste(textInput.value)
|
|
textInput.value = ''
|
|
}
|
|
|
|
watch(() => textInput.value, onTextInputChange)
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
if (!props.isControling) {
|
|
return
|
|
}
|
|
|
|
// when the last scroll was more than 250ms ago
|
|
const firstScroll = e.timeStamp - wheelTimeStamp > 250
|
|
|
|
if (firstScroll) {
|
|
wheelX = 0
|
|
wheelY = 0
|
|
wheelTimeStamp = e.timeStamp
|
|
}
|
|
|
|
let dx = e.deltaX
|
|
let dy = e.deltaY
|
|
|
|
if (e.deltaMode !== 0) {
|
|
dx *= WHEEL_LINE_HEIGHT
|
|
dy *= WHEEL_LINE_HEIGHT
|
|
}
|
|
|
|
wheelX += dx
|
|
wheelY += dy
|
|
|
|
let x = 0
|
|
if (Math.abs(wheelX) >= wheelStep.value || firstScroll) {
|
|
if (wheelX < 0) {
|
|
x = wheelSensitivity.value * -1
|
|
} else if (wheelX > 0) {
|
|
x = wheelSensitivity.value
|
|
}
|
|
|
|
if (!firstScroll) {
|
|
wheelX = 0
|
|
}
|
|
}
|
|
|
|
let y = 0
|
|
if (Math.abs(wheelY) >= wheelStep.value || firstScroll) {
|
|
if (wheelY < 0) {
|
|
y = wheelSensitivity.value * -1
|
|
} else if (wheelY > 0) {
|
|
y = wheelSensitivity.value
|
|
}
|
|
|
|
if (!firstScroll) {
|
|
wheelY = 0
|
|
}
|
|
}
|
|
|
|
// skip if not scrolled
|
|
if (x == 0 && y == 0) return
|
|
|
|
// TODO: add position for precision scrolling
|
|
props.control.scroll({
|
|
delta_x: x,
|
|
delta_y: y,
|
|
control_key: e.ctrlKey,
|
|
})
|
|
}
|
|
|
|
let lastMouseMove = 0
|
|
|
|
function onMouseMove(e: MouseEvent) {
|
|
// throttle mousemove events
|
|
if (e.timeStamp - lastMouseMove < MOUSE_MOVE_THROTTLE) return
|
|
lastMouseMove = e.timeStamp
|
|
|
|
if (props.isControling) {
|
|
sendMousePos(e)
|
|
}
|
|
|
|
if (props.inactiveCursors) {
|
|
saveInactiveMousePos(e)
|
|
}
|
|
}
|
|
|
|
let isMouseDown = false
|
|
|
|
function onMouseDown(e: MouseEvent) {
|
|
isMouseDown = true
|
|
|
|
if (!props.isControling) {
|
|
implicitControlRequest(e)
|
|
return
|
|
}
|
|
|
|
const key = e.button + 1
|
|
const pos = getMousePos(e.clientX, e.clientY)
|
|
props.control.buttonDown(key, pos)
|
|
}
|
|
|
|
function onMouseUp(e: MouseEvent) {
|
|
// only if we are the one who started the mouse down
|
|
if (!isMouseDown) return
|
|
isMouseDown = false
|
|
|
|
if (!props.isControling) {
|
|
implicitControlRequest(e)
|
|
return
|
|
}
|
|
|
|
const key = e.button + 1
|
|
const pos = getMousePos(e.clientX, e.clientY)
|
|
props.control.buttonUp(key, pos)
|
|
}
|
|
|
|
function onMouseEnter(e: MouseEvent) {
|
|
// focus opens the keyboard on mobile (only for android)
|
|
if (!props.hasMobileKeyboard) {
|
|
textarea.value?.focus()
|
|
}
|
|
|
|
focused.value = true
|
|
|
|
if (props.isControling) {
|
|
updateKeyboardModifiers(e)
|
|
}
|
|
}
|
|
|
|
function onMouseLeave(e: MouseEvent) {
|
|
if (props.isControling) {
|
|
// save current keyboard modifiers state
|
|
keyboardModifiers = getModifierState(e)
|
|
}
|
|
|
|
focused.value = false
|
|
}
|
|
|
|
function onDragEnter(e: DragEvent) {
|
|
onMouseEnter(e as MouseEvent)
|
|
}
|
|
|
|
function onDragLeave(e: DragEvent) {
|
|
onMouseLeave(e as MouseEvent)
|
|
}
|
|
|
|
function onDragOver(e: DragEvent) {
|
|
onMouseMove(e as MouseEvent)
|
|
}
|
|
|
|
async function onDrop(e: DragEvent) {
|
|
if (props.isControling || props.implicitControl) {
|
|
const dt = e.dataTransfer
|
|
if (!dt) return
|
|
|
|
const files = await getFilesFromDataTansfer(dt)
|
|
if (files.length === 0) return
|
|
|
|
const pos = getMousePos(e.clientX, e.clientY)
|
|
emit('uploadDrop', { ...pos, files })
|
|
}
|
|
}
|
|
|
|
//
|
|
// inactive cursor position
|
|
//
|
|
|
|
let inactiveCursorInterval: number | null = null
|
|
let inactiveCursorPosition: CursorPosition | null = null
|
|
|
|
function clearInactiveCursorInterval() {
|
|
if (inactiveCursorInterval) {
|
|
window.clearInterval(inactiveCursorInterval)
|
|
inactiveCursorInterval = null
|
|
}
|
|
}
|
|
|
|
function restartInactiveCursorInterval() {
|
|
// clear interval if exists
|
|
clearInactiveCursorInterval()
|
|
|
|
if (props.inactiveCursors && focused.value && !props.isControling) {
|
|
inactiveCursorInterval = window.setInterval(sendInactiveMousePos, INACTIVE_CURSOR_INTERVAL)
|
|
}
|
|
}
|
|
|
|
watch(focused, restartInactiveCursorInterval)
|
|
watch(() => props.inactiveCursors, restartInactiveCursorInterval)
|
|
watch(() => props.isControling, restartInactiveCursorInterval)
|
|
|
|
function saveInactiveMousePos(e: MouseEvent) {
|
|
const pos = getMousePos(e.clientX, e.clientY)
|
|
inactiveCursorPosition = pos
|
|
}
|
|
|
|
function sendInactiveMousePos() {
|
|
if (inactiveCursorPosition && props.webrtc.connected) {
|
|
// not using NekoControl here, because inactive cursors are
|
|
// treated differently than moving the mouse while controling
|
|
props.webrtc.send('mousemove', inactiveCursorPosition)
|
|
} // if webrtc is not connected, we don't need to send anything
|
|
}
|
|
|
|
//
|
|
// keyboard modifiers
|
|
//
|
|
|
|
let keyboardModifiers: KeyboardModifiers | null = null
|
|
|
|
function updateKeyboardModifiers(e: MouseEvent) {
|
|
const mods = getModifierState(e)
|
|
const newMods = Object.values(mods).join()
|
|
const oldMods = Object.values(keyboardModifiers || {}).join()
|
|
|
|
// update keyboard modifiers only if they changed
|
|
if (newMods !== oldMods) {
|
|
emit('updateKeyboardModifiers', mods)
|
|
}
|
|
}
|
|
|
|
//
|
|
// canvas
|
|
//
|
|
|
|
const cursorImage = ref<CursorImage | null>(null)
|
|
const cursorElement = new Image()
|
|
|
|
let cursorPosition: CursorPosition | null = null
|
|
let cursorLastTime = 0
|
|
let canvasRequestedFrame = false
|
|
let canvasRenderTimeout: number | null = null
|
|
|
|
let unsubscribePixelRatioChange: (() => void) | null = null
|
|
|
|
function onPixelRatioChange() {
|
|
if (unsubscribePixelRatioChange) {
|
|
unsubscribePixelRatioChange()
|
|
}
|
|
|
|
const media = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`)
|
|
media.addEventListener('change', onPixelRatioChange)
|
|
unsubscribePixelRatioChange = () => {
|
|
media.removeEventListener('change', onPixelRatioChange)
|
|
}
|
|
|
|
canvasScale = window.devicePixelRatio
|
|
onCanvasSizeChange(props.canvasSize)
|
|
}
|
|
|
|
function onCanvasSizeChange({ width, height }: Dimension) {
|
|
canvasResize({ width, height })
|
|
canvasRequestRedraw()
|
|
}
|
|
|
|
watch(() => props.canvasSize, onCanvasSizeChange)
|
|
|
|
function onCursorPosition(data: CursorPosition) {
|
|
if (!props.isControling) {
|
|
cursorPosition = data
|
|
canvasRequestRedraw()
|
|
}
|
|
}
|
|
|
|
function onCursorImage(data: CursorImage) {
|
|
cursorImage.value = data
|
|
|
|
if (!props.isControling) {
|
|
cursorElement.src = data.uri
|
|
}
|
|
}
|
|
|
|
function canvasResize({ width, height }: Dimension) {
|
|
if (!ctx || !overlay.value) return
|
|
|
|
overlay.value.width = width * canvasScale
|
|
overlay.value.height = height * canvasScale
|
|
ctx.setTransform(canvasScale, 0, 0, canvasScale, 0, 0)
|
|
}
|
|
|
|
function canvasRequestRedraw() {
|
|
if (canvasRequestedFrame) return
|
|
|
|
if (props.fps > 0) {
|
|
if (canvasRenderTimeout) {
|
|
window.clearTimeout(canvasRenderTimeout)
|
|
canvasRenderTimeout = null
|
|
}
|
|
|
|
const now = Date.now()
|
|
if (now - cursorLastTime < 1000 / props.fps) {
|
|
canvasRenderTimeout = window.setTimeout(canvasRequestRedraw, 1000 / props.fps)
|
|
return
|
|
}
|
|
|
|
cursorLastTime = now
|
|
}
|
|
|
|
canvasRequestedFrame = true
|
|
window.requestAnimationFrame(() => {
|
|
if (props.isControling) {
|
|
canvasClear()
|
|
} else {
|
|
canvasRedraw()
|
|
}
|
|
|
|
canvasRequestedFrame = false
|
|
})
|
|
}
|
|
|
|
watch(() => props.hostId, canvasRequestRedraw)
|
|
watch(() => props.cursorDraw, canvasRequestRedraw)
|
|
|
|
function canvasRedraw() {
|
|
if (!ctx || !cursorPosition || !props.screenSize || !cursorImage.value) return
|
|
|
|
// clear drawings
|
|
canvasClear()
|
|
|
|
// ignore hidden cursor
|
|
if (cursorImage.value.width <= 1 && cursorImage.value.height <= 1) return
|
|
|
|
// get intrinsic dimensions
|
|
const { width, height } = props.canvasSize
|
|
|
|
// reset transformation, X and Y will be 0 again
|
|
ctx.setTransform(canvasScale, 0, 0, canvasScale, 0, 0)
|
|
|
|
// get cursor position
|
|
let x = Math.round((cursorPosition.x / props.screenSize.width) * width)
|
|
let y = Math.round((cursorPosition.y / props.screenSize.height) * height)
|
|
|
|
// use custom draw function, if available
|
|
if (props.cursorDraw) {
|
|
props.cursorDraw(ctx, x, y, cursorElement, cursorImage.value, props.hostId)
|
|
return
|
|
}
|
|
|
|
// draw cursor image
|
|
ctx.drawImage(
|
|
cursorElement,
|
|
x - cursorImage.value.x,
|
|
y - cursorImage.value.y,
|
|
cursorImage.value.width,
|
|
cursorImage.value.height,
|
|
)
|
|
|
|
// draw cursor tag
|
|
const cursorTag = props.sessions[props.hostId]?.profile.name || ''
|
|
if (cursorTag) {
|
|
x += cursorImage.value.width
|
|
y += cursorImage.value.height
|
|
|
|
ctx.font = '14px Arial, sans-serif'
|
|
ctx.textBaseline = 'top'
|
|
ctx.shadowColor = 'black'
|
|
ctx.shadowBlur = 2
|
|
ctx.lineWidth = 2
|
|
ctx.fillStyle = 'black'
|
|
ctx.strokeText(cursorTag, x, y)
|
|
ctx.shadowBlur = 0
|
|
ctx.fillStyle = 'white'
|
|
ctx.fillText(cursorTag, x, y)
|
|
}
|
|
}
|
|
|
|
function canvasClear() {
|
|
if (!ctx) return
|
|
|
|
// reset transformation, X and Y will be 0 again
|
|
ctx.setTransform(canvasScale, 0, 0, canvasScale, 0, 0)
|
|
|
|
const { width, height } = props.canvasSize
|
|
ctx.clearRect(0, 0, width, height)
|
|
}
|
|
|
|
//
|
|
// implicit hosting
|
|
//
|
|
|
|
let reqMouseDown: MouseEvent | null = null
|
|
let reqMouseUp: MouseEvent | null = null
|
|
|
|
function onControlChange(isControling: boolean) {
|
|
keyboardModifiers = null
|
|
|
|
if (isControling && reqMouseDown) {
|
|
updateKeyboardModifiers(reqMouseDown)
|
|
onMouseDown(reqMouseDown)
|
|
}
|
|
|
|
if (isControling && reqMouseUp) {
|
|
onMouseUp(reqMouseUp)
|
|
}
|
|
|
|
canvasRequestRedraw()
|
|
|
|
reqMouseDown = null
|
|
reqMouseUp = null
|
|
}
|
|
|
|
watch(() => props.isControling, onControlChange)
|
|
|
|
function implicitControlRequest(e: MouseEvent) {
|
|
if (props.implicitControl && e.type === 'mousedown') {
|
|
reqMouseDown = e
|
|
reqMouseUp = null
|
|
props.control.request()
|
|
}
|
|
|
|
if (props.implicitControl && e.type === 'mouseup') {
|
|
reqMouseUp = e
|
|
}
|
|
}
|
|
|
|
// unused
|
|
function implicitControlRelease() {
|
|
if (props.implicitControl) {
|
|
props.control.release()
|
|
}
|
|
}
|
|
|
|
//
|
|
// mobile keyboard
|
|
//
|
|
|
|
let kbdShow = false
|
|
let kbdOpen = false
|
|
|
|
function mobileKeyboardShow() {
|
|
// skip if not a touch device
|
|
if (!props.hasMobileKeyboard) return
|
|
|
|
kbdShow = true
|
|
kbdOpen = false
|
|
|
|
textarea.value!.focus()
|
|
window.visualViewport?.addEventListener('resize', onVisualViewportResize)
|
|
emit('mobileKeyboardOpen', true)
|
|
}
|
|
|
|
function mobileKeyboardHide() {
|
|
// skip if not a touch device
|
|
if (!props.hasMobileKeyboard) return
|
|
|
|
kbdShow = false
|
|
kbdOpen = false
|
|
|
|
emit('mobileKeyboardOpen', false)
|
|
window.visualViewport?.removeEventListener('resize', onVisualViewportResize)
|
|
textarea.value!.blur()
|
|
}
|
|
|
|
// visual viewport resize event is fired when keyboard is opened or closed
|
|
// android does not blur textarea when keyboard is closed, so we need to do it manually
|
|
function onVisualViewportResize() {
|
|
if (!kbdShow) return
|
|
|
|
if (!kbdOpen) {
|
|
kbdOpen = true
|
|
} else {
|
|
mobileKeyboardHide()
|
|
}
|
|
}
|
|
|
|
</script>
|