Add mobile keyboard API (#21)

* fix page for mobile - minor changes.

* fix textarea overlay to hide caret and avodi zooming on mobiles.

* fix typo.

* show keyboard btn if is touch device.

* lint fix.

* add to API.

* mobile keybaord fix andorid blur.

* add mobile keybaord toggle.

* fix overlay.

* mobile keybaord, skip if not a touch device.
This commit is contained in:
Miroslav Šedivý 2023-01-27 19:21:42 +01:00 committed by GitHub
parent 5758350a78
commit dc2ef37e17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 116 additions and 13 deletions

View File

@ -26,9 +26,9 @@ echo \"@demodesk:registry\" \"https://npm.pkg.github.com\" >> .yarnrc
You can set keyboard provider at build time, either `novnc` or the default `guacamole`. You can set keyboard provider at build time, either `novnc` or the default `guacamole`.
```bash ```bash
# by default uses guacamole keybaord # by default uses guacamole keyboard
npm run build npm run build
# uses novnc keybaord # uses novnc keyboard
KEYBOARD=novnc npm run build KEYBOARD=novnc npm run build
``` ```

View File

@ -20,6 +20,7 @@
:cursorDraw="inactiveCursorDrawFunction" :cursorDraw="inactiveCursorDrawFunction"
/> />
<neko-overlay <neko-overlay
ref="overlay"
v-show="!private_mode_enabled && state.connection.status != 'disconnected'" v-show="!private_mode_enabled && state.connection.status != 'disconnected'"
:style="{ pointerEvents: state.control.locked ? 'none' : 'auto' }" :style="{ pointerEvents: state.control.locked ? 'none' : 'auto' }"
:wsControl="control" :wsControl="control"
@ -35,6 +36,7 @@
:inactiveCursors="state.settings.inactive_cursors && session.profile.sends_inactive_cursor" :inactiveCursors="state.settings.inactive_cursors && session.profile.sends_inactive_cursor"
@updateKeyboardModifiers="updateKeyboardModifiers($event)" @updateKeyboardModifiers="updateKeyboardModifiers($event)"
@uploadDrop="uploadDrop($event)" @uploadDrop="uploadDrop($event)"
@mobileKeyboardOpen="state.mobile_keyboard_open = $event"
/> />
</div> </div>
</div> </div>
@ -102,6 +104,7 @@
@Ref('component') readonly _component!: HTMLElement @Ref('component') readonly _component!: HTMLElement
@Ref('container') readonly _container!: HTMLElement @Ref('container') readonly _container!: HTMLElement
@Ref('video') readonly _video!: HTMLVideoElement @Ref('video') readonly _video!: HTMLVideoElement
@Ref('overlay') readonly _overlay!: Overlay
// fallback image for webrtc reconnections: // fallback image for webrtc reconnections:
// chrome shows black screen when closing webrtc connection, that's why // chrome shows black screen when closing webrtc connection, that's why
@ -196,6 +199,7 @@
merciful_reconnect: false, merciful_reconnect: false,
}, },
cursors: [], cursors: [],
mobile_keyboard_open: false,
} as NekoState } as NekoState
///////////////////////////// /////////////////////////////
@ -405,6 +409,22 @@
Vue.set(this.state.control, 'keyboard', { layout, variant }) Vue.set(this.state.control, 'keyboard', { layout, variant })
} }
public mobileKeyboardShow() {
this._overlay.mobileKeyboardShow()
}
public mobileKeyboardHide() {
this._overlay.mobileKeyboardHide()
}
public mobileKeyboardToggle() {
if (this.state.mobile_keyboard_open) {
this.mobileKeyboardHide()
} else {
this.mobileKeyboardShow()
}
}
public setCursorDrawFunction(fn?: CursorDrawFunction) { public setCursorDrawFunction(fn?: CursorDrawFunction) {
Vue.set(this, 'cursorDrawFunction', fn) Vue.set(this, 'cursorDrawFunction', fn)
} }

View File

