Adapt composer clearance to Obsidian status bar

This commit is contained in:
murashit 2026-06-05 14:06:55 +09:00
parent e0b3c29c2e
commit f7c91d880a
4 changed files with 135 additions and 2 deletions

View file

@ -15,6 +15,7 @@ interface ChatPanelShellMount {
props: ChatPanelShellProps;
stateStore: ChatStateStore;
unsubscribe: () => void;
stopStatusBarClearanceSync: () => void;
}
const shellMounts = new WeakMap<HTMLElement, ChatPanelShellMount>();
@ -62,8 +63,10 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe
const existing = shellMounts.get(container);
if (existing?.stateStore === props.stateStore) {
existing.props = props;
syncStatusBarClearance(container);
} else {
existing?.unsubscribe();
existing?.stopStatusBarClearanceSync();
shellMounts.set(container, {
props,
stateStore: props.stateStore,
@ -72,6 +75,7 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe
if (!mount) return;
renderMountedSlots(container, mount.props);
}),
stopStatusBarClearanceSync: startStatusBarClearanceSync(container),
});
}
renderMountedSlots(container, props);
@ -79,7 +83,9 @@ export function renderChatPanelShell(container: HTMLElement, props: ChatPanelShe
export function unmountChatPanelShell(container: HTMLElement | null): void {
if (!container) return;
shellMounts.get(container)?.unsubscribe();
const mount = shellMounts.get(container);
mount?.unsubscribe();
mount?.stopStatusBarClearanceSync();
shellMounts.delete(container);
unmountSlotRoots(container);
container.replaceChildren();
@ -142,3 +148,80 @@ function renderKey(renderVersion: number, snapshot: ChatPanelSlotSnapshot): stri
function ensureBody(container: HTMLElement): HTMLElement {
return container.querySelector<HTMLElement>(":scope > .codex-panel__body") ?? container.createDiv({ cls: "codex-panel__body" });
}
function startStatusBarClearanceSync(container: HTMLElement): () => void {
const win = container.ownerDocument.defaultView;
if (!win) return noop;
const cleanupCallbacks: (() => void)[] = [];
let observedStatusBar: HTMLElement | null = null;
let statusBarMutationObserver: MutationObserver | null = null;
let statusBarResizeObserver: ResizeObserver | null = null;
const observeStatusBar = (): void => {
const statusBar = container.ownerDocument.querySelector<HTMLElement>(".status-bar");
if (statusBar === observedStatusBar) return;
statusBarMutationObserver?.disconnect();
statusBarResizeObserver?.disconnect();
statusBarMutationObserver = null;
statusBarResizeObserver = null;
observedStatusBar = statusBar;
if (!statusBar) return;
statusBarMutationObserver = new win.MutationObserver(() => {
syncStatusBarClearance(container);
});
statusBarMutationObserver.observe(statusBar, { attributes: true, attributeFilter: ["class", "style"] });
const ResizeObserverCtor = (win as Window & { ResizeObserver?: typeof ResizeObserver }).ResizeObserver;
if (ResizeObserverCtor) {
statusBarResizeObserver = new ResizeObserverCtor(() => {
syncStatusBarClearance(container);
});
statusBarResizeObserver.observe(statusBar);
}
};
const sync = (): void => {
observeStatusBar();
syncStatusBarClearance(container);
};
const bodyObserver = new win.MutationObserver(sync);
bodyObserver.observe(container.ownerDocument.body, { attributes: true, attributeFilter: ["class", "style"], childList: true });
cleanupCallbacks.push(() => {
bodyObserver.disconnect();
});
win.addEventListener("resize", sync);
cleanupCallbacks.push(() => {
win.removeEventListener("resize", sync);
});
sync();
return () => {
for (const cleanup of cleanupCallbacks) cleanup();
statusBarMutationObserver?.disconnect();
statusBarResizeObserver?.disconnect();
};
}
function noop(): void {
return undefined;
}
function syncStatusBarClearance(container: HTMLElement): void {
container.style.setProperty("--codex-panel-status-bar-clearance", `${String(statusBarClearance(container))}px`);
}
function statusBarClearance(container: HTMLElement): number {
const win = container.ownerDocument.defaultView;
const statusBar = container.ownerDocument.querySelector<HTMLElement>(".status-bar");
if (!win || !statusBar) return 0;
const style = win.getComputedStyle(statusBar);
if (style.display === "none" || style.visibility === "hidden" || style.position !== "fixed") return 0;
const rectHeight = statusBar.getBoundingClientRect().height;
if (Number.isFinite(rectHeight) && rectHeight > 0) return Math.ceil(rectHeight);
const computedHeight = Number.parseFloat(style.height);
return Number.isFinite(computedHeight) && computedHeight > 0 ? Math.ceil(computedHeight) : 0;
}

View file

@ -32,7 +32,7 @@
--codex-panel-icon-button-block-size: calc(var(--codex-panel-control-icon-size) + var(--codex-panel-control-gap) * 2);
--codex-panel-composer-control-size: var(--codex-panel-icon-button-block-size);
--codex-panel-composer-min-height: calc((var(--codex-panel-composer-control-size) * 2) + var(--codex-panel-control-gap));
--codex-panel-status-bar-clearance: var(--status-bar-height, calc(var(--codex-panel-section-gap) * 3));
--codex-panel-status-bar-clearance: 0px;
--codex-panel-bottom-safe-area: max(env(safe-area-inset-bottom, 0px), var(--codex-panel-status-bar-clearance));
--codex-panel-toolbar-text-color: var(--text-muted);
--codex-panel-toolbar-text-size: var(--font-ui-small);

View file

@ -114,6 +114,46 @@ describe("ChatPanelShell", () => {
});
});
it("sets composer bottom clearance only for fixed visible Obsidian status bars", async () => {
const store = createChatStateStore();
const container = document.createElement("div");
const statusBar = document.createElement("div");
statusBar.className = "status-bar";
document.body.appendChild(statusBar);
document.body.appendChild(container);
Object.defineProperty(statusBar, "getBoundingClientRect", {
configurable: true,
value: () => ({ height: 26, width: 100, top: 0, right: 100, bottom: 26, left: 0, x: 0, y: 0, toJSON: () => ({}) }),
});
await act(async () => {
statusBar.style.display = "flex";
statusBar.style.position = "fixed";
renderChatPanelShell(container, shellRenderers(store));
await settleShellEffects();
});
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("26px");
await act(async () => {
statusBar.style.position = "static";
renderChatPanelShell(container, shellRenderers(store));
await settleShellEffects();
});
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px");
await act(async () => {
statusBar.style.display = "none";
renderChatPanelShell(container, shellRenderers(store));
await settleShellEffects();
});
expect(container.style.getPropertyValue("--codex-panel-status-bar-clearance")).toBe("0px");
await act(async () => {
unmountChatPanelShell(container);
});
statusBar.remove();
});
it("unmounts existing slot roots before rebuilding a damaged shell scaffold", async () => {
const store = createChatStateStore();
const container = document.createElement("div");

View file

@ -133,6 +133,16 @@ describe("chat toolbar CSS", () => {
expect(composerMetaFatal).toContain("padding-inline-start: calc(var(--size-4-1) / 2)");
});
it("lets the shell provide Obsidian status bar composer clearance", () => {
const tokenScope = /^(?<selectors>(?:\.[^{]+,\n)*\.[^{]+) \{(?<body>[^}]+)\}/m.exec(styles)?.groups?.["body"] ?? "";
const clearanceLine = tokenScope
.split("\n")
.find((line) => line.trim().startsWith("--codex-panel-status-bar-clearance:"))
?.trim();
expect(clearanceLine).toBe("--codex-panel-status-bar-clearance: 0px;");
});
it("keeps composer status accessible summary visually hidden", () => {
const summary = /\.codex-panel__composer-meta-summary \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";
const visual = /\.codex-panel__composer-meta-status-visual \{(?<body>[^}]+)\}/.exec(styles)?.groups?.["body"] ?? "";