fix: combo dropdown no longer jumps modal to top on first click

The hex-editor / map-link combo dropdowns lived inside the scrolling
`.modal-content` and were positioned `absolute`, so to avoid being
clipped the code toggled the scroll container to `overflow-y: visible`
while open. Switching a *scrolled* element from `overflow: auto` to
`visible` forces its scrollTop to 0 — so the first click on a section
you'd scrolled down to (Towns/Quests/etc.) snapped the whole modal to
the top. Browser-universal, not Linux-only (issue #31).

Replace the overflow hack with a shared `HexmakerModal.anchorDropdown`
helper that positions the dropdown `position: fixed`, anchored to its
trigger from JS. A fixed child resolves its containing block to the
viewport (no modal ancestor has a transform), so it escapes every
overflow clip without touching the scroll container — scroll position
is never disturbed. The helper repositions on scroll/resize/filter and
flips above the input when there's no room below. Both HexEditorModal
and MapLinkModal use it.

Also remove the dead `renderLinkSection` method and its now-orphaned
imports from HexEditorModal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaprettycoolguy@protonmail.com 2026-06-23 19:02:46 -04:00
parent 852e178113
commit a2dcfd2cfb
4 changed files with 139 additions and 121 deletions

View file

@ -1,61 +1,114 @@
import { App, Modal } from "obsidian";
/** Base class for all Hexmaker modals. Provides shared behaviour. */
export class HexmakerModal extends Modal {
constructor(app: App) {
super(app);
}
/** Make this modal draggable by its title-bar area. Safe to call multiple times. */
protected makeDraggable(): void {
const modalEl = this.modalEl;
if (modalEl.dataset.draggable) return;
modalEl.dataset.draggable = "1";
modalEl.addClass("duckmage-editor-modal-drag");
// position: fixed so the containing block is the viewport rather than
// the nearest transform/filter/will-change ancestor. Other plugins or
// themes can establish a containing block on a `.modal-container` ancestor
// (issue #26 — a third-party plugin re-rooted position:absolute away from
// the viewport, landing the modal partway off-screen).
modalEl.setCssProps({ position: 'fixed', margin: '0' });
const doc = modalEl.ownerDocument;
const win = doc.defaultView ?? window;
// Clamp the centered position so the modal can never open off-screen,
// even if a transformed ancestor still establishes a containing block
// for fixed positioning.
const PADDING = 8;
const centerInViewport = () => {
const r = modalEl.getBoundingClientRect();
const maxLeft = Math.max(PADDING, win.innerWidth - r.width - PADDING);
const maxTop = Math.max(PADDING, win.innerHeight - r.height - PADDING);
const left = Math.min(Math.max((win.innerWidth - r.width) / 2, PADDING), maxLeft);
const top = Math.min(Math.max((win.innerHeight - r.height) / 2, PADDING), maxTop);
modalEl.setCssProps({ left: `${left}px`, top: `${top}px` });
};
win.requestAnimationFrame(centerInViewport);
modalEl.addEventListener("mousedown", (e: MouseEvent) => {
const modalContent = modalEl.querySelector<HTMLElement>(".modal-content");
if (modalContent && e.clientY >= modalContent.getBoundingClientRect().top) return;
if ((e.target as HTMLElement).closest("button, a, input, select, textarea")) return;
e.preventDefault();
const r = modalEl.getBoundingClientRect();
modalEl.setCssProps({ left: `${r.left}px`, top: `${r.top}px` });
const sx = e.clientX, sy = e.clientY;
const ox = r.left, oy = r.top;
const onMove = (ev: MouseEvent) => {
modalEl.setCssProps({ left: `${ox + ev.clientX - sx}px`, top: `${oy + ev.clientY - sy}px` });
};
const onUp = () => {
doc.removeEventListener("mousemove", onMove);
doc.removeEventListener("mouseup", onUp);
};
doc.addEventListener("mousemove", onMove);
doc.addEventListener("mouseup", onUp);
});
}
}
import { App, Modal } from "obsidian";
/** Base class for all Hexmaker modals. Provides shared behaviour. */
export class HexmakerModal extends Modal {
constructor(app: App) {
super(app);
}
/** Make this modal draggable by its title-bar area. Safe to call multiple times. */
protected makeDraggable(): void {
const modalEl = this.modalEl;
if (modalEl.dataset.draggable) return;
modalEl.dataset.draggable = "1";
modalEl.addClass("duckmage-editor-modal-drag");
// position: fixed so the containing block is the viewport rather than
// the nearest transform/filter/will-change ancestor. Other plugins or
// themes can establish a containing block on a `.modal-container` ancestor
// (issue #26 — a third-party plugin re-rooted position:absolute away from
// the viewport, landing the modal partway off-screen).
modalEl.setCssProps({ position: 'fixed', margin: '0' });
const doc = modalEl.ownerDocument;
const win = doc.defaultView ?? window;
// Clamp the centered position so the modal can never open off-screen,
// even if a transformed ancestor still establishes a containing block
// for fixed positioning.
const PADDING = 8;
const centerInViewport = () => {
const r = modalEl.getBoundingClientRect();
const maxLeft = Math.max(PADDING, win.innerWidth - r.width - PADDING);
const maxTop = Math.max(PADDING, win.innerHeight - r.height - PADDING);
const left = Math.min(Math.max((win.innerWidth - r.width) / 2, PADDING), maxLeft);
const top = Math.min(Math.max((win.innerHeight - r.height) / 2, PADDING), maxTop);
modalEl.setCssProps({ left: `${left}px`, top: `${top}px` });
};
win.requestAnimationFrame(centerInViewport);
modalEl.addEventListener("mousedown", (e: MouseEvent) => {
const modalContent = modalEl.querySelector<HTMLElement>(".modal-content");
if (modalContent && e.clientY >= modalContent.getBoundingClientRect().top) return;
if ((e.target as HTMLElement).closest("button, a, input, select, textarea")) return;
e.preventDefault();
const r = modalEl.getBoundingClientRect();
modalEl.setCssProps({ left: `${r.left}px`, top: `${r.top}px` });
const sx = e.clientX, sy = e.clientY;
const ox = r.left, oy = r.top;
const onMove = (ev: MouseEvent) => {
modalEl.setCssProps({ left: `${ox + ev.clientX - sx}px`, top: `${oy + ev.clientY - sy}px` });
};
const onUp = () => {
doc.removeEventListener("mousemove", onMove);
doc.removeEventListener("mouseup", onUp);
};
doc.addEventListener("mousemove", onMove);
doc.addEventListener("mouseup", onUp);
});
}
/**
* Anchor a combo dropdown to its trigger as a viewport-`fixed` element.
*
* The dropdown markup lives inside the modal's scrolling `.modal-content`.
* Positioning it `absolute` there means an `overflow` ancestor clips it, so
* the old code switched `.modal-content` to `overflow: visible` while open
* which forces a scrolled container's `scrollTop` back to 0 and snaps the
* whole modal to the top on the first interaction (issue #31). A `fixed`
* dropdown resolves its containing block to the viewport (no modal ancestor
* has a transform), so it escapes every `overflow` clip WITHOUT touching the
* scroll container the scroll position is never disturbed.
*
* Returns `{ reposition, detach }`: call `reposition()` after the dropdown's
* contents change (filter typing flips its height), and `detach()` from the
* close path to remove the scroll/resize listeners.
*/
protected anchorDropdown(
anchorEl: HTMLElement,
dropdownEl: HTMLElement,
): { reposition: () => void; detach: () => void } {
const win = anchorEl.ownerDocument.defaultView ?? window;
const GAP = 2;
const PADDING = 8;
const reposition = () => {
const r = anchorEl.getBoundingClientRect();
const below = win.innerHeight - r.bottom;
const above = r.top;
const dh = dropdownEl.offsetHeight;
// Prefer dropping below; flip above only when there isn't room below
// AND there's more room above.
const flip = dh > below && above > below;
const top = flip
? Math.max(PADDING, r.top - GAP - dh)
: r.bottom + GAP;
dropdownEl.setCssProps({
left: `${r.left}px`,
top: `${top}px`,
width: `${r.width}px`,
});
};
reposition();
const scrollPane = anchorEl.closest<HTMLElement>(".modal-content");
scrollPane?.addEventListener("scroll", reposition, { passive: true });
win.addEventListener("resize", reposition);
return {
reposition,
detach: () => {
scrollPane?.removeEventListener("scroll", reposition);
win.removeEventListener("resize", reposition);
},
};
}
}

View file

@ -18,12 +18,10 @@ import {
import {
addLinkToSection,
removeLinkFromSection,
getLinksInSection,
getAllSectionData,
setSectionContent,
addBacklinkToFile,
} from "../sections";
import { FileLinkSuggestModal } from "./FileLinkSuggestModal";
import { TEXT_SECTIONS } from "../types";
import type { LinkSection, HexEditorOptions, TerrainColor } from "../types";
import { RandomTableModal } from "../random-tables/RandomTableModal";
@ -833,19 +831,22 @@ export class HexEditorModal extends HexmakerModal {
}
};
const scrollPane = comboWrap.closest<HTMLElement>(".modal-content");
let anchor: { reposition: () => void; detach: () => void } | null = null;
const openDropdown = () => {
isOpen = true;
populateDropdown(input.value);
scrollPane?.addClass("duckmage-combo-open");
dropdown.show();
// Anchor AFTER show so offsetHeight is measurable for above/below flip.
anchor?.detach();
anchor = this.anchorDropdown(comboWrap, dropdown);
};
const closeDropdown = () => {
isOpen = false;
dropdown.hide();
scrollPane?.removeClass("duckmage-combo-open");
anchor?.detach();
anchor = null;
};
const selectFile = async (file: TFile) => {
@ -906,7 +907,10 @@ export class HexEditorModal extends HexmakerModal {
);
input.addEventListener("input", () => {
if (!isOpen) openDropdown();
else populateDropdown(input.value);
else {
populateDropdown(input.value);
anchor?.reposition();
}
});
input.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") {
@ -937,49 +941,6 @@ export class HexEditorModal extends HexmakerModal {
});
}
private renderLinkSection(
container: HTMLElement,
path: string,
section: LinkSection,
hexExists: boolean,
initialLinks: string[],
): void {
const sectionEl = container.createDiv({
cls: "duckmage-editor-link-section",
});
const header = sectionEl.createDiv({ cls: "duckmage-link-section-header" });
header.createEl("h4", { text: section });
const addBtn = header.createEl("button", {
text: "+ add",
cls: "duckmage-add-btn",
});
const linksEl = sectionEl.createDiv({ cls: "duckmage-link-list" });
if (hexExists) {
this.renderLinkList(linksEl, initialLinks, path);
} else {
linksEl.createSpan({ text: "—", cls: "duckmage-link-empty" });
}
addBtn.addEventListener("click", () => {
new FileLinkSuggestModal(this.app, this.plugin, (file) => {
void (async () => {
const hexFile = await this.ensureHexNote();
if (!hexFile) {
new Notice("Could not create hex note.");
return;
}
const linkText = `[[${this.app.metadataCache.fileToLinktext(file, path)}]]`;
await addLinkToSection(this.app, path, section, linkText);
this.onChanged();
const links = await getLinksInSection(this.app, path, section);
linksEl.empty();
this.renderLinkList(linksEl, links, path);
})();
}).open();
});
}
private renderLinkList(
container: HTMLElement,
links: string[],

View file

@ -85,26 +85,32 @@ export class MapLinkModal extends HexmakerModal {
}
};
const scrollPane = comboWrap.closest<HTMLElement>(".modal-content");
let anchor: { reposition: () => void; detach: () => void } | null = null;
const openDropdown = (query: string) => {
isOpen = true;
populateDropdown(query);
scrollPane?.addClass("duckmage-combo-open");
dropdown.show();
// Anchor AFTER show so offsetHeight is measurable for above/below flip.
anchor?.detach();
anchor = this.anchorDropdown(comboWrap, dropdown);
};
const closeDropdown = () => {
isOpen = false;
dropdown.hide();
scrollPane?.removeClass("duckmage-combo-open");
anchor?.detach();
anchor = null;
};
mapInput.addEventListener("focus", () => openDropdown(""));
mapInput.addEventListener("blur", () => window.setTimeout(() => closeDropdown(), 150));
mapInput.addEventListener("input", () => {
if (!isOpen) openDropdown(mapInput.value);
else populateDropdown(mapInput.value);
else {
populateDropdown(mapInput.value);
anchor?.reposition();
}
});
mapInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.key === "Escape") closeDropdown();

View file

@ -663,9 +663,6 @@ div.duckmage-hex-icon {
display: flex;
flex-direction: column;
}
.duckmage-editor-modal-drag .modal-content.duckmage-combo-open {
overflow-y: visible;
}
.duckmage-editor-title-drag {
cursor: move;
user-select: none;
@ -1082,10 +1079,11 @@ input.duckmage-input-error {
}
.duckmage-link-combo-dropdown {
position: absolute;
top: calc(100% + 2px);
left: 0;
right: 0;
/* position: fixed (anchored from JS via anchorDropdown) so the dropdown
escapes the modal's scroll container without that container needing
`overflow: visible` which would reset its scrollTop and jump the
modal to the top (issue #31). left/top/width are set inline. */
position: fixed;
z-index: 100;
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);