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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user