2 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 854cb4b403 Android: a hardware keyboard's arrow keys move the cursor (#6199)
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 12s
CI & Build / Python tests (push) Successful in 14s
Android / Core and FFI clippy and tests (push) Successful in 51s
CI & Build / integration (push) Successful in 1m50s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m59s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m38s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m22s
Android / Build the server image (push) Successful in 1s
Scribe #6199 "Android: a hardware keyboard's arrow keys move focus instead
of the cursor".

Compose's text field sends the arrow keys of any device reporting D-pad
keys to focus search before the field sees them, and a physical keyboard
reports exactly that, so left and right jumped to the neighbouring control.
Cancelling focus search in those directions hands the key back to the field.
Up and down move between a field's lines, and from its first or last line
on to the field above or below; single-line checklist rows still step row
to row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 17:14:32 -04:00
bvandeusenandClaude Opus 5.5 19487b079a 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>
2026-10-21 17:14:32 -04:00
8 changed files with 279 additions and 26 deletions
@@ -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<out T> {
data class Ready<T>(
val value: T,
) : Handout<T>
data class Failed(
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
* 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
* 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 <T> handOut(
attachment: Attachment,
@StringRes failed: Int,
make: (Uri) -> T,
): Handout<T> {
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()))
}
}
@@ -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))
}
@@ -20,7 +20,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
@@ -28,6 +31,7 @@ import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
@@ -241,6 +245,15 @@ private fun BlockField(
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
val style = textStyle.copy(color = MaterialTheme.colorScheme.onSurface)
// Kept for the arrow keys, which ask which line the caret is on. Read only when
// one is pressed, never while composing.
var layout by remember { mutableStateOf<TextLayoutResult?>(null) }
val line = { offset: Int -> layout?.getLineForOffset(offset) ?: 0 }
val arrows =
Modifier.arrowKeysMoveCaret(
atTop = { line(value.selection.end) == 0 },
atBottom = { line(value.selection.end) == (layout?.lineCount ?: 1) - 1 },
)
Box(modifier = modifier) {
if (hint != null && value.text.isEmpty()) {
Text(
@@ -252,12 +265,13 @@ private fun BlockField(
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().then(arrows),
enabled = enabled,
singleLine = singleLine,
textStyle = style,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
onTextLayout = { layout = it },
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
)
}
@@ -13,6 +13,8 @@ import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
@@ -54,7 +56,7 @@ fun PlainTextField(
TextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth().arrowKeysMoveCaret(atTop = { singleLine }, atBottom = { singleLine }),
enabled = enabled,
placeholder = hint?.let { { Text(stringResource(it)) } },
singleLine = singleLine,
@@ -86,3 +88,29 @@ private fun plainFieldColors(): TextFieldColors =
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
)
/**
* Arrow keys on a hardware keyboard move the caret, not the focus.
*
* Compose's text field gives the arrow keys of any device that reports D-pad keys
* to focus search before the field sees them, and a physical keyboard with arrow
* keys reports exactly that — so without this, ← and → jump to whatever sits beside
* the field instead of moving through the text. Cancelling the search in a
* direction hands the key back: the search reports that nothing moved, and the
* field takes the key as editing.
*
* ← and → never leave the field. ↑ and ↓ move between its lines, and from the first
* or last line — [atTop], [atBottom] — on to the field above or below, the way a
* caret leaves one paragraph for the next. Both are asked at the moment of the key
* press, so they can read where the caret is now.
*/
fun Modifier.arrowKeysMoveCaret(
atTop: () -> Boolean,
atBottom: () -> Boolean,
): Modifier =
focusProperties {
left = FocusRequester.Cancel
right = FocusRequester.Cancel
if (!atTop()) up = FocusRequester.Cancel
if (!atBottom()) down = FocusRequester.Cancel
}
@@ -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_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_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>
<!-- 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 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"),
);
}