fixed text fields

This commit is contained in:
WiseGuru 2026-05-28 12:28:01 -07:00
parent dddfb0cf12
commit 3efc6e25e9
9 changed files with 198 additions and 173 deletions

View file

@ -241,7 +241,7 @@ Per [.editorconfig](.editorconfig): tabs (width 4), LF, UTF-8, final newline. Ma
- **Destination override does not mutate the template object.** [src/ui/modal.ts](src/ui/modal.ts) renders a per-invocation Destination control (insertMode + conditional newFile fields) and threads the result through `PipelineParams.destinationOverride`. [src/pipeline.ts](src/pipeline.ts) shallow-merges the override onto a *copy* of the template via `applyDestinationOverride` before calling `insertOutput`; the cached template and the file on disk remain untouched. The override is ephemeral: it resets when the modal closes and when the template selector changes. Not exposed in Quick Record, `runTextPipeline`, or `runAudioFilePipeline` (no UI surface). The UI is a collapsible `<details>` whose `<summary>` reads `"Destination: Default (<description>)"` (no override) or `"Destination: Custom (<description>)"` (override set, forced open); expand state is tracked on `ReWriteModal.destinationExpanded` so it survives the full-container re-renders that fire when the inner insertMode dropdown changes. `describeDestination(mode, folder, name)` formats the description (e.g. `New file: ReWrite Notes/{{date}}-note`).
- **Quick Record floater holds its own popover.** The floater grew a third button between the timer and Stop that opens a popover-style template list ([src/ui/quick-record.ts](src/ui/quick-record.ts)). The popover is a child of the floater div, listens for outside-click via a capture-phase `document` listener and Escape via `document keydown`, and cleans up both listeners on dismiss. The popover dismisses on selection, Escape, outside click, and when `setBusy` runs (the pipeline is in flight). Selecting a template updates `controller.template` but does NOT update `lastUsedTemplateId`; that only happens after a successful completion, matching pre-popover behavior.
- **Whisper status bar polls `whisperHost.status()` every 1 s** via `registerInterval`. The host has no event emitter; both [src/settings/tab.ts](src/settings/tab.ts) (re-render on Start/Stop click) and [src/ui/whisper-status-bar.ts](src/ui/whisper-status-bar.ts) (interval poll) re-read the status synchronously. If you add a third consumer, poll the same way; do not bolt on an event system.
- **Mobile keyboard scroll uses `visualViewport`, not `scrollIntoView` alone.** Android WebViews don't resize the layout viewport when the soft keyboard opens, so a bare `scrollIntoView({ block: 'center' })` centers the input on the full screen, which is then covered by the keyboard. `installMobileKeyboardScrollFix` in [src/platform.ts](src/platform.ts) waits for `window.visualViewport.resize` (keyboard finished opening), then runs a two-step recovery: first scroll the nearest scrollable ancestor by the delta needed to lift the input (this is what works on the settings tab); if the input is still below the visible region, shrink the enclosing `.modal-container` to the visible region by setting inline `top` and `height`. Obsidian's flex centering inside `.modal-container` then re-positions the `.modal` popup above the keyboard. Scrolling alone cannot move a `position: fixed` popup. The container shrink restores on `blur` and re-fits on subsequent `visualViewport.resize` events (keyboard height changes, emoji panel, etc.); it fully restores when `vv.height` returns to near `window.innerHeight`. Falls back to the legacy `scrollIntoView` path when `visualViewport` is unavailable. Attach it on every container that hosts user-facing text inputs ([src/ui/modal.ts](src/ui/modal.ts), [src/settings/tab.ts](src/settings/tab.ts), [src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts), `RenamePromptModal` in [src/insert.ts](src/insert.ts)); `focusin` bubbles, so a single listener on the container covers descendants and the inline setup card.
- **Mobile keyboard avoidance is CSS-only: pin our popups to the top.** Obsidian mobile (Capacitor) overlays the soft keyboard on top of the WebView without resizing the layout or visual viewport, so there is no reliable JS signal (`visualViewport` does not shrink, the `resize` event does not fire) to react to. The earlier JS helper (`installMobileKeyboardScrollFix`, which read `visualViewport` and shrank `.modal-container`) was a confirmed no-op on the failing cases and has been removed. The fix lives entirely in [styles.css](styles.css): under `.is-mobile`, our modal classes (`.rewrite-modal`, which covers the main + passphrase modals, and `.rewrite-rename-modal`) get `align-self: flex-start; margin-top: 8px; margin-bottom: auto; max-height: calc(100% - 16px)`, pinning the popup to the top of the flex container that centers modals. The keyboard opens from the bottom, so a top-anchored popup and its near-top input fields stay visible above it. Scoped to our classes so core Obsidian modals are untouched. The settings tab (a tall scrollable surface) was never affected and needs no rule: Chromium's native keyboard-aware focus-scroll handles it. If you add a new popup with a text field, give it one of these classes (or add its class to the selector) rather than reaching for a JS keyboard helper. Top-anchoring alone is not enough when something pushes the focused element low within a tall popup, so three companion tweaks keep the relevant element high: (a) `.is-mobile .rewrite-modal .modal-content` gets `padding-top: 8px` and `.is-mobile .rewrite-modal h2` gets `margin-top: 0` to reclaim the empty band Obsidian leaves above the title; (b) the Paste textarea renders at `rows = 4` on mobile (vs 10 on desktop, set in [src/ui/modal.ts](src/ui/modal.ts)) with the desktop 160px `min-height` floor dropped to 80px under `.is-mobile`, so its submit button stays above the keyboard; (c) the change-passphrase tips block is a `<details>` ([src/ui/passphrase-modal.ts](src/ui/passphrase-modal.ts) `renderPassphraseTips`) expanded by default on every platform (opt-out security guidance), but on mobile it auto-collapses (`collapseTipsOnMobile`) when a passphrase field receives focus, so it is seen on open yet stops pushing the fields into the keyboard once the user starts typing. The first field's `autofocus` is disabled on mobile (`autofocus = !Platform.isMobile`) so the auto-collapse fires on the user's tap rather than a premature programmatic focus.
## Local install for testing

