diff --git a/src/components/CopilotView.tsx b/src/components/CopilotView.tsx index c7685887..a66cecbf 100644 --- a/src/components/CopilotView.tsx +++ b/src/components/CopilotView.tsx @@ -5,7 +5,7 @@ import { AppContext, EventTargetContext } from "@/context"; import CopilotPlugin from "@/main"; import { FileParserManager } from "@/tools/FileParserManager"; import * as Tooltip from "@radix-ui/react-tooltip"; -import { ItemView, WorkspaceLeaf } from "obsidian"; +import { ItemView, Platform, WorkspaceLeaf } from "obsidian"; import * as React from "react"; import { createRoot, Root } from "react-dom/client"; @@ -17,6 +17,8 @@ export default class CopilotView extends ItemView { private fileParserManager: FileParserManager; private root: Root | null = null; private handleSaveAsNote: (() => Promise) | null = null; + private keyboardObserver: MutationObserver | null = null; + private lastDrawerEl: HTMLElement | null = null; eventTarget: EventTarget; constructor( @@ -58,6 +60,52 @@ export default class CopilotView extends ItemView { }; this.renderView(handleSaveAsNote, updateUserMessageHistory); + this.setupMobileKeyboardObserver(); + } + + /** + * Observe --keyboard-height on style to toggle a class on the + * parent .workspace-drawer when the soft keyboard is open. + * CSS uses this class to hide drawer header elements on mobile. + * + * Reason: The drawer lookup is inside the callback (not at setup time) because + * the view can be moved from editor tab to drawer without triggering onOpen again. + */ + private setupMobileKeyboardObserver(): void { + if (!Platform.isMobile) return; + + // Reason: Disconnect any existing observer defensively in case onOpen runs more than once + this.keyboardObserver?.disconnect(); + + const syncKeyboardClass = () => { + const drawer = this.containerEl.closest(".workspace-drawer") as HTMLElement | null; + + // Reason: If the view moved out of its previous drawer, clear the class on the old one + // so drawer chrome (header/tab options) is restored. + if (this.lastDrawerEl && this.lastDrawerEl !== drawer) { + this.lastDrawerEl.classList.remove("copilot-keyboard-open"); + } + this.lastDrawerEl = drawer; + + if (!drawer) return; + + // Reason: Check if this view itself is inside the active tab content, rather than + // querying by data-type which is more brittle across Obsidian versions. + const isCopilotActive = !!this.containerEl.closest(".workspace-drawer-active-tab-content"); + const kbHeight = parseFloat( + document.documentElement.style.getPropertyValue("--keyboard-height") || "0" + ); + drawer.classList.toggle("copilot-keyboard-open", isCopilotActive && kbHeight > 0); + }; + + this.keyboardObserver = new MutationObserver(syncKeyboardClass); + this.keyboardObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["style"], + }); + + // Reason: Sync initial state in case keyboard is already open when view opens + syncKeyboardClass(); } private renderView( @@ -104,6 +152,14 @@ export default class CopilotView extends ItemView { } async onClose(): Promise { + this.keyboardObserver?.disconnect(); + this.keyboardObserver = null; + // Reason: Clean up the class on the tracked drawer element when the view is closed. + // Use lastDrawerEl instead of querying closest(), because the view may have already + // been detached from the drawer DOM by the time onClose fires. + this.lastDrawerEl?.classList.remove("copilot-keyboard-open"); + this.lastDrawerEl = null; + if (this.root) { this.root.unmount(); this.root = null; diff --git a/src/components/composer/ApplyView.tsx b/src/components/composer/ApplyView.tsx index c7240dd5..f6cc831e 100644 --- a/src/components/composer/ApplyView.tsx +++ b/src/components/composer/ApplyView.tsx @@ -4,11 +4,10 @@ import { getSettings, updateSetting } from "@/settings/model"; import { Change, diffArrays } from "diff"; import { Check, X as XIcon } from "lucide-react"; import { App, ItemView, Notice, TFile, WorkspaceLeaf } from "obsidian"; -import React, { useRef, memo, useMemo } from "react"; +import React, { memo, useMemo, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import { Button } from "../ui/button"; import { SettingSwitch } from "../ui/setting-switch"; -import { useState } from "react"; import { getChangeBlocks } from "@/composerUtils"; import { ApplyViewResult } from "@/types"; import { ensureFolderExists } from "@/utils"; diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index bccc62f1..146eab76 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -887,10 +887,10 @@ If your plugin does not need CSS, delete this file. /* use 'important' to prevent ob default css style from being third-party theme overridden */ .workspace-leaf-content[data-type="copilot-chat-view"] .view-content { - padding-bottom: max(var(--safe-area-inset-bottom), var(--size-4-8)) !important; + padding-bottom: max(calc(var(--safe-area-inset-bottom, 0px) + var(--size-4-1)), var(--size-4-8)) !important; } -/* open plugin in Editor mode for mobile */ +/* Editor mode for mobile - legacy (no bottom navbar): minimal padding */ body.is-mobile .workspace-tab-container .workspace-leaf-content[data-type="copilot-chat-view"] @@ -898,6 +898,48 @@ body.is-mobile padding-bottom: var(--size-4-1) !important; } +/* Editor mode for mobile - new version (with bottom navbar visible): reserve navbar space */ +@supports selector(body:has(*)) { + body.is-mobile:has(.mobile-navbar):not(.is-hidden-nav) + .workspace-tab-container + .workspace-leaf-content[data-type="copilot-chat-view"] + .view-content { + padding-bottom: calc( + var(--navbar-height, 50px) + max(var(--safe-area-inset-bottom, 0px), var(--size-4-3)) + var(--size-4-1) + ) !important; + } +} + +/* ApplyView for mobile - new version (with bottom navbar visible): reserve navbar space */ +@supports selector(body:has(*)) { + body.is-mobile:has(.mobile-navbar):not(.is-hidden-nav) + .workspace-tab-container + .workspace-leaf-content[data-type="obsidian-copilot-apply-view"] + .view-content { + padding-bottom: calc( + var(--navbar-height, 50px) + max(var(--safe-area-inset-bottom, 0px), var(--size-4-3)) + + var(--size-4-1) + var(--size-4-4) + ) !important; + } + + /* Lift the fixed Accept/Reject button bar above the navbar */ + body.is-mobile:has(.mobile-navbar):not(.is-hidden-nav) + .workspace-tab-container + .workspace-leaf-content[data-type="obsidian-copilot-apply-view"] + .tw-fixed.tw-bottom-4 { + bottom: calc( + var(--navbar-height, 50px) + max(var(--safe-area-inset-bottom, 0px), var(--size-4-3)) + + var(--size-4-1) + ) !important; + } +} + +/* Hide drawer header and tab-options when keyboard is open in Copilot's drawer */ +body.is-mobile .workspace-drawer.copilot-keyboard-open .workspace-drawer-header, +body.is-mobile .workspace-drawer.copilot-keyboard-open .workspace-drawer-tab-options { + display: none !important; +} + /* Collapsible sources styling */ .copilot-sources { margin-top: var(--size-4-1);