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 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 17:14:32 -04:00
co-authored by Claude Opus 5.5
parent 142fafe0ed
commit 19487b079a
6 changed files with 235 additions and 24 deletions
@@ -1,5 +1,7 @@
package com.fabledsword.inkwell.ui package com.fabledsword.inkwell.ui
import android.content.ClipData
import android.content.ClipDescription
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.database.Cursor import android.database.Cursor
@@ -10,6 +12,7 @@ import android.media.ExifInterface
import android.net.Uri import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.LruCache import android.util.LruCache
import androidx.annotation.StringRes
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
@@ -40,15 +43,18 @@ sealed interface PickedFile {
) : PickedFile ) : PickedFile
} }
/** What [AttachmentFiles.opener] made: an intent to start, or why there isn't one. */ /**
sealed interface Opener { * What [AttachmentFiles] made to hand a file to another app — an intent to start, a
data class Ready( * clip for the clipboard — or why there isn't one.
val intent: Intent, */
) : Opener sealed interface Handout<out T> {
data class Ready<T>(
val value: T,
) : Handout<T>
data class Failed( data class Failed(
val message: String, val message: String,
) : Opener ) : Handout<Nothing>
} }
/** /**
@@ -117,29 +123,56 @@ class AttachmentFiles(
* An intent that hands the file to whatever app opens its type, or the reason * An intent that hands the file to whatever app opens its type, or the reason
* there can't be one. * there can't be one.
* *
* Blocking; call it off the main thread, then start the intent from a screen.
*/
fun opener(attachment: Attachment): Handout<Intent> =
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<ClipData> =
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 * 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 * 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 * spreadsheet. The copy lives in the cache, which the system reclaims, and only
* that directory is shared (`res/xml/file_paths.xml`). * 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 <T> handOut(
attachment: Attachment,
@StringRes failed: Int,
make: (Uri) -> T,
): Handout<T> {
val source = attachment.sha256?.let { core.blobPath(it) }?.let(::File) 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 dir = File(app.cacheDir, "$OPEN_DIR/${attachment.id}")
val copy = File(dir, safeName(attachment.filename)) val copy = File(dir, safeName(attachment.filename))
return try { return try {
dir.mkdirs() dir.mkdirs()
if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true) if (!copy.isFile || copy.length() != source.length()) source.copyTo(copy, overwrite = true)
val uri = FileProvider.getUriForFile(app, "${app.packageName}.files", copy) Handout.Ready(make(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)
} catch (e: IOException) { } 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()))
} }
} }
@@ -1,12 +1,16 @@
package com.fabledsword.inkwell.ui package com.fabledsword.inkwell.ui
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Build
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -33,8 +37,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -115,6 +121,7 @@ fun EditorAttachments(
onAction: (EditorAction) -> Unit, onAction: (EditorAction) -> Unit,
) { ) {
val open = rememberOpener() val open = rememberOpener()
val copy = rememberCopier()
Column(modifier = Modifier.padding(top = 12.dp)) { Column(modifier = Modifier.padding(top = 12.dp)) {
attachments.forEach { attachment -> attachments.forEach { attachment ->
val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) } val remove = { onAction(EditorAction.RemoveAttachment(attachment.id)) }
@@ -124,6 +131,7 @@ fun EditorAttachments(
attachment = attachment, attachment = attachment,
maxPx = EDITOR_IMAGE_PX, maxPx = EDITOR_IMAGE_PX,
onClick = { open(attachment) }, onClick = { open(attachment) },
onLongClick = { copy(attachment) },
) { ) {
FileRow(attachment = attachment, onClick = { open(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. * 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 * [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 * 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. * something is attached instead of a gap.
@@ -214,6 +225,7 @@ private fun AttachmentImage(
attachment: Attachment, attachment: Attachment,
maxPx: Int, maxPx: Int,
onClick: (() -> Unit)? = null, onClick: (() -> Unit)? = null,
onLongClick: (() -> Unit)? = null,
fallback: @Composable () -> Unit, fallback: @Composable () -> Unit,
) { ) {
val files = LocalAttachmentFiles.current val files = LocalAttachmentFiles.current
@@ -234,7 +246,15 @@ private fun AttachmentImage(
) )
is ImageLoad.Ready -> { is ImageLoad.Ready -> {
val ratio = (state.bitmap.width.toFloat() / state.bitmap.height).coerceAtLeast(MIN_ASPECT) 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( Image(
bitmap = state.bitmap, bitmap = state.bitmap,
contentDescription = attachment.filename, contentDescription = attachment.filename,
@@ -311,8 +331,39 @@ private fun rememberOpener(): (Attachment) -> Unit {
if (files != null) { if (files != null) {
scope.launch { scope.launch {
when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) { when (val opener = withContext(Dispatchers.IO) { files.opener(attachment) }) {
is Opener.Ready -> start(context, opener) is Handout.Ready -> start(context, opener.value)
is Opener.Failed -> toast(context, opener.message) 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( private fun start(
context: Context, context: Context,
opener: Opener.Ready, intent: Intent,
) { ) {
try { try {
context.startActivity(opener.intent) context.startActivity(intent)
} catch (expected: ActivityNotFoundException) { } catch (expected: ActivityNotFoundException) {
toast(context, context.getString(R.string.attach_no_app)) toast(context, context.getString(R.string.attach_no_app))
} }
@@ -116,6 +116,10 @@
<string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string> <string name="attach_not_here">This file hasn\'t downloaded to this phone yet. Sync, then try again.</string>
<string name="attach_no_app">No app on this phone opens this kind of file.</string> <string name="attach_no_app">No app on this phone opens this kind of file.</string>
<string name="attach_open_failed">Couldn\'t open the file: %1$s</string> <string name="attach_open_failed">Couldn\'t open the file: %1$s</string>
<string name="attach_view">Open full size</string>
<string name="attach_copy">Copy image</string>
<string name="attach_copied">Image copied</string>
<string name="attach_copy_failed">Couldn\'t copy the image: %1$s</string>
<string name="preview_remove">Remove link preview</string> <string name="preview_remove">Remove link preview</string>
<!-- Deleting for good is the only thing in the app that cannot be undone, so <!-- Deleting for good is the only thing in the app that cannot be undone, so
+76
View File
@@ -0,0 +1,76 @@
<script setup lang="ts">
// An attached image at full size, with the two things a thumbnail can't offer: copying
// the picture back out and saving it.
//
// Teleported to <body> because the editor panel it opens from is transformed while it
// animates, and a fixed overlay inside a transformed box is positioned against that
// box rather than the window. Escape is stopped here so it closes only this viewer —
// not the editor underneath, and not the shell's "Esc goes back to the board".
import { useUiStore } from "../stores/ui";
import type { Attachment } from "../stores/notes";
import { canCopyImages, copyImage } from "../notes/attachments";
import BaseModal from "./BaseModal.vue";
import Icon from "./Icon.vue";
const props = defineProps<{ attachment: Attachment }>();
const emit = defineEmits<{ (e: "close"): void }>();
const ui = useUiStore();
const copyable = canCopyImages();
async function copy(): Promise<void> {
try {
await copyImage(props.attachment.url);
ui.showToast("Image copied.");
} catch {
ui.showToast("Couldn't copy the image — right-click it to copy instead.");
}
}
</script>
<template>
<Teleport to="body">
<div @keydown.esc.stop>
<BaseModal
align="center"
panel-class="flex max-h-[95vh] w-auto max-w-[95vw] flex-col overflow-hidden shadow-xl"
:aria-label="attachment.filename || 'Image'"
@close="emit('close')"
>
<img
:src="attachment.url"
:alt="attachment.filename || ''"
class="min-h-0 max-w-full flex-1 object-contain"
style="max-height: calc(95vh - 3.25rem)"
/>
<div class="flex items-center gap-1 border-t border-neutral-100 px-3 py-2 dark:border-neutral-800">
<span class="min-w-0 flex-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
{{ attachment.filename || "Image" }}
</span>
<button
v-if="copyable"
type="button"
class="icon-btn"
title="Copy image"
aria-label="Copy image"
@click="copy"
>
<Icon name="copy" />
</button>
<a
:href="attachment.url"
:download="attachment.filename || ''"
class="icon-btn"
title="Download"
aria-label="Download"
>
<Icon name="download" />
</a>
<button type="button" class="icon-btn" title="Close" aria-label="Close" @click="emit('close')">
<Icon name="close" />
</button>
</div>
</BaseModal>
</div>
</Teleport>
</template>
+14 -2
View File
@@ -8,11 +8,12 @@ import NoteActions from "./NoteActions.vue";
import ReminderActions from "./ReminderActions.vue"; import ReminderActions from "./ReminderActions.vue";
import LabelPicker from "./LabelPicker.vue"; import LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue"; import LinkPreview from "./LinkPreview.vue";
import ImageViewer from "./ImageViewer.vue";
import { formatShortDateTime, fromLocalInput, toLocalInput } from "../notes/datetime"; import { formatShortDateTime, fromLocalInput, toLocalInput } from "../notes/datetime";
import { RECURRENCES } from "../notes/recurrence"; import { RECURRENCES } from "../notes/recurrence";
import { takeMorphOrigin } from "../composables/useEditorMorph"; import { takeMorphOrigin } from "../composables/useEditorMorph";
import { prefersReducedMotion } from "../composables/useReducedMotion"; import { prefersReducedMotion } from "../composables/useReducedMotion";
import type { Note, NoteLabel, NoteRevision } from "../stores/notes"; import type { Attachment, Note, NoteLabel, NoteRevision } from "../stores/notes";
import { labelChipClasses, labelDotClasses } from "../notes/colors"; import { labelChipClasses, labelDotClasses } from "../notes/colors";
import { insertTag, suggestTags, tagQueryAt, type TagQuery } from "../notes/tags"; import { insertTag, suggestTags, tagQueryAt, type TagQuery } from "../notes/tags";
import { useLabelsStore } from "../stores/labels"; import { useLabelsStore } from "../stores/labels";
@@ -570,6 +571,8 @@ function addChecklist(): void {
} }
// ---- attachments ---- // ---- attachments ----
/** The image open full-size in the viewer, if any. */
const viewing = ref<Attachment | null>(null);
const refusedUploads = computed(() => liveNote.value.attachments.filter((a) => a.upload_error)); const refusedUploads = computed(() => liveNote.value.attachments.filter((a) => a.upload_error));
function pickFile() { function pickFile() {
fileInput.value?.click(); fileInput.value?.click();
@@ -679,7 +682,15 @@ function revPreview(rev: NoteRevision): string {
<template v-for="att in liveNote.attachments" :key="att.id"> <template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image → inline thumbnail --> <!-- Image → inline thumbnail -->
<div v-if="attKind(att.mime) === 'image'" class="group/att relative"> <div v-if="attKind(att.mime) === 'image'" class="group/att relative">
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" /> <button
type="button"
class="block cursor-zoom-in rounded-lg"
:title="att.filename || 'View image'"
aria-label="View image"
@click="viewing = att"
>
<img :src="att.url" alt="" loading="lazy" decoding="async" class="h-24 w-24 rounded-lg object-cover" />
</button>
<button <button
v-if="own" v-if="own"
type="button" type="button"
@@ -983,6 +994,7 @@ function revPreview(rev: NoteRevision): string {
</button> </button>
</div> </div>
</div> </div>
<ImageViewer v-if="viewing" :attachment="viewing" @close="viewing = null" />
</div> </div>
</Transition> </Transition>
</template> </template>
+35
View File
@@ -9,3 +9,38 @@
export function rendersInline(mime: string): boolean { export function rendersInline(mime: string): boolean {
return mime.startsWith("image/") && mime !== "image/svg+xml"; 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<void> {
await navigator.clipboard.write([new ClipboardItem({ "image/png": asPng(url) })]);
}
async function asPng(url: string): Promise<Blob> {
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"),
);
}