From 19487b079a189ec72be38522bcaf9d897fa6fd67 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 17:14:32 -0400 Subject: [PATCH] Attached images open full size and copy back out (#6198) Scribe #6198 "An attached image can't be viewed large or copied back out". Web and desktop: clicking an image in the editor opens it full size, with Copy image (re-encoded to PNG, the one type every clipboard accepts), Download and Close. Copy hides where the browser has no async clipboard, such as a server reached over plain http; right-clicking the full-size image still copies there. Android: long-pressing an image in the editor copies it to the clipboard; a tap still opens it full size in the phone's viewer. Opening and copying share one hand-out that copies the file out of the blob store behind a FileProvider URI. Co-Authored-By: Claude Opus 5.5 --- .../fabledsword/inkwell/ui/AttachmentFiles.kt | 67 +++++++++++----- .../fabledsword/inkwell/ui/AttachmentViews.kt | 61 +++++++++++++-- android/app/src/main/res/values/strings.xml | 4 + frontend/src/components/ImageViewer.vue | 76 +++++++++++++++++++ frontend/src/components/NoteEditor.vue | 16 +++- frontend/src/notes/attachments.ts | 35 +++++++++ 6 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/ImageViewer.vue diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt index ce9cfb5..f17857b 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentFiles.kt @@ -1,5 +1,7 @@ package com.fabledsword.inkwell.ui +import android.content.ClipData +import android.content.ClipDescription import android.content.Context import android.content.Intent import android.database.Cursor @@ -10,6 +12,7 @@ import android.media.ExifInterface import android.net.Uri import android.provider.OpenableColumns import android.util.LruCache +import androidx.annotation.StringRes import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap @@ -40,15 +43,18 @@ sealed interface PickedFile { ) : PickedFile } -/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */ -sealed interface Opener { - data class Ready( - val intent: Intent, - ) : Opener +/** + * What [AttachmentFiles] made to hand a file to another app — an intent to start, a + * clip for the clipboard — or why there isn't one. + */ +sealed interface Handout { + data class Ready( + val value: T, + ) : Handout data class Failed( val message: String, - ) : Opener + ) : Handout } /** @@ -117,29 +123,56 @@ class AttachmentFiles( * An intent that hands the file to whatever app opens its type, or the reason * there can't be one. * + * Blocking; call it off the main thread, then start the intent from a screen. + */ + fun opener(attachment: Attachment): Handout = + handOut(attachment, R.string.attach_open_failed) { uri -> + Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, attachment.mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + /** + * A clip that puts the file itself on the clipboard, so it pastes into another + * app as the picture rather than as a path. + * + * The clip carries the attachment's own type: built from the URI alone, its type + * would be guessed from the file name's extension, and a pasted image saved + * without one would paste as an unknown file. The clipboard grants whichever app + * pastes it read access to the one URI. + * + * Blocking; call it off the main thread. + */ + fun clip(attachment: Attachment): Handout = + handOut(attachment, R.string.attach_copy_failed) { uri -> + val label = attachment.filename.orEmpty() + ClipData(ClipDescription(label, arrayOf(attachment.mime)), ClipData.Item(uri)) + } + + /** + * The file as a URI another app may read, made into whatever [make] builds from + * it. + * * Copied out of the blob store first, under its real name: the store names files * by hash with no extension, and a viewer shown `3f2a…` cannot tell a PDF from a * spreadsheet. The copy lives in the cache, which the system reclaims, and only * that directory is shared (`res/xml/file_paths.xml`). - * - * Blocking; call it off the main thread, then start the intent from a screen. */ - fun opener(attachment: Attachment): Opener { + private fun handOut( + attachment: Attachment, + @StringRes failed: Int, + make: (Uri) -> T, + ): Handout { val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File) - if (source == null) return Opener.Failed(app.getString(R.string.attach_not_here)) + if (source == null) return Handout.Failed(app.getString(R.string.attach_not_here)) val dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}") val copy = File(dir, safeName(attachment.filename)) return try { dir.mkdirs() if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true) - val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy) - val view = - Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, attachment.mime) - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - Opener.Ready(view) + Handout.Ready(make(FileProvider.getUriForFile(app, "${app.packageName}.files", copy))) } catch (e: IOException) { - Opener.Failed(app.getString(R.string.attach_open_failed, e.message ?: attachment.filename.orEmpty())) + Handout.Failed(app.getString(failed, e.message ?: attachment.filename.orEmpty())) } } diff --git a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt index 3dfef29..5f4a9a0 100644 --- a/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt +++ b/android/app/src/main/java/com/fabledsword/inkwell/ui/AttachmentViews.kt @@ -1,12 +1,16 @@ package com.fabledsword.inkwell.ui import android.content.ActivityNotFoundException +import android.content.ClipboardManager import android.content.Context +import android.content.Intent +import android.os.Build import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -33,8 +37,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow @@ -115,6 +121,7 @@ fun EditorAttachments( onAction: (EditorAction) -> Unit, ) { val open = rememberOpener() + val copy = rememberCopier() Column(modifier = Modifier.padding(top = 12.dp)) { attachments.forEach { attachment -> val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) } @@ -124,6 +131,7 @@ fun EditorAttachments( attachment = attachment, maxPx = EDITOR_IMAGE_PX, onClick = { open(attachment) }, + onLongClick = { copy(attachment) }, ) { FileRow(attachment = attachment, onClick = { open(attachment) }) } @@ -205,6 +213,9 @@ private sealed interface ImageLoad { /** * An attachment drawn as a picture, decoded off the main thread. * + * In the editor a tap opens it full size in the phone's viewer and a long press + * copies it, the same gesture that copies an image in a browser. + * * [fallback] is what draws when it can't be: a file this device hasn't downloaded * yet, or bytes that don't decode. It is a file row, so the note still shows that * something is attached instead of a gap. @@ -214,6 +225,7 @@ private fun AttachmentImage( attachment: Attachment, maxPx: Int, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, fallback: @Composable () -> Unit, ) { val files = LocalAttachmentFiles.current @@ -234,7 +246,15 @@ private fun AttachmentImage( ) is ImageLoad.Ready -> { val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT) - val tap = onClick?.let { Modifier.clickable(onClick = it) } ?: Modifier + val tap = + onClick?.let { click -> + Modifier.combinedClickable( + onClickLabel = stringResource(R.string.attach_view), + onLongClickLabel = onLongClick?.let { stringResource(R.string.attach_copy) }, + onLongClick = onLongClick, + onClick = click, + ) + } ?: Modifier Image( bitmap = state.bitmap, contentDescription = attachment.filename, @@ -311,8 +331,39 @@ private fun rememberOpener(): (Attachment) -> Unit { if (files != null) { scope.launch { when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) { - is Opener.Ready -> start(context, opener) - is Opener.Failed -> toast(context, opener.message) + is Handout.Ready -> start(context, opener.value) + is Handout.Failed -> toast(context, opener.message) + } + } + } + } +} + +/** + * Put an image on the clipboard, or say why not. + * + * Android 13 and later show their own confirmation when anything is copied, so the + * toast is only for the versions that don't — two notices for one copy reads as two + * copies. + */ +@Composable +private fun rememberCopier(): (Attachment) -> Unit { + val files = LocalAttachmentFiles.current + val context = LocalContext.current + val haptics = LocalHapticFeedback.current + val scope = rememberCoroutineScope() + return { attachment -> + if (files != null) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + scope.launch { + when (val clip = withContext(Dispatchers.IO) { files.clip(attachment) }) { + is Handout.Ready -> { + context.getSystemService(ClipboardManager::class.java).setPrimaryClip(clip.value) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + toast(context, context.getString(R.string.attach_copied)) + } + } + is Handout.Failed -> toast(context, clip.message) } } } @@ -321,10 +372,10 @@ private fun rememberOpener(): (Attachment) -> Unit { private fun start( context: Context, - opener: Opener.Ready, + intent: Intent, ) { try { - context.startActivity(opener.intent) + context.startActivity(intent) } catch (expected: ActivityNotFoundException) { toast(context, context.getString(R.string.attach_no_app)) } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 543a6ea..d0169c8 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -116,6 +116,10 @@ This file hasn\'t downloaded to this phone yet. Sync, then try again. No app on this phone opens this kind of file. Couldn\'t open the file: %1$s + Open full size + Copy image + Image copied + Couldn\'t copy the image: %1$s Remove link preview
- +
+ diff --git a/frontend/src/notes/attachments.ts b/frontend/src/notes/attachments.ts index ccb0942..f30e6bb 100644 --- a/frontend/src/notes/attachments.ts +++ b/frontend/src/notes/attachments.ts @@ -9,3 +9,38 @@ export function rendersInline(mime: string): boolean { return mime.startsWith("image/") && mime !== "image/svg+xml"; } + +/** Whether this browser can put a picture on the clipboard at all. + * + * The async clipboard only exists in a secure context, so a server reached over plain + * http has none — the viewer then leaves copying to the browser's own context menu + * on the full-size image. */ +export function canCopyImages(): boolean { + return typeof ClipboardItem !== "undefined" && typeof navigator.clipboard?.write === "function"; +} + +/** Put the image at `url` on the clipboard. + * + * As PNG whatever it was stored as: PNG is the one image type every browser's + * clipboard accepts, so a JPEG or WebP is redrawn through a canvas first. The item is + * given a promise rather than a blob so the write starts inside the click — Safari + * refuses a clipboard write that begins after an await. */ +export async function copyImage(url: string): Promise { + await navigator.clipboard.write([new ClipboardItem({ "image/png": asPng(url) })]); +} + +async function asPng(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const blob = await res.blob(); + if (blob.type === "image/png") return blob; + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d")?.drawImage(bitmap, 0, 0); + bitmap.close(); + return new Promise((resolve, reject) => + canvas.toBlob((png) => (png ? resolve(png) : reject(new Error("Could not encode the image."))), "image/png"), + ); +}