[#1575] Support copy & paste (#1593)

* [#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:
Constantin Piber
2025-08-19 22:01:31 +03:00
committed by GitHub
parent 7a0d3a1efe
commit 3075888d26
4 changed files with 249 additions and 16 deletions

View File

@@ -130,6 +130,7 @@
<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_close">Close</string>
<string name="webview_label_title">Suwayomi WebView</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_getstarted">Enter a URL to get started</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="login_label_title">Suwayomi Login</string>

View File

@@ -131,6 +131,87 @@
main .status:empty {
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/ */
.arrow-right {
@@ -160,10 +241,30 @@
<div class="status" id="status"></div>
<canvas id="frame"></canvas>
<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>
<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)}">&times;</button>
</div>
<p>${MR.strings.webview_label_copy_description.localized(locale)}</p>
<input type="text" id="copyinput" disabled readonly/>
</div>
</div>
<script>
const messageDiv = document.getElementById('message');
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 frameInput = document.getElementById('inputtrap');
const ctx = frame.getContext("2d");
@@ -190,6 +291,34 @@
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
const setHash = (u) => {
@@ -229,6 +358,20 @@
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
window.addEventListener('hashchange', e => {
@@ -293,6 +436,9 @@
const lg = obj.severity == 4 ? console.error : obj.severity == 3 ? console.warn : console.log;
lg(obj.source + ':' + obj.line + ':', obj.message);
} break;
case "copy":
copy(obj.content);
break;
default:
console.warn("Unknown event", obj.type)
break;
@@ -322,9 +468,26 @@
observer.observe(frame);
const frameEvent = (e) => {
// Chrome Android bug, see below
// Chrome Android bug, see input below
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();
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 clickX = e.clientX !== undefined ? e.clientX - rect.left : 0;
const clickY = e.clientY !== undefined ? e.clientY - rect.top : 0;
@@ -348,7 +511,7 @@
const attachEvents = () => {
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) {
frameInput.addEventListener(ev, frameEvent, false);
}
@@ -367,7 +530,6 @@
e.preventDefault();
let deltaX = touch.pageX - e.touches[0].pageX;
let deltaY = touch.pageY - e.touches[0].pageY;
console.log(deltaX, deltaY)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
// assume horizontal scroll
socket.send(JSON.stringify({
@@ -400,23 +562,23 @@
frameInput.addEventListener('input', e => {
e.preventDefault();
socket.send(JSON.stringify({
type: 'event',
eventType: 'keydown',
clickX: 0,
clickY: 0,
key: e.data,
}));
socket.send(JSON.stringify({
type: 'event',
eventType: 'keyup',
clickX: 0,
clickY: 0,
key: e.data,
type: 'paste',
data: e.data,
}));
e.target.value = '';
});
frameInput.addEventListener('contextmenu', e => {
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);
};
attachEvents();

View File

@@ -27,7 +27,10 @@ import org.cef.network.CefCookieManager
import org.cef.network.CefRequest
import uy.kohesive.injekt.injectLazy
import java.awt.Component
import java.awt.HeadlessException
import java.awt.Rectangle
import java.awt.Toolkit
import java.awt.datatransfer.DataFlavor
import java.awt.event.InputEvent
import java.awt.event.KeyEvent
import java.awt.event.MouseEvent
@@ -113,6 +116,12 @@ class KcefWebView {
val error: String? = null,
) : Event()
@Serializable
@SerialName("copy")
private data class CopyEvent(
val content: String,
) : Event()
private inner class DisplayHandler : CefDisplayHandlerAdapter() {
override fun onConsoleMessage(
browser: CefBrowser,
@@ -346,6 +355,16 @@ class KcefWebView {
modifier: Int,
): KeyEvent? {
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 =
when (char.uppercaseChar()) {
in 'A'..'Z', in '0'..'9' -> char.uppercaseChar().code
@@ -379,7 +398,7 @@ class KcefWebView {
' ' -> KeyEvent.VK_SPACE
'_' -> KeyEvent.VK_UNDERSCORE
else ->
when (msg.key) {
when (strKey) {
"Alt" -> KeyEvent.VK_ALT
"Backspace" -> KeyEvent.VK_BACK_SPACE
"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 goBack() {

View File

@@ -79,6 +79,16 @@ object WebView : Websocket<String>() {
val deltaY: Float? = null,
) : TypeObject()
@Serializable
@SerialName("paste")
data class JsPasteMessage(
val data: String,
) : TypeObject()
@Serializable
@SerialName("copy")
class JsCopyMessage : TypeObject()
override fun handleRequest(ctx: WsMessageContext) {
val dr = driver ?: return
try {
@@ -97,6 +107,12 @@ object WebView : Websocket<String>() {
is JsEventMessage -> {
dr.event(event)
}
is JsPasteMessage -> {
dr.paste(event.data)
}
is JsCopyMessage -> {
dr.copy()
}
}
} catch (e: Exception) {
logger.warn(e) { "Failed to deserialize client request: ${ctx.message()}" }