mirror of
https://github.com/sbuffkin/hexmaker.git
synced 2026-07-22 14:30:24 +00:00
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:
parent
852e178113
commit
a2dcfd2cfb
4 changed files with 139 additions and 121 deletions
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
12
styles.css
12
styles.css
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue