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
+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 LabelPicker from "./LabelPicker.vue";
import LinkPreview from "./LinkPreview.vue";
import ImageViewer from "./ImageViewer.vue";
import { formatShortDateTime, fromLocalInput, toLocalInput } from "../notes/datetime";
import { RECURRENCES } from "../notes/recurrence";
import { takeMorphOrigin } from "../composables/useEditorMorph";
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 { insertTag, suggestTags, tagQueryAt, type TagQuery } from "../notes/tags";
import { useLabelsStore } from "../stores/labels";
@@ -570,6 +571,8 @@ function addChecklist(): void {
}
// ---- 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));
function pickFile() {
fileInput.value?.click();
@@ -679,7 +682,15 @@ function revPreview(rev: NoteRevision): string {
<template v-for="att in liveNote.attachments" :key="att.id">
<!-- Image → inline thumbnail -->
<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
v-if="own"
type="button"
@@ -983,6 +994,7 @@ function revPreview(rev: NoteRevision): string {
</button>
</div>
</div>
<ImageViewer v-if="viewing" :attachment="viewing" @close="viewing = null" />
</div>
</Transition>
</template>
+35
View File
@@ -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<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"),
);
}