Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
getWindowFullscreenState,
openExternal,
openSystemSettings,
pasteAsText,
probeRemoteEditors,
pickFolder,
pickProjectFavicon,
Expand Down Expand Up @@ -122,6 +123,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(showContextMenu);
yield* ipc.handle(openExternal);
yield* ipc.handle(openSystemSettings);
yield* ipc.handle(pasteAsText);
yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
Expand Down
24 changes: 24 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import {
getLocalEnvironmentBootstraps,
getWindowFullscreenState,
pasteAsText,
pickProjectFavicon,
} from "./window.ts";

Expand Down Expand Up @@ -153,6 +154,29 @@ describe("getWindowFullscreenState", () => {
});
});

describe("pasteAsText", () => {
it.effect("pastes only after the main renderer acknowledges the menu action", () => {
const paste = vi.fn();
const window = {
webContents: { id: 42, paste },
} as unknown as Electron.BrowserWindow;

return Effect.gen(function* () {
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);

yield* pasteAsText.handler(undefined, { sender: { id: 99 } });
assert.equal(paste.mock.calls.length, 1);
}).pipe(
Effect.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
main: Effect.succeed(Option.some(window)),
}),
),
);
});
});

describe("pickProjectFavicon", () => {
it.effect("opens a single-image picker from the project directory", () =>
Effect.gen(function* () {
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,24 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
}),
});

export const pasteAsText = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PASTE_AS_TEXT_CHANNEL,
payload: Schema.Undefined,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.main;
if (
event === undefined ||
Option.isNone(window) ||
window.value.webContents.id !== event.sender.id
) {
return;
}
window.value.webContents.paste();
}),
});

/** Theme files are a few KB; anything larger returns empty text and lets the
* renderer reject it by size without the contents ever crossing the bridge. */
const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
openSystemSettings: (pane: string) =>
ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => {
}),
);

it.effect("owns Paste as Text and routes it through the renderer", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
assert.isDefined(editMenu);
if (!Array.isArray(editMenu.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
assert.isDefined(pasteAsTextItem);
assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V");
if (typeof pasteAsTextItem.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}

pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent,
);
assert.equal(yield* Deferred.await(selectedAction), "paste-as-text");
}),
);