View file

@ -1,9 +1,17 @@
# Mobile keyboard covers plugin text boxes (Android) — investigation log
Status: **UNRESOLVED.** Three substantively different code approaches have produced no
observable change on the user's Android device. This document records everything tried so
the problem can be tackled methodically (instrument first, then fix) rather than by more
blind guessing.
Status: **CSS top-anchor confirmed working; residual tall-popup cases fixed in Attempt 4b, awaiting
device confirmation.** After three JS approaches (all keyed off `visualViewport`) produced no
observable change, the JS helper was removed entirely and replaced with a CSS-only top-anchor (see
Attempt 4 below). The reporter confirmed the top-anchor fixed the simple popups; two tall-popup cases
(change-passphrase fields, Paste submit button) were then addressed in Attempt 4b. Corroborating evidence drove the
pivot: (a) the reporter confirmed another plugin with the same popup-style text boxes hits the
*identical* bug, establishing this as Obsidian-mobile platform behavior rather than a defect in our
code; (b) the official Obsidian docs expose no plugin-facing keyboard/viewport/inset API and confirm
Obsidian mobile is Capacitor-based (`Platform.isMobileApp` = "running the capacitor-js mobile app"),
which is consistent with Hypothesis A (keyboard overlays without resizing the viewport, so no JS
signal fires). The CSS approach is signal-independent: it does not depend on `visualViewport` or any
Capacitor event. This document records everything tried so the history is not lost.
## Problem statement
@ -107,7 +115,57 @@ The helper early-returns unless `Platform.isMobile`.
the existing flex centering do the work.
- **Result:** Reporter: "No change for any of the tests."
## Current code (verbatim summary)
### Attempt 4 — CSS-only top-anchor; JS helper removed (current)
- **Change:** Deleted `installMobileKeyboardScrollFix` and its four call sites from
[src/platform.ts](../src/platform.ts), [src/ui/modal.ts](../src/ui/modal.ts),
[src/settings/tab.ts](../src/settings/tab.ts), [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts),
and [src/insert.ts](../src/insert.ts). Added a CSS rule in [styles.css](../styles.css): under
`.is-mobile`, `.rewrite-modal` (main + passphrase) and `.rewrite-rename-modal` get
`align-self: flex-start; margin-top: 8px; margin-bottom: auto; max-height: calc(100% - 16px)`.
- **Theory:** This is alternative #3b made concrete. The one surface that always worked (the
settings tab) is a tall scrollable container where Chromium's native keyboard-aware focus-scroll
does the job; the failing surfaces are short fixed-position popups centered by Obsidian's flex.
Rather than fight the keyboard with JS that never had a signal to fire on, pin the popup to the
top of the screen so the keyboard (which opens from the bottom) cannot cover it. The reporter
confirmed other plugins use exactly this workaround.
- **Why CSS over JS:** Hypothesis A (the keyboard overlays without resizing the viewport) means no
JS signal is reliable. CSS positioning needs no signal. `align-self: flex-start` moves only our
modal to the top of the flex container; `margin-bottom: auto` is a fallback for non-default flex
configurations; scoping to our classes leaves core Obsidian modals untouched.
- **Caveat for the main modal:** the Paste textarea sits below the template selector and tab row,
so top-anchoring lifts it into the upper half of the screen but not to the very top. For the
passphrase and rename modals the input is the first field, so they clear fully.
- **Result:** Confirmed working on device for the simple popups (unlock prompt, rename prompt, Paste
textarea itself). Two residual cases remained, both instances of "something pushes the focused
element low within a tall popup," fixed in Attempt 4b below.
### Attempt 4b — companion tweaks for tall-popup cases (current)
Top-anchoring fixes popups whose input sits near the top, but two surfaces kept the focused element
low:
1. **Change passphrase (settings → Change passphrase):** the two passphrase fields render *below* the
"Picking a strong passphrase" tips block, so even a top-anchored modal put them in the keyboard
zone. Fix: the tips block is now a `<details>` ([src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts)
`renderPassphraseTips`), expanded by default on every platform (opt-out security guidance) but
auto-collapsing on mobile when a passphrase field is focused (`collapseTipsOnMobile`), so the
guidance is seen on open yet the fields rise under the title once the user taps in. The first
field's `autofocus` is disabled on mobile so the collapse coincides with the user's tap, not a
premature programmatic focus.
2. **Paste tab submit button:** the textarea was fine but the "Clean up" button below it was covered.
Fixes: the textarea renders at `rows = 4` on mobile (vs 10 on desktop, [src/ui/modal.ts](../src/ui/modal.ts))
with the desktop 160px `min-height` floor dropped to 80px under `.is-mobile`; and the reporter's
observation that Obsidian leaves an empty band above the title is addressed by `padding-top: 8px`
on `.modal-content` + `margin-top: 0` on the `h2` (mobile-scoped), shifting the whole popup up.
All of these are in [styles.css](../styles.css) plus the two small JS changes noted above. Still
signal-independent; no `visualViewport` or Capacitor dependency.
## Removed JS helper (Attempts 1-3, verbatim summary — no longer in the codebase)
This describes the `installMobileKeyboardScrollFix` / `liftAboveKeyboard` logic that Attempt 4
deleted. Retained for history only; none of this runs anymore.
`liftAboveKeyboard(target, vv)`:
1. `visibleBottom = vv.offsetTop + vv.height`. If `rect.bottom <= visibleBottom - 16`, **return

