mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-22 20:42:37 +01:00
* [#1575] Support paste * WebView: Implement copy * Localize copy dialog, lint * Implement a custom context menu for copy/paste * Remove click event which causes double events * WebView: Fix input events broken by moved preventDefault We want to fall back to the `input` event for Android bug and paste, but we want to prevent the event for the others, so change the order
This commit is contained in:
@@ -130,6 +130,7 @@
|
|||||||
|
|
||||||
<string name="label_error">Error</string>
|
<string name="label_error">Error</string>
|
||||||
<string name="label_version">Version <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
|
<string name="label_version">Version <xliff:g id="version" example="v2.0.1833">%1$s</xliff:g></string>
|
||||||
|
<string name="label_close">Close</string>
|
||||||
|
|
||||||
<string name="webview_label_title">Suwayomi WebView</string>
|
<string name="webview_label_title">Suwayomi WebView</string>
|
||||||
<string name="webview_label_disconnected">Disconnected, please refresh</string>
|
<string name="webview_label_disconnected">Disconnected, please refresh</string>
|
||||||
@@ -138,6 +139,8 @@
|
|||||||
<string name="webview_label_init">Initializing... Please wait</string>
|
<string name="webview_label_init">Initializing... Please wait</string>
|
||||||
<string name="webview_label_getstarted">Enter a URL to get started</string>
|
<string name="webview_label_getstarted">Enter a URL to get started</string>
|
||||||
<string name="webview_label_loading">Loading page...</string>
|
<string name="webview_label_loading">Loading page...</string>
|
||||||
|
<string name="webview_label_copy">Copy to Clipboard</string>
|
||||||
|
<string name="webview_label_copy_description">Automatic clipboard copy failed, please use the input below to manually copy the value.</string>
|
||||||
<string name="webview_placeholder_url">Enter URL...</string>
|
<string name="webview_placeholder_url">Enter URL...</string>
|
||||||
|
|
||||||
<string name="login_label_title">Suwayomi Login</string>
|
<string name="login_label_title">Suwayomi Login</string>
|
||||||
|
|||||||
@@ -131,6 +131,87 @@
|
|||||||
main .status:empty {
|
main .status:empty {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
main .contextmenu {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
max-width: fit-content;
|
||||||
|
min-height: calc(1.5em + 2px + 2px + 4px);
|
||||||
|
min-width: 120px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
}
|
||||||
|
main .contextmenu.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
main .contextmenu button {
|
||||||
|
all: unset;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex-grow: 1;
|
||||||
|
background: white;
|
||||||
|
transition: background 0.1s ease-in-out;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 0.5px solid #666;
|
||||||
|
padding: 2px 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
main .contextmenu button:hover {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
.copydialog {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.copydialog.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.copydialog::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: black;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.copydialog__inner {
|
||||||
|
position: relative;
|
||||||
|
max-width: 960px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
background: #eee;
|
||||||
|
padding: 8px;
|
||||||
|
margin: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.copydialog__title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.copydialog input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.copydialog .close {
|
||||||
|
flex-shrink: 1;
|
||||||
|
all: unset;
|
||||||
|
cursor: pointer;
|
||||||
|
align-self: start;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.copydialog {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.copydialog__inner {
|
||||||
|
padding: 12px 18px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* https://css-tricks.com/snippets/css/css-triangle/ */
|
/* https://css-tricks.com/snippets/css/css-triangle/ */
|
||||||
.arrow-right {
|
.arrow-right {
|
||||||
@@ -160,10 +241,30 @@
|
|||||||
<div class="status" id="status"></div>
|
<div class="status" id="status"></div>
|
||||||
<canvas id="frame"></canvas>
|
<canvas id="frame"></canvas>
|
||||||
<input type="text" id="inputtrap" autocomplete="off"/>
|
<input type="text" id="inputtrap" autocomplete="off"/>
|
||||||
|
<div class="contextmenu" id="contextmenu" role="menu">
|
||||||
|
<button type="button" id="menu_copy" role="menuitem">Copy</button>
|
||||||
|
<button type="button" id="menu_paste" role="menuitem">Paste</button>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<div class="copydialog" id="copydialog" role="dialog">
|
||||||
|
<div class="copydialog__inner">
|
||||||
|
<div class="copydialog__title">
|
||||||
|
<h2>${MR.strings.webview_label_copy.localized(locale)}</h2>
|
||||||
|
<button type="button" class="close" id="copyclose" title="${MR.strings.label_close.localized(locale)}">×</button>
|
||||||
|
</div>
|
||||||
|
<p>${MR.strings.webview_label_copy_description.localized(locale)}</p>
|
||||||
|
<input type="text" id="copyinput" disabled readonly/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const messageDiv = document.getElementById('message');
|
const messageDiv = document.getElementById('message');
|
||||||
const statusDiv = document.getElementById('status');
|
const statusDiv = document.getElementById('status');
|
||||||
|
const copyDiv = document.getElementById('copydialog');
|
||||||
|
const copyInput = document.getElementById('copyinput');
|
||||||
|
const copyClose = document.getElementById('copyclose');
|
||||||
|
const contextMenuDiv = document.getElementById('contextmenu');
|
||||||
|
const contextMenuCopy = document.getElementById('menu_copy');
|
||||||
|
const contextMenuPaste = document.getElementById('menu_paste');
|
||||||
const frame = document.getElementById('frame');
|
const frame = document.getElementById('frame');
|
||||||
const frameInput = document.getElementById('inputtrap');
|
const frameInput = document.getElementById('inputtrap');
|
||||||
const ctx = frame.getContext("2d");
|
const ctx = frame.getContext("2d");
|
||||||
@@ -190,6 +291,34 @@
|
|||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
copyClose.addEventListener('click', () => {
|
||||||
|
copyDiv.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
contextMenuCopy.addEventListener('click', () => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'copy',
|
||||||
|
}));
|
||||||
|
contextMenuDiv.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
contextMenuPaste.addEventListener('click', () => {
|
||||||
|
navigator.clipboard.readText().then(data => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'paste',
|
||||||
|
data: data,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
contextMenuDiv.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!navigator.clipboard || !navigator.clipboard.readText) {
|
||||||
|
// if not served via HTTPS, remove the button, users can still paste via clipboard
|
||||||
|
// e.g. Ctrl+V or the dedicated paste button on gboard
|
||||||
|
// TODO: dialog like with copy?
|
||||||
|
contextMenuPaste.remove();
|
||||||
|
}
|
||||||
|
|
||||||
/// Helpers
|
/// Helpers
|
||||||
|
|
||||||
const setHash = (u) => {
|
const setHash = (u) => {
|
||||||
@@ -229,6 +358,20 @@
|
|||||||
ctx.clearRect(0, 0, frame.width, frame.height);
|
ctx.clearRect(0, 0, frame.width, frame.height);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copy = (data) => {
|
||||||
|
try {
|
||||||
|
if (!!navigator.clipboard && !!navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.warn('Clipbaord API not supported (not served over HTTPS?), presenting dialog');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Clipboard access threw, presenting dialog', e);
|
||||||
|
}
|
||||||
|
copyInput.value = data;
|
||||||
|
copyDiv.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
/// Form
|
/// Form
|
||||||
|
|
||||||
window.addEventListener('hashchange', e => {
|
window.addEventListener('hashchange', e => {
|
||||||
@@ -293,6 +436,9 @@
|
|||||||
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
|
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
|
||||||
lg(obj.source + ':' + obj.line + ':', obj.message);
|
lg(obj.source + ':' + obj.line + ':', obj.message);
|
||||||
} break;
|
} break;
|
||||||
|
case "copy":
|
||||||
|
copy(obj.content);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
console.warn("Unknown event", obj.type)
|
console.warn("Unknown event", obj.type)
|
||||||
break;
|
break;
|
||||||
@@ -322,9 +468,26 @@
|
|||||||
observer.observe(frame);
|
observer.observe(frame);
|
||||||
|
|
||||||
const frameEvent = (e) => {
|
const frameEvent = (e) => {
|
||||||
// Chrome Android bug, see below
|
// Chrome Android bug, see input below
|
||||||
if (e.key === "Unidentified") return;
|
if (e.key === "Unidentified") return;
|
||||||
|
// paste is handled in input below
|
||||||
|
if (e.key === "v" && e.ctrlKey === true) return;
|
||||||
|
if (e.key === "c" && e.ctrlKey === true) {
|
||||||
|
if (e.type === "keydown") {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'copy',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (e.type === "mousedown" && contextMenuDiv.classList.contains('show')) {
|
||||||
|
console.log('remove context menu');
|
||||||
|
contextMenuDiv.classList.remove('show');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// right-click, handled in contextmenu below
|
||||||
|
if (e.type === "mousedown" && e.button === 2) return;
|
||||||
const rect = frame.getBoundingClientRect();
|
const rect = frame.getBoundingClientRect();
|
||||||
const clickX = e.clientX !== undefined ? e.clientX - rect.left : 0;
|
const clickX = e.clientX !== undefined ? e.clientX - rect.left : 0;
|
||||||
const clickY = e.clientY !== undefined ? e.clientY - rect.top : 0;
|
const clickY = e.clientY !== undefined ? e.clientY - rect.top : 0;
|
||||||
@@ -348,7 +511,7 @@
|
|||||||
|
|
||||||
const attachEvents = () => {
|
const attachEvents = () => {
|
||||||
console.log('Attaching event handlers to new document');
|
console.log('Attaching event handlers to new document');
|
||||||
const events = ["click", "mousedown", "mouseup", "mousemove", "wheel", "keydown", "keyup"];
|
const events = ["mousedown", "mouseup", "mousemove", "wheel", "keydown", "keyup"];
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
frameInput.addEventListener(ev, frameEvent, false);
|
frameInput.addEventListener(ev, frameEvent, false);
|
||||||
}
|
}
|
||||||
@@ -367,7 +530,6 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let deltaX = touch.pageX - e.touches[0].pageX;
|
let deltaX = touch.pageX - e.touches[0].pageX;
|
||||||
let deltaY = touch.pageY - e.touches[0].pageY;
|
let deltaY = touch.pageY - e.touches[0].pageY;
|
||||||
console.log(deltaX, deltaY)
|
|
||||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||||
// assume horizontal scroll
|
// assume horizontal scroll
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
@@ -400,23 +562,23 @@
|
|||||||
frameInput.addEventListener('input', e => {
|
frameInput.addEventListener('input', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'event',
|
type: 'paste',
|
||||||
eventType: 'keydown',
|
data: e.data,
|
||||||
clickX: 0,
|
|
||||||
clickY: 0,
|
|
||||||
key: e.data,
|
|
||||||
}));
|
|
||||||
socket.send(JSON.stringify({
|
|
||||||
type: 'event',
|
|
||||||
eventType: 'keyup',
|
|
||||||
clickX: 0,
|
|
||||||
clickY: 0,
|
|
||||||
key: e.data,
|
|
||||||
}));
|
}));
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
});
|
});
|
||||||
frameInput.addEventListener('contextmenu', e => {
|
frameInput.addEventListener('contextmenu', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
contextMenuDiv.style.left = e.offsetX + 'px';
|
||||||
|
contextMenuDiv.style.top = e.offsetY + 'px';
|
||||||
|
contextMenuDiv.classList.add('show');
|
||||||
|
|
||||||
|
const shiftLeft = contextMenuDiv.offsetParent.offsetWidth - contextMenuDiv.offsetWidth - contextMenuDiv.offsetLeft;
|
||||||
|
if (shiftLeft < 0)
|
||||||
|
contextMenuDiv.style.left = (e.offsetX + shiftLeft) + 'px';
|
||||||
|
const shiftTop = contextMenuDiv.offsetParent.offsetHeight - contextMenuDiv.offsetHeight - contextMenuDiv.offsetTop;
|
||||||
|
if (shiftTop < 0)
|
||||||
|
contextMenuDiv.style.top = (e.offsetY + shiftTop) + 'px';
|
||||||
}, false);
|
}, false);
|
||||||
};
|
};
|
||||||
attachEvents();
|
attachEvents();
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import org.cef.network.CefCookieManager
|
|||||||
import org.cef.network.CefRequest
|
import org.cef.network.CefRequest
|
||||||
import uy.kohesive.injekt.injectLazy
|
import uy.kohesive.injekt.injectLazy
|
||||||
import java.awt.Component
|
import java.awt.Component
|
||||||
|
import java.awt.HeadlessException
|
||||||
import java.awt.Rectangle
|
import java.awt.Rectangle
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.datatransfer.DataFlavor
|
||||||
import java.awt.event.InputEvent
|
import java.awt.event.InputEvent
|
||||||
import java.awt.event.KeyEvent
|
import java.awt.event.KeyEvent
|
||||||
import java.awt.event.MouseEvent
|
import java.awt.event.MouseEvent
|
||||||
@@ -113,6 +116,12 @@ class KcefWebView {
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
) : Event()
|
) : Event()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("copy")
|
||||||
|
private data class CopyEvent(
|
||||||
|
val content: String,
|
||||||
|
) : Event()
|
||||||
|
|
||||||
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
|
||||||
override fun onConsoleMessage(
|
override fun onConsoleMessage(
|
||||||
browser: CefBrowser,
|
browser: CefBrowser,
|
||||||
@@ -346,6 +355,16 @@ class KcefWebView {
|
|||||||
modifier: Int,
|
modifier: Int,
|
||||||
): KeyEvent? {
|
): KeyEvent? {
|
||||||
val char = if (msg.key?.length == 1) msg.key[0] else KeyEvent.CHAR_UNDEFINED
|
val char = if (msg.key?.length == 1) msg.key[0] else KeyEvent.CHAR_UNDEFINED
|
||||||
|
return keyEvent(char, component, id, modifier, msg.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyEvent(
|
||||||
|
char: Char,
|
||||||
|
component: Component,
|
||||||
|
id: Int,
|
||||||
|
modifier: Int,
|
||||||
|
strKey: String? = null,
|
||||||
|
): KeyEvent? {
|
||||||
val code =
|
val code =
|
||||||
when (char.uppercaseChar()) {
|
when (char.uppercaseChar()) {
|
||||||
in 'A'..'Z', in '0'..'9' -> char.uppercaseChar().code
|
in 'A'..'Z', in '0'..'9' -> char.uppercaseChar().code
|
||||||
@@ -379,7 +398,7 @@ class KcefWebView {
|
|||||||
' ' -> KeyEvent.VK_SPACE
|
' ' -> KeyEvent.VK_SPACE
|
||||||
'_' -> KeyEvent.VK_UNDERSCORE
|
'_' -> KeyEvent.VK_UNDERSCORE
|
||||||
else ->
|
else ->
|
||||||
when (msg.key) {
|
when (strKey) {
|
||||||
"Alt" -> KeyEvent.VK_ALT
|
"Alt" -> KeyEvent.VK_ALT
|
||||||
"Backspace" -> KeyEvent.VK_BACK_SPACE
|
"Backspace" -> KeyEvent.VK_BACK_SPACE
|
||||||
"Delete" -> KeyEvent.VK_DELETE
|
"Delete" -> KeyEvent.VK_DELETE
|
||||||
@@ -560,6 +579,39 @@ class KcefWebView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun paste(msg: String) {
|
||||||
|
val component = browser?.uiComponent ?: return
|
||||||
|
for (c in msg) {
|
||||||
|
browser!!.sendKeyEvent(keyEvent(c, component, KeyEvent.KEY_PRESSED, 0)!!)
|
||||||
|
keyEvent(c, component, KeyEvent.KEY_TYPED, 0)?.let { browser!!.sendKeyEvent(it) }
|
||||||
|
browser!!.sendKeyEvent(keyEvent(c, component, KeyEvent.KEY_RELEASED, 0)!!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun copy() {
|
||||||
|
val frame = browser?.focusedFrame ?: return
|
||||||
|
frame.copy()
|
||||||
|
val clip =
|
||||||
|
try {
|
||||||
|
Toolkit.getDefaultToolkit().getSystemClipboard()
|
||||||
|
} catch (e: HeadlessException) {
|
||||||
|
logger.warn(e) { "Failed to get clipboard" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val text =
|
||||||
|
try {
|
||||||
|
clip.getData(DataFlavor.stringFlavor) as String
|
||||||
|
} catch (e: Exception) {
|
||||||
|
logger.warn(e) { "Failed to get clipboard contents" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WebView.notifyAllClients(
|
||||||
|
Json.encodeToString<Event>(
|
||||||
|
CopyEvent(text),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun canGoBack(): Boolean = browser!!.canGoBack()
|
fun canGoBack(): Boolean = browser!!.canGoBack()
|
||||||
|
|
||||||
fun goBack() {
|
fun goBack() {
|
||||||
|
|||||||
@@ -79,6 +79,16 @@ object WebView : Websocket<String>() {
|
|||||||
val deltaY: Float? = null,
|
val deltaY: Float? = null,
|
||||||
) : TypeObject()
|
) : TypeObject()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("paste")
|
||||||
|
data class JsPasteMessage(
|
||||||
|
val data: String,
|
||||||
|
) : TypeObject()
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
@SerialName("copy")
|
||||||
|
class JsCopyMessage : TypeObject()
|
||||||
|
|
||||||
override fun handleRequest(ctx: WsMessageContext) {
|
override fun handleRequest(ctx: WsMessageContext) {
|
||||||
val dr = driver ?: return
|
val dr = driver ?: return
|
||||||
try {
|
try {
|
||||||
@@ -97,6 +107,12 @@ object WebView : Websocket<String>() {
|
|||||||
is JsEventMessage -> {
|
is JsEventMessage -> {
|
||||||
dr.event(event)
|
dr.event(event)
|
||||||
}
|
}
|
||||||
|
is JsPasteMessage -> {
|
||||||
|
dr.paste(event.data)
|
||||||
|
}
|
||||||
|
is JsCopyMessage -> {
|
||||||
|
dr.copy()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
logger.warn(e) { "Failed to deserialize client request: ${ctx.message()}" }
|
logger.warn(e) { "Failed to deserialize client request: ${ctx.message()}" }
|
||||||
|
|||||||
Reference in New Issue
Block a user