// Zoom must route through DesktopWindow.zoomMain instead of the Electron
// zoom roles: the roles zoom whichever webContents has focus, which breaks
// app zoom while an embedded preview WebContentsView holds focus.
Expand Down
32 changes: 31 additions & 1 deletion apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ export const make = Effect.gen(function* () {
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
const pasteAsTextClick = () => {
runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
};
Expand Down Expand Up @@ -184,7 +187,34 @@ export const make = Effect.gen(function* () {
{ role: environment.platform === "darwin" ? "close" : "quit" },
],
},
{ role: "editMenu" },
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{
label: "Paste as Text",
accelerator: "CmdOrCtrl+Shift+V",
click: pasteAsTextClick,
},
{ role: "delete" },
{ type: "separator" },
{ role: "selectAll" },
...(environment.platform === "darwin"
? [
{ type: "separator" as const },
{
label: "Speech",
submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }],
},
]
: []),
],
},
{
label: "View",
submenu: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,17 @@ class T3ComposerEditorModule : Module() {
Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean ->
view.setSpellCheck(spellCheck)
}
Prop("interceptTextPastes") { view: T3ComposerEditorView, intercept: Boolean ->
view.setInterceptTextPastes(intercept)
}

Events(
"onComposerChange",
"onComposerSelectionChange",
"onComposerFocus",
"onComposerBlur",
"onComposerPasteImages",
"onComposerPasteText",
"onComposerContentSizeChange",
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package expo.modules.t3composereditor

import android.content.Context
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Color
import android.graphics.Canvas
import android.graphics.Paint
Expand All @@ -15,6 +16,7 @@ import android.text.TextWatcher
import android.text.style.ReplacementSpan
import android.util.TypedValue
import android.view.Gravity
import android.view.KeyEvent
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.EditText
Expand All @@ -39,6 +41,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
private val onComposerFocus by EventDispatcher()
private val onComposerBlur by EventDispatcher()
private val onComposerPasteImages by EventDispatcher()
private val onComposerPasteText by EventDispatcher()
private val onComposerContentSizeChange by EventDispatcher()
private var applyingNativeValue = false
private var desiredLineHeightPx = 0
Expand Down Expand Up @@ -72,6 +75,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
editor.pasteImagesListener = { uris ->
onComposerPasteImages(mapOf("uris" to uris))
}
editor.pasteTextListener = { text, start, end ->
onComposerPasteText(
mapOf(
"text" to text,
"selection" to mapOf("start" to start, "end" to end),
),
)
}
editor.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
onComposerFocus(emptyMap<String, Any>())
Expand Down Expand Up @@ -244,6 +255,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
updateInputFlags()
}

fun setInterceptTextPastes(intercept: Boolean) {
editor.interceptTextPastes = intercept
Comment thread
chrisdeeming marked this conversation as resolved.
}

fun focusEditor() {
editor.requestFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
Expand Down Expand Up @@ -496,31 +511,63 @@ private fun parseTokens(value: String): List<ComposerToken> = try {
private class SelectionAwareEditText(context: Context) : EditText(context) {
var selectionListener: ((Int, Int) -> Unit)? = null
var pasteImagesListener: ((List<String>) -> Unit)? = null
var pasteTextListener: ((String, Int, Int) -> Unit)? = null
var interceptTextPastes = false

override fun onSelectionChanged(selStart: Int, selEnd: Int) {
super.onSelectionChanged(selStart, selEnd)
selectionListener?.invoke(selStart, selEnd)
}

override fun onTextContextMenuItem(id: Int): Boolean {
if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clip = clipboard?.primaryClip
val imageUris = buildList {
if (clip != null) {
for (index in 0 until clip.itemCount) {
clip.getItemAt(index).uri?.let { uri ->
val mimeType = context.contentResolver.getType(uri)
if (mimeType?.startsWith("image/") == true) add(uri.toString())
}
if (id != android.R.id.paste && id != android.R.id.pasteAsPlainText) {
return super.onTextContextMenuItem(id)
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
val clip = clipboard?.primaryClip
val imageUris = buildList {
if (clip != null) {
for (index in 0 until clip.itemCount) {
clip.getItemAt(index).uri?.let { uri ->
val mimeType = context.contentResolver.getType(uri)
if (mimeType?.startsWith("image/") == true) add(uri.toString())
}
}
}
if (imageUris.isNotEmpty()) {
}
val text = if (interceptTextPastes) clip?.plainText() else null
return when {
imageUris.isNotEmpty() -> {
pasteImagesListener?.invoke(imageUris)
return true
true
}
!text.isNullOrEmpty() -> {
pasteTextListener?.invoke(
text,
selectionStart.coerceAtLeast(0),
selectionEnd.coerceAtLeast(0),
)
true
}
else -> super.onTextContextMenuItem(id)
}
}

// coerceToText opens content: URIs synchronously. Leave URI-backed
// clipboard items to Android's normal paste path so the UI thread never
// reads an arbitrary provider just to measure a text paste.
private fun ClipData.plainText(): String? =
takeIf { itemCount > 0 }
?.getItemAt(0)
?.takeIf { it.uri == null }
?.coerceToText(context)
?.toString()
?.takeIf(String::isNotEmpty)

override fun onKeyShortcut(keyCode: Int, event: KeyEvent): Boolean {
if (keyCode == KeyEvent.KEYCODE_V && event.isCtrlPressed && event.isShiftPressed) {
return super.onTextContextMenuItem(android.R.id.pasteAsPlainText)
}
return super.onTextContextMenuItem(id)
return super.onKeyShortcut(keyCode, event)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ public class T3ComposerEditorModule: Module {
Prop("spellCheck") { (view: T3ComposerEditorView, spellCheck: Bool) in
view.setSpellCheck(spellCheck)
}
Prop("interceptTextPastes") { (view: T3ComposerEditorView, intercept: Bool) in
view.setInterceptTextPastes(intercept)
}

Events(
"onComposerChange",
Expand All @@ -52,6 +55,7 @@ public class T3ComposerEditorModule: Module {
"onComposerBlur",
"onComposerSubmit",
"onComposerPasteImages",
"onComposerPasteText",
"onComposerContentSizeChange"
)

Expand Down
Loading
Loading