@ -33,8 +33,9 @@
bottom: 0; bottom: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
font-size: 1px; /* chrome would not paste text if 0px */ font-size: 16px; /* at least 16px to avoid zooming on mobile */
resize: none; /* hide textarea resize corner */ resize: none; /* hide textarea resize corner */
caret-color: transparent; /* hide caret */
outline: 0; outline: 0;
border: 0; border: 0;
color: transparent; color: transparent;
@ -116,6 +117,10 @@
return 'url(' + uri + ') ' + x + ' ' + y + ', default' return 'url(' + uri + ') ' + x + ' ' + y + ', default'
} }
get isTouchDevice(): boolean {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
mounted() { mounted() {
// register mouseup globally as user can release mouse button outside of overlay // register mouseup globally as user can release mouse button outside of overlay
window.addEventListener('mouseup', this.onMouseUp, true) window.addEventListener('mouseup', this.onMouseUp, true)
@ -373,7 +378,11 @@
} }
onMouseEnter(e: MouseEvent) { onMouseEnter(e: MouseEvent) {
// focus opens the keyboard on mobile (only for android)
if (!this.isTouchDevice) {
this._textarea.focus() this._textarea.focus()
}
this.focused = true this.focused = true
if (this.isControling) { if (this.isControling) {
@ -629,5 +638,48 @@
this.wsControl.release() this.wsControl.release()
} }
} }
//
// mobile keyboard
//
public kbdShow = false
public kbdOpen = false
public mobileKeyboardShow() {
// skip if not a touch device
if (!this.isTouchDevice) return
this.kbdShow = true
this.kbdOpen = false
this._textarea.focus()
window.visualViewport.addEventListener('resize', this.onVisualViewportResize)
this.$emit('mobileKeyboardOpen', true)
}
public mobileKeyboardHide() {
// skip if not a touch device
if (!this.isTouchDevice) return
this.kbdShow = false
this.kbdOpen = false
this.$emit('mobileKeyboardOpen', false)
window.visualViewport.removeEventListener('resize', this.onVisualViewportResize)
this._textarea.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
onVisualViewportResize() {
if (!this.kbdShow) return
if (!this.kbdOpen) {
this.kbdOpen = true
} else {
this.mobileKeyboardHide()
}
}
} }
</script> </script>

View File

@ -11,6 +11,7 @@ export default interface State {
sessions: Record<string, Session> sessions: Record<string, Session>
settings: Settings settings: Settings
cursors: Cursors cursors: Cursors
mobile_keyboard_open: boolean
} }
///////////////////////////// /////////////////////////////

View File

@ -400,6 +400,18 @@
<td>{{ neko.state.cursors }}</td> <td>{{ neko.state.cursors }}</td>
</tr> </tr>
<tr>
<th>mobile_keyboard_open</th>
<td>
<div class="space-between">
<span>{{ neko.state.mobile_keyboard_open }}</span>
<button @click="neko.mobileKeyboardToggle">
<i class="fas fa-toggle-on"></i>
</button>
</div>
</td>
</tr>
<tr> <tr>
<th>control actions</th> <th>control actions</th>
<td> <td>

View File

@ -43,6 +43,13 @@
</div> </div>
</div> </div>
<div class="room-container" style="text-align: center"> <div class="room-container" style="text-align: center">
<button
v-if="loaded && isTouchDevice"
@click="neko.mobileKeyboardToggle"
style="position: absolute; left: 5px; transform: translateY(-100%)"
>
<i class="fa fa-keyboard" />
</button>
<span v-if="loaded && neko.state.session_id" style="padding-top: 10px"> <span v-if="loaded && neko.state.session_id" style="padding-top: 10px">
You are logged in as You are logged in as
<strong style="font-weight: bold"> <strong style="font-weight: bold">
@ -193,6 +200,9 @@
flex-shrink: 0; flex-shrink: 0;
flex-direction: column; flex-direction: column;
display: flex; display: flex;
/* for mobile */
overflow-y: hidden;
overflow-x: auto;
.room-menu { .room-menu {
max-width: 100%; max-width: 100%;
@ -279,10 +289,14 @@
} }
} }
/* for mobile */
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
$offset: 38px;
#neko.expanded { #neko.expanded {
/* show only enough of the menu to see the toggle button */
.neko-main { .neko-main {
transform: translateX(calc(-100% + 65px)); transform: translateX(calc(-100% + $offset));
video { video {
display: none; display: none;
} }
@ -292,15 +306,15 @@
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 65px; left: $offset;
width: calc(100% - 65px); width: calc(100% - $offset);
} }
/* display menu toggle button far right */
.header .menu,
.header .menu li {
margin-right: 2px;
} }
} }
@media only screen and (max-width: 768px) {
#neko .neko-main .room-container {
display: none;
}
} }
</style> </style>
@ -362,7 +376,7 @@
}) })
export default class extends Vue { export default class extends Vue {
@Ref('neko') readonly neko!: NekoCanvas @Ref('neko') readonly neko!: NekoCanvas
expanded: boolean = true expanded: boolean = !window.matchMedia('(max-width: 600px)').matches // default to expanded on bigger screens
loaded: boolean = false loaded: boolean = false
tab: string = '' tab: string = ''
@ -371,6 +385,10 @@
uploadActive = false uploadActive = false
uploadProgress = 0 uploadProgress = 0
get isTouchDevice(): boolean {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0
}
dialogOverlayActive = false dialogOverlayActive = false
dialogRequestActive = false dialogRequestActive = false
async dialogUploadFiles(files: File[]) { async dialogUploadFiles(files: File[]) {