View file

@ -0,0 +1,60 @@
# Plan: Remove the plaintext secrets mode; defer SecretStorage to GA
Status: planned (not yet implemented). Captured 2026-05-28.
## Context
The plugin stores per-profile API keys in `secrets.json.nosync` via [src/secrets.ts](../src/secrets.ts) using three encryption modes: `safeStorage` (OS keychain), `passphrase` (AES-GCM/PBKDF2), and `plaintext` (no encryption). Plaintext exists only as the zero-config auto-fallback on devices without an OS keychain (mobile, Linux-without-keyring).
The original idea was to swap plaintext for Obsidian's new SecretStorage API. Investigation found SecretStorage (`app.secretStorage.getSecret/setSecret/listSecrets`, plus `SecretComponent`) only shipped in **Obsidian 1.11.4** (Jan 2026, desktop early-access). It is not in the installed typings (1.10.3), `minAppVersion` is `1.4.0`, and 1.11.x is not yet on npm `latest`.
Decisions taken:
- **Remove the plaintext mode entirely now.** No unencrypted at-rest option should exist.
- **Defer SecretStorage** until it reaches GA (and lands on mobile). Record it as future work rather than building against an early-access API.
**Behavioral consequence (intended, accepted):** after this change, a device with no OS keychain has no zero-config store. First-run on such a device requires the user to set a passphrase before any API key can be saved. This is the main UX shift and the reason the change is more than a one-line deletion.
## Approach
### 1. [src/secrets.ts](../src/secrets.ts) — drop plaintext, add a `configured` flag
- `EncryptionMode` -> `'safeStorage' | 'passphrase'` (remove `'plaintext'`).
- `defaultEnvelope()`: `mode: getSafeStorage() ? 'safeStorage' : 'passphrase'`. A passphrase envelope with no `kdf`/`verifier` is the "unconfigured" state (nothing is ever written in this state because `saveManyKeys` is already a no-op while locked).
- `encryptValue` / `decryptValue`: remove the `plaintext` branches.
- `parseEnvelope`: drop `plaintext` from the valid-mode check; a stored `plaintext` mode now parses as invalid -> `defaultEnvelope()` (fresh start, consistent with the pre-release no-migration rule).
- `changeEncryptionMode`: remove plaintext handling (only `safeStorage` and `passphrase` remain; the non-passphrase branch keeps `unlockedKey = null`).
- `EncryptionStatus`: add `configured: boolean`, computed in `getEncryptionStatus` as `mode !== 'passphrase' || (envelope.kdf != null && envelope.verifier != null)`. Distinguishes "passphrase set but locked" (unlock) from "no passphrase yet" (create).
### 2. [src/main.ts](../src/main.ts) — make `promptUnlock` branch on `configured`
- When `!encryptionStatus.configured`: open `PassphraseModal` in *create* mode (`requireConfirm: true`, `onSubmit` -> `changeEncryptionMode(this, 'passphrase', pass)` then hydrate + refresh). Reuses the exact flow already in `handleModeChange('passphrase')`.
- When `configured` (and locked): existing unlock flow (`unlockSecrets`).
- The four entry points ([src/ui/modal.ts](../src/ui/modal.ts), [src/ui/quick-record.ts](../src/ui/quick-record.ts), [src/ui/text-source.ts](../src/ui/text-source.ts), [src/ui/audio-source.ts](../src/ui/audio-source.ts)) already gate on `encryptionStatus.locked` and call `promptUnlock()`; unconfigured-passphrase is `locked === true`, so they fire correctly with no change. Only `promptUnlock`'s body changes. Keep the method name (avoids churn across 5 call sites; the create branch is an internal detail of "ensure secrets are usable").
### 3. [src/settings/tab.ts](../src/settings/tab.ts) — UI cleanup + first-run prompt
- Dropdown: remove the `dd.addOption('plaintext', ...)` line.
- Banner (`renderEncryption`): remove the `else if (status.mode === 'plaintext')` branch. Add a branch for `mode === 'passphrase' && !status.configured` (reuse the `is-warning` class) reading "Set a passphrase to store your API keys securely." with a "Set passphrase" button -> `promptUnlock(() => this.display())` (now routes to create). Keep the existing `status.locked` branch for the configured-but-locked case; its copy ("Enter your passphrase to decrypt") is then only shown when a passphrase already exists.
- `encryptionModeDescription`: remove the "Plaintext: no encryption..." line.
- `handleModeChange`: the non-passphrase path now only ever switches to `safeStorage`; simplify the label to `'OS keychain'`.
- `EncryptionStatus` consumers in this file take the new `configured` field automatically (it widens the existing snapshot type).
- Minor: `apiKeyPlaceholder` wording when locked-and-unconfigured ("Set a passphrase to store keys") — optional polish.
### 4. Docs
- [CLAUDE.md](../CLAUDE.md) "Secrets encryption" section: reduce the mode list to two, update the envelope description, rewrite the first-run-fallback note (now passphrase-unconfigured, not plaintext), and note that existing dev installs in plaintext lose stored keys (re-enter; per the pre-release no-migration rule).
- [docs/claude-scratch/STATUS.md](claude-scratch/STATUS.md): add a future-work / decision entry: "Adopt Obsidian SecretStorage (`app.secretStorage`, 1.11.4+) as an encryption mode once GA and available on mobile. Deferred 2026-05-28 (early-access). Would become the zero-config option that plaintext used to provide."
- Sweep `plaintext` across the repo (docs + `styles.css`) and update any user-facing copy. `styles.css` `.is-warning` stays used (reassigned to the unconfigured-passphrase banner).
## Reuse (do not write new)
- `PassphraseModal` ([src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts)) for the create-passphrase prompt.
- `changeEncryptionMode(plugin, 'passphrase', pass)` already creates the kdf+verifier and re-encrypts existing keys; reuse for both the create flow and desktop safeStorage->passphrase switching.
## Out of scope (explicit)
- Building anything against `app.secretStorage` / `SecretComponent` (deferred to GA).
- The shared-secret "store a reference by name" rearchitecture. The plugin keeps its value-owning model.
- Any migration code for existing plaintext installs.
## Verification
- `npm run build` (tsc `-noEmit` typecheck + esbuild) and `npm run lint` — CI parity. Confirm zero remaining `'plaintext'` references and no type errors from the narrowed `EncryptionMode`.
- Manual, desktop with keychain: Settings -> API key encryption shows only "OS keychain" and "Passphrase"; keys save/load; switch to passphrase (set + unlock + lock) and back to OS keychain.
- Manual, no-keychain path (test on mobile, or temporarily force `getSafeStorage()` to return `null`): first run shows the "Set a passphrase" banner; API key fields gated until set; after setting, keys persist; lock -> entry points (Open modal, Quick record, Process text, Reprocess audio) prompt to unlock; unconfigured state prompts to create. Reload to confirm the envelope round-trips.
## Future work (SecretStorage, when GA)
When `app.secretStorage` reaches GA and is confirmed on mobile, add a `secretStorage` encryption mode that routes `saveKey`/`loadKey`/`loadAllKeys` to `setSecret`/`getSecret`/`listSecrets` under the existing key IDs (`profile:desktop:transcription`, etc.). It would become the zero-config, cross-platform option that plaintext used to provide, sitting alongside `safeStorage` and `passphrase`. Bump the obsidian dev dependency for real typings and raise `minAppVersion` to the GA version at that time.

