Skip to content

Commit ca5c3ef

Browse files
Leo310claude
andauthored
feat(chat): hide the mobile toolbar while composing; smoother keyboard and jump-to-bottom (#525)
* feat(chat): hide the mobile toolbar while composing; slide on close too While the chat composer has focus, Obsidian's formatting toolbar and the spacer core reserves for it are hidden, so the composer sits 6px above the keyboard (tuned live on an iPhone). The chat has its own attach button, and tapping the conversation still dismisses the keyboard. The toolbar returns only once the keyboard has closed (restoring it on blur made the composer jump up 44px first), or at once if focus moves to another editor. Closing now slides the composer down with the keyboard instead of snapping. Co-Authored-By: Claude <noreply@anthropic.com> * fix(chat): jump-to-bottom through iOS momentum; toolbar state across views - A flick's momentum kept coasting in the scroller (the floating jump button isn't inside it, so tapping it doesn't stop it) and overwrote every frame of the jump animation, which stopped halfway. On mobile the scroller is set to overflow: hidden for the animation, which ends the momentum. Verified on an iPhone. - The body class that hides Obsidian's toolbar is now shared across chat views: it stays while any of them is composing, so closing an idle view no longer brings the toolbar back under an active one. - A toolbar-band change with the keyboard already up (focus arriving from or leaving for a note) now slides instead of jumping 66px. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bddeb1d commit ca5c3ef

2 files changed

Lines changed: 132 additions & 32 deletions

File tree

‎src/components/chat/MessageContainer.svelte‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ function animateScrollTo(top: number | "bottom") {
159159
if (scrollRafId !== null) {
160160
cancelAnimationFrame(scrollRafId);
161161
scrollRafId = null;
162+
el.style.overflowY = "";
162163
}
163164
164165
const start = el.scrollTop;
@@ -169,6 +170,14 @@ function animateScrollTo(top: number | "bottom") {
169170
const initialDelta = resolveTarget() - start;
170171
if (Math.abs(initialDelta) < 1) return;
171172
173+
// iOS: a flick keeps coasting in the scroller's own momentum, which the
174+
// floating jump button doesn't stop (it isn't inside the scroller), and that
175+
// momentum overwrote every frame of this animation — the jump stopped
176+
// halfway. `overflow: hidden` ends the momentum; programmatic scrolling
177+
// still works while it's set. Mobile only: on desktop it would hide the
178+
// scrollbar and reflow the content for the duration.
179+
if (onMobile) el.style.overflowY = "hidden";
180+
172181
const duration = Math.min(260, 120 + Math.abs(initialDelta) * 0.15);
173182
let startTime: number | null = null;
174183
@@ -182,6 +191,7 @@ function animateScrollTo(top: number | "bottom") {
182191
scrollRafId = window.requestAnimationFrame(step);
183192
} else {
184193
scrollRafId = null;
194+
el.style.overflowY = "";
185195
}
186196
};
187197
scrollRafId = window.requestAnimationFrame(step);

‎src/views/chat/Chat.svelte‎

Lines changed: 122 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
<script module lang="ts">
2+
// Chat roots whose composer is composing (see `setComposing`); the body class
3+
// that hides Obsidian's toolbar is shared by every open chat view.
4+
const composingViews = new Set<HTMLElement>();
5+
</script>
6+
17
<script lang="ts">
28
import { QueryClientProvider } from "@tanstack/svelte-query";
39
import Input from "../../components/chat/Input.svelte";
@@ -212,54 +218,120 @@ function portalComposer(node: HTMLElement) {
212218
publishGeometry();
213219
};
214220
215-
// Slide the composer up with the keyboard instead of snapping. Obsidian
221+
// Slide the composer with the keyboard instead of snapping. Obsidian
216222
// flips `--keyboard-height` (inline on the root) in one step when the
217223
// keyboard starts to open, which puts the composer at its final `top` at
218224
// once while the keyboard is still rising. Core animates its own toolbar the
219225
// same way this does: jump to the end position, offset back by the distance
220226
// travelled, then transition the offset away. The curve approximates the iOS
221227
// keyboard's (fast start, long settle) rather than core's slow-start one,
222228
// which let the rising keyboard cover the composer for the first frames.
223-
// Closing still snaps: the keyboard slides away beneath it, which already
224-
// reads as native.
229+
// Closing slides the same way in reverse (a negative offset); snapping
230+
// dropped the composer to the bottom while the keyboard was still leaving.
231+
// While the composer has focus, hide Obsidian's formatting toolbar (and the
232+
// spacer core reserves for it) so the composer sits right on the keyboard;
233+
// the chat has its own attach button, and tapping the conversation still
234+
// dismisses the keyboard since the message list takes focus. The band the
235+
// layout rules reserve for the toolbar becomes `COMPOSING_BAND`: a 6px gap
236+
// minus the 20px the composer keeps below its card (the glow spacer plus the
237+
// flex gap), so that empty strip tucks behind the keyboard's edge instead of
238+
// the card shifting when focus lands.
239+
//
240+
// On blur the toolbar must not come back before the keyboard has closed:
241+
// focus leaves first, and restoring the band then made the composer jump up
242+
// 44px before sliding down. So it's deferred to the moment
243+
// `--keyboard-height` returns to 0 — unless focus moved to another editor
244+
// (a note), which needs its toolbar at once.
245+
const COMPOSING_CLASS = "s2b-composing";
246+
const COMPOSING_BAND = -14;
247+
let composing = false;
248+
let composingPending = false;
249+
let composingCleanup: (() => void) | null = null;
250+
225251
const KEYBOARD_SLIDE = "transform 300ms cubic-bezier(0.38, 0.7, 0.125, 1)";
252+
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
226253
const readKeyboardHeight = () =>
227254
Number.parseFloat(document.documentElement.style.getPropertyValue("--keyboard-height")) || 0;
228-
const watchKeyboard = (el: HTMLElement) => {
229-
const rootStyle = getComputedStyle(document.documentElement);
230-
const toolbarHeight = Number.parseFloat(rootStyle.getPropertyValue("--mobile-toolbar-height")) || 52;
231-
const probe = document.createElement("div");
232-
probe.style.cssText = "position:absolute;visibility:hidden;padding-bottom:env(safe-area-inset-bottom)";
233-
document.body.appendChild(probe);
234-
const safeBottom = Number.parseFloat(getComputedStyle(probe).paddingBottom) || 0;
235-
probe.remove();
236-
// The band below the composer, mirroring the `max()` in its `top` rule.
237-
const band = (keyboard: number) => Math.max(keyboard + toolbarHeight, 52 + safeBottom);
238-
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
255+
const rootStyle = getComputedStyle(document.documentElement);
256+
const toolbarHeight = Number.parseFloat(rootStyle.getPropertyValue("--mobile-toolbar-height")) || 52;
257+
const probe = document.createElement("div");
258+
probe.style.cssText = "position:absolute;visibility:hidden;padding-bottom:env(safe-area-inset-bottom)";
259+
document.body.appendChild(probe);
260+
const safeBottom = Number.parseFloat(getComputedStyle(probe).paddingBottom) || 0;
261+
probe.remove();
262+
// The band below the composer, mirroring the `max()` in its `top` rule, with
263+
// the toolbar's share swapped for `COMPOSING_BAND` while composing.
264+
const band = (keyboard: number, isComposing: boolean) =>
265+
Math.max(keyboard + (isComposing ? COMPOSING_BAND : toolbarHeight), 52 + safeBottom);
266+
267+
// Core's toolbar technique: the composer is already at its end position, so
268+
// offset it back by the distance travelled and transition the offset away.
269+
const slideComposer = (el: HTMLElement, offset: number) => {
270+
if (offset === 0 || reducedMotion.matches || el.style.display === "none") return;
271+
el.style.transition = "none";
272+
el.style.transform = `translateY(${offset}px)`;
273+
// Commit the offset now, in the triggering task, so the slide starts on
274+
// the same frame as the keyboard.
275+
void getComputedStyle(el).transform;
276+
el.style.transition = KEYBOARD_SLIDE;
277+
el.style.transform = "";
278+
// Children's transitions (the input card's border colour changes on
279+
// focus, mid-slide) bubble here too; only the composer's own transform
280+
// ends the slide.
281+
const finishSlide = (event: TransitionEvent) => {
282+
if (event.target !== el || event.propertyName !== "transform") return;
283+
el.style.transition = "";
284+
el.removeEventListener("transitionend", finishSlide);
285+
};
286+
el.addEventListener("transitionend", finishSlide);
287+
};
288+
289+
const setComposing = (el: HTMLElement, on: boolean) => {
290+
composingPending = false;
291+
if (on === composing) return;
292+
// With the keyboard already up (focus arriving from a note, or leaving
293+
// for one) the band changes without any keyboard change, so slide here.
294+
const keyboard = readKeyboardHeight();
295+
const before = band(keyboard, composing);
296+
composing = on;
297+
// Several chat views can be open; the toolbar stays hidden while any of
298+
// them is composing.
299+
if (on) composingViews.add(node);
300+
else composingViews.delete(node);
301+
document.body.classList.toggle("s2b-chat-composing", composingViews.size > 0);
302+
el.classList.toggle(COMPOSING_CLASS, on);
303+
node.classList.toggle(COMPOSING_CLASS, on);
304+
if (keyboard > 0) slideComposer(el, band(keyboard, on) - before);
305+
};
306+
const watchComposing = (el: HTMLElement) => {
307+
const onFocusIn = () => setComposing(el, true);
308+
const onFocusOut = (event: FocusEvent) => {
309+
const next = event.relatedTarget as HTMLElement | null;
310+
if (next && el.contains(next)) return;
311+
const toEditor = next?.isContentEditable || next?.closest("input, textarea, .cm-editor");
312+
if (toEditor || readKeyboardHeight() === 0) setComposing(el, false);
313+
else composingPending = true;
314+
};
315+
el.addEventListener("focusin", onFocusIn);
316+
el.addEventListener("focusout", onFocusOut);
317+
composingCleanup = () => {
318+
el.removeEventListener("focusin", onFocusIn);
319+
el.removeEventListener("focusout", onFocusOut);
320+
setComposing(el, false);
321+
};
322+
};
239323
324+
const watchKeyboard = (el: HTMLElement) => {
240325
let lastKeyboard = readKeyboardHeight();
241326
keyboardObserver = new MutationObserver(() => {
242327
const keyboard = readKeyboardHeight();
243328
if (keyboard === lastKeyboard) return;
244-
const rise = band(keyboard) - band(lastKeyboard);
329+
const offset = band(keyboard, composing) - band(lastKeyboard, composing);
245330
lastKeyboard = keyboard;
246-
if (rise <= 0 || reducedMotion.matches || el.style.display === "none") return;
247-
el.style.transition = "none";
248-
el.style.transform = `translateY(${rise}px)`;
249-
// Commit the offset now, in the keyboard's own task, so the slide
250-
// starts on the same frame the keyboard does.
251-
void getComputedStyle(el).transform;
252-
el.style.transition = KEYBOARD_SLIDE;
253-
el.style.transform = "";
254-
// Children's transitions (the input card's border colour changes on
255-
// focus, mid-slide) bubble here too; only the composer's own transform
256-
// ends the slide.
257-
const finishSlide = (event: TransitionEvent) => {
258-
if (event.target !== el || event.propertyName !== "transform") return;
259-
el.style.transition = "";
260-
el.removeEventListener("transitionend", finishSlide);
261-
};
262-
el.addEventListener("transitionend", finishSlide);
331+
slideComposer(el, offset);
332+
// The deferred blur (see above) lands once the keyboard is gone; at
333+
// height 0 both bands resolve to the navbar's, so nothing moves.
334+
if (keyboard === 0 && composingPending) setComposing(el, false);
263335
});
264336
keyboardObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] });
265337
};
@@ -303,6 +375,7 @@ function portalComposer(node: HTMLElement) {
303375
classObserver = new MutationObserver(ensurePortaledClass);
304376
classObserver.observe(found, { attributes: true, attributeFilter: ["class"] });
305377
watchKeyboard(found);
378+
watchComposing(found);
306379
};
307380
308381
// `<Input>` is rendered by a child component, so it may not exist yet when
@@ -330,6 +403,7 @@ function portalComposer(node: HTMLElement) {
330403
classObserver?.disconnect();
331404
treeObserver?.disconnect();
332405
keyboardObserver?.disconnect();
406+
composingCleanup?.();
333407
if (!composer) return;
334408
composer.style.display = "";
335409
composer.style.transform = "";
@@ -593,6 +667,22 @@ function portalComposer(node: HTMLElement) {
593667
);
594668
}
595669
670+
/* See `watchComposing`: the toolbar and its reserved spacer are hidden while
671+
the chat composer has focus, and every rule above that reserves the
672+
toolbar's band (`--mobile-toolbar-height`) gets `COMPOSING_BAND` instead —
673+
set on the composer and the chat root only, never the document root, whose
674+
variable changes restyle the whole page. Keep the value in sync with
675+
`COMPOSING_BAND`. */
676+
:global(body.s2b-chat-composing .mobile-toolbar),
677+
:global(body.s2b-chat-composing .mobile-toolbar-spacer) {
678+
display: none;
679+
}
680+
681+
:global(.is-mobile .chat-root.s2b-composing),
682+
:global(.is-mobile .chat-input-container.s2b-composer-portaled.s2b-composing) {
683+
--mobile-toolbar-height: -14px;
684+
}
685+
596686
/* Anchor the absolute chat-root to the leaf's content area. `:has` is supported
597687
on the iOS WebKit / modern Electron Obsidian runs on. */
598688
:global(.is-mobile .view-content:has(> .chat-root)) {

0 commit comments

Comments
 (0)