|
| 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 | + |
1 | 7 | <script lang="ts"> |
2 | 8 | import { QueryClientProvider } from "@tanstack/svelte-query"; |
3 | 9 | import Input from "../../components/chat/Input.svelte"; |
@@ -212,54 +218,120 @@ function portalComposer(node: HTMLElement) { |
212 | 218 | publishGeometry(); |
213 | 219 | }; |
214 | 220 |
|
215 | | - // Slide the composer up with the keyboard instead of snapping. Obsidian |
| 221 | + // Slide the composer with the keyboard instead of snapping. Obsidian |
216 | 222 | // flips `--keyboard-height` (inline on the root) in one step when the |
217 | 223 | // keyboard starts to open, which puts the composer at its final `top` at |
218 | 224 | // once while the keyboard is still rising. Core animates its own toolbar the |
219 | 225 | // same way this does: jump to the end position, offset back by the distance |
220 | 226 | // travelled, then transition the offset away. The curve approximates the iOS |
221 | 227 | // keyboard's (fast start, long settle) rather than core's slow-start one, |
222 | 228 | // 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 | +
|
225 | 251 | const KEYBOARD_SLIDE = "transform 300ms cubic-bezier(0.38, 0.7, 0.125, 1)"; |
| 252 | + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); |
226 | 253 | const readKeyboardHeight = () => |
227 | 254 | 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 | + }; |
239 | 323 |
|
| 324 | + const watchKeyboard = (el: HTMLElement) => { |
240 | 325 | let lastKeyboard = readKeyboardHeight(); |
241 | 326 | keyboardObserver = new MutationObserver(() => { |
242 | 327 | const keyboard = readKeyboardHeight(); |
243 | 328 | if (keyboard === lastKeyboard) return; |
244 | | - const rise = band(keyboard) - band(lastKeyboard); |
| 329 | + const offset = band(keyboard, composing) - band(lastKeyboard, composing); |
245 | 330 | 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); |
263 | 335 | }); |
264 | 336 | keyboardObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] }); |
265 | 337 | }; |
@@ -303,6 +375,7 @@ function portalComposer(node: HTMLElement) { |
303 | 375 | classObserver = new MutationObserver(ensurePortaledClass); |
304 | 376 | classObserver.observe(found, { attributes: true, attributeFilter: ["class"] }); |
305 | 377 | watchKeyboard(found); |
| 378 | + watchComposing(found); |
306 | 379 | }; |
307 | 380 |
|
308 | 381 | // `<Input>` is rendered by a child component, so it may not exist yet when |
@@ -330,6 +403,7 @@ function portalComposer(node: HTMLElement) { |
330 | 403 | classObserver?.disconnect(); |
331 | 404 | treeObserver?.disconnect(); |
332 | 405 | keyboardObserver?.disconnect(); |
| 406 | + composingCleanup?.(); |
333 | 407 | if (!composer) return; |
334 | 408 | composer.style.display = ""; |
335 | 409 | composer.style.transform = ""; |
@@ -593,6 +667,22 @@ function portalComposer(node: HTMLElement) { |
593 | 667 | ); |
594 | 668 | } |
595 | 669 |
|
| 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 | +
|
596 | 686 | /* Anchor the absolute chat-root to the leaf's content area. `:has` is supported |
597 | 687 | on the iOS WebKit / modern Electron Obsidian runs on. */ |
598 | 688 | :global(.is-mobile .view-content:has(> .chat-root)) { |
|
0 commit comments