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