fix: improve mobile keyboard/navbar CSS scoping and platform detection (#2157)

* fix: improve mobile keyboard/navbar CSS scoping and platform detection

* fix: clear drawer keyboard class when view moves out of drawer

Track the last drawer element so that when the Copilot view is moved
out of a drawer while the soft keyboard is open, the copilot-keyboard-open
class is properly removed from the old drawer, restoring its header/tab UI.
This commit is contained in:
Emt-lin 2026-02-07 02:09:47 +08:00 committed by GitHub
parent aa71040702
commit c76235a326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 102 additions and 5 deletions

View file

@ -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<void>) | 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 <html> 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<void> {
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;

View file

@ -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";

View file

@ -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);