[#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_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>

View File

@@ -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)}">&times;</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();

View File

@@ -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() {

View File

@@ -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()}" }