View file

@ -1,5 +1,4 @@
import { App, MarkdownView, Modal, moment, normalizePath, Notice, Setting, TFile } from 'obsidian';
import { installMobileKeyboardScrollFix } from './platform';
import { NewFileCollisionMode, NoteTemplate } from './types';
export type InsertStage = 'cursor' | 'newFile' | 'append';
@ -118,7 +117,6 @@ class RenamePromptModal extends Modal {
onOpen(): void {
const { contentEl } = this;
this.modalEl.addClass('rewrite-rename-modal');
installMobileKeyboardScrollFix(contentEl);
contentEl.createEl('h2', { text: 'File already exists' });
contentEl.createEl('p', { text: `A file already exists at ${this.conflictPath}. Choose a new path.` });

View file

@ -25,148 +25,3 @@ export function resolveActiveProfile(settings: GlobalSettings): {
export function isMediaRecorderAvailable(): boolean {
return typeof MediaRecorder !== 'undefined' && typeof navigator !== 'undefined' && !!navigator.mediaDevices;
}
// Mobile soft-keyboards routinely cover whichever input just received focus.
// This helper attaches a `focusin` listener to `root` and, when on mobile,
// brings the focused input/textarea into the visible region above the
// keyboard. Android WebViews do NOT resize the layout viewport when the
// keyboard opens (iOS does), so a bare `scrollIntoView({ block: 'center' })`
// centers the input on the full screen, which is then covered by the
// keyboard. We use `window.visualViewport` to read the actual post-keyboard
// visible region, then:
// 1. Scroll the nearest scrollable ancestor by the delta needed to lift
// the input. On the settings tab this fully resolves the case.
// 2. If the input is still below the visible region (typical for short
// Obsidian popups whose `.modal-content` has no scroll room), shrink
// the enclosing `.modal-container` to the visible region via inline
// `top`/`height`. Obsidian centers `.modal` inside `.modal-container`
// via flex, so the popup re-positions above the keyboard. Restore on
// blur. Scrolling cannot move a `position: fixed` modal up by itself.
// Falls back to `scrollIntoView` when `visualViewport` is unavailable. Safe
// to call on desktop; it returns immediately. The caller's element is
// expected to outlive the listener (Obsidian re-empties containers across
// renders rather than detaching them), so no explicit teardown is provided.
export function installMobileKeyboardScrollFix(root: HTMLElement): void {
if (!Platform.isMobile) return;
root.addEventListener('focusin', (event) => {
const target = event.target;
if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)) return;
const vv = window.visualViewport;
if (!vv) {
window.setTimeout(() => {
try {
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
} catch {
target.scrollIntoView();
}
}, 300);
return;
}
const act = () => liftAboveKeyboard(target, vv);
// If the keyboard is already up (visual viewport already shrunk), act now.
if (vv.height < window.innerHeight - 100) {
window.setTimeout(act, 50);
return;
}
// Otherwise wait for the visual viewport to shrink (keyboard opens),
// with a safety timeout so we still scroll if no resize fires.
let done = false;
const onResize = () => {
if (done) return;
done = true;
vv.removeEventListener('resize', onResize);
window.clearTimeout(timer);
act();
};
const timer = window.setTimeout(() => {
if (done) return;
done = true;
vv.removeEventListener('resize', onResize);
act();
}, 600);
vv.addEventListener('resize', onResize);
});
}
function liftAboveKeyboard(target: HTMLElement, vv: VisualViewport): void {
const margin = 16;
const visibleBottom = vv.offsetTop + vv.height;
let rect = target.getBoundingClientRect();
if (rect.bottom <= visibleBottom - margin) return;
let delta = rect.bottom - (visibleBottom - margin);
// First try scrolling the nearest scrollable ancestor. This is what works
// on the settings page (the inner tab-content scrolls and the input rises
// into the visible region).
const scrollable = findScrollableAncestor(target);
if (scrollable) {
scrollable.scrollTop += delta;
rect = target.getBoundingClientRect();
if (rect.bottom <= visibleBottom - margin) return;
delta = rect.bottom - (visibleBottom - margin);
}
// Still hidden. The input lives inside a `position: fixed` popup whose
// `.modal-content` doesn't have enough scroll room (passphrase modal,
// rename prompt, the Paste tab modal, etc.). Shrink the enclosing
// `.modal-container` to the visible region; Obsidian centers `.modal`
// inside it via flex, so the popup re-positions above the keyboard.
const container = findModalContainer(target);
if (container) {
applyModalContainerLift(container, target, vv);
return;
}
window.scrollBy(0, delta);
}
function findScrollableAncestor(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.body) {
const style = window.getComputedStyle(node);
const overflowY = style.overflowY;
if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) {
return node;
}
node = node.parentElement;
}
return null;
}
function findModalContainer(el: HTMLElement): HTMLElement | null {
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.body) {
if (node.classList.contains('modal-container')) return node;
node = node.parentElement;
}
return null;
}
function applyModalContainerLift(container: HTMLElement, target: HTMLElement, vv: VisualViewport): void {
const prevTop = container.style.top;
const prevHeight = container.style.height;
container.style.top = `${vv.offsetTop}px`;
container.style.height = `${vv.height}px`;
let restored = false;
const restore = () => {
if (restored) return;
restored = true;
target.removeEventListener('blur', onBlur);
vv.removeEventListener('resize', onResize);
container.style.top = prevTop;
container.style.height = prevHeight;
};
const onBlur = () => restore();
const onResize = () => {
// Keyboard closed: visual viewport returns to roughly full window height.
if (vv.height >= window.innerHeight - 50) restore();
else {
// Keyboard height may have changed (e.g., emoji panel toggled);
// re-fit the container.
container.style.top = `${vv.offsetTop}px`;
container.style.height = `${vv.height}px`;
}
};
target.addEventListener('blur', onBlur);
vv.addEventListener('resize', onResize);
}

View file

@ -11,7 +11,7 @@ import {
TranscriptionConfig,
TranscriptionProviderID,
} from '../types';
import { detectActiveProfileKind, installMobileKeyboardScrollFix } from '../platform';
import { detectActiveProfileKind } from '../platform';
import { createTranscriptionProvider } from '../transcription';
import { createLLMProvider } from '../llm';
import { formatWhisperStatus } from '../whisper-host';
@ -53,7 +53,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
// the full-container redraws that fire when dropdowns toggle conditional
// fields (provider, insertMode, activeProfileOverride).
private inactiveProfileExpanded = false;
private keyboardScrollFixInstalled = false;
constructor(app: App, private readonly plugin: ReWritePlugin) {
super(app, plugin);
@ -64,12 +63,6 @@ export class ReWriteSettingTab extends PluginSettingTab {
containerEl.empty();
containerEl.addClass('rewrite-settings');
// containerEl is reused across display() calls, so install once.
if (!this.keyboardScrollFixInstalled) {
installMobileKeyboardScrollFix(containerEl);
this.keyboardScrollFixInstalled = true;
}
this.renderEncryption(containerEl);
this.renderActiveProfile(containerEl);
this.renderProfile(containerEl, 'desktop');

View file

@ -1,7 +1,7 @@
import { App, Modal, Notice } from 'obsidian';
import { App, Modal, Notice, Platform } from 'obsidian';
import type ReWritePlugin from '../main';
import { runPipeline, PipelineSource, PipelineStage } from '../pipeline';
import { installMobileKeyboardScrollFix, isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { isMediaRecorderAvailable, resolveActiveProfile } from '../platform';
import { Recorder } from '../recorder';
import { DestinationOverride, EnvironmentProfile, InsertMode, NoteTemplate } from '../types';
import { isProfileConfigured, isProfileConfiguredForText, renderSetupCard } from './setup-card';
@ -28,7 +28,6 @@ export class ReWriteModal extends Modal {
onOpen(): void {
this.modalEl.addClass('rewrite-modal');
installMobileKeyboardScrollFix(this.contentEl);
this.render();
}
@ -334,7 +333,8 @@ export class ReWriteModal extends Modal {
private renderPasteTab(parent: HTMLElement): void {
const textarea = parent.createEl('textarea', { cls: 'rewrite-paste' });
textarea.rows = 10;
// Fewer rows on mobile so the submit button stays above the soft keyboard.
textarea.rows = Platform.isMobile ? 4 : 10;
const button = parent.createEl('button', { text: 'Clean up', cls: 'mod-cta' });
button.addEventListener('click', () => {
const text = textarea.value.trim();

View file

@ -1,5 +1,4 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import { installMobileKeyboardScrollFix } from '../platform';
import { App, Modal, Notice, Platform, Setting } from 'obsidian';
export interface PassphrasePromptParams {
app: App;
@ -17,6 +16,7 @@ export class PassphraseModal extends Modal {
private confirm = '';
private busy = false;
private errorEl: HTMLElement | null = null;
private tipsEl: HTMLDetailsElement | null = null;
constructor(private readonly params: PassphrasePromptParams) {
super(params.app);
@ -26,7 +26,6 @@ export class PassphraseModal extends Modal {
this.modalEl.addClass('rewrite-modal');
this.modalEl.addClass('rewrite-passphrase-modal');
const { contentEl } = this;
installMobileKeyboardScrollFix(contentEl);
contentEl.createEl('h2', { text: this.params.title });
if (this.params.description) {
@ -42,8 +41,11 @@ export class PassphraseModal extends Modal {
.addText((t) => {
t.inputEl.type = 'password';
t.inputEl.addClass('rewrite-passphrase-input');
t.inputEl.autofocus = true;
// On mobile, programmatic autofocus would fire `focus` (collapsing
// the tips) before the user has read them; let the user's tap do it.
t.inputEl.autofocus = !Platform.isMobile;
t.onChange((v) => { this.passphrase = v; });
t.inputEl.addEventListener('focus', () => this.collapseTipsOnMobile());
t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e));
});
@ -54,6 +56,7 @@ export class PassphraseModal extends Modal {
t.inputEl.type = 'password';
t.inputEl.addClass('rewrite-passphrase-input');
t.onChange((v) => { this.confirm = v; });
t.inputEl.addEventListener('focus', () => this.collapseTipsOnMobile());
t.inputEl.addEventListener('keydown', (e) => this.onKeydown(e));
});
@ -79,8 +82,14 @@ export class PassphraseModal extends Modal {
}
private renderPassphraseTips(parent: HTMLElement): void {
const tips = parent.createDiv({ cls: 'rewrite-passphrase-tips' });
tips.createEl('strong', { text: 'Picking a strong passphrase' });
// Expanded by default everywhere so the guidance is seen before typing
// (opt-out, not opt-in). On mobile it auto-collapses when a passphrase
// field is focused (see collapseTipsOnMobile), so it doesn't push the
// fields into the soft keyboard once the user starts entering a value.
const tips = parent.createEl('details', { cls: 'rewrite-passphrase-tips' });
tips.setAttr('open', '');
this.tipsEl = tips;
tips.createEl('summary', { text: 'Picking a strong passphrase' });
const list = tips.createEl('ul');
@ -106,6 +115,10 @@ export class PassphraseModal extends Modal {
li3.createSpan({ text: ' for brute-force time estimates by length and character class.' });
}
private collapseTipsOnMobile(): void {
if (Platform.isMobile) this.tipsEl?.removeAttribute('open');
}
private onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
e.preventDefault();

View file

@ -362,8 +362,12 @@
font-size: var(--font-ui-small);
}
.rewrite-passphrase-modal .rewrite-passphrase-tips strong {
display: block;
.rewrite-passphrase-modal .rewrite-passphrase-tips summary {
font-weight: var(--font-bold, 700);
cursor: pointer;
}
.rewrite-passphrase-modal .rewrite-passphrase-tips[open] summary {
margin-bottom: 6px;
}
@ -409,3 +413,47 @@
font-family: var(--font-monospace);
}
/*
* Mobile soft-keyboard avoidance.
*
* Obsidian mobile (Capacitor) overlays the soft keyboard on top of the WebView
* without resizing the layout or visual viewport, so a centered popup is
* covered by the keyboard with no JS signal to react to. We pin our own popups
* to the top of the screen instead: the keyboard opens from the bottom, so a
* top-anchored modal (and its near-top input fields) stays visible above it.
* Scoped to our modal classes so core Obsidian modals are untouched.
* `align-self: flex-start` moves just our modal to the top of the flex
* container that centers modals; the auto bottom margin is a fallback for
* non-default flex configurations.
*/
.is-mobile .rewrite-modal,
.is-mobile .rewrite-rename-modal {
align-self: flex-start;
margin-top: 8px;
margin-bottom: auto;
max-height: calc(100% - 16px);
}
/*
* Reclaim the empty band Obsidian leaves above the title on mobile and tighten
* the title's own top margin, so the whole popup shifts up and more of it
* clears the keyboard.
*/
.is-mobile .rewrite-modal .modal-content,
.is-mobile .rewrite-rename-modal .modal-content {
padding-top: 8px;
}
.is-mobile .rewrite-modal h2 {
margin-top: 0;
}
/*
* The Paste textarea renders at 4 rows on mobile (set in modal.ts); drop the
* desktop 160px min-height floor so that takes effect and the submit button
* below it stays above the keyboard.
*/
.is-mobile .rewrite-modal .rewrite-paste {
min-height: 80px;
}