feat(dialogs): add movable Settings modal controls

Enhance supported Obsidian Settings and catalog dialogs with drag, resize, geometry persistence, preset controls, and cleanup on unload. Cover the pure geometry, settings, detector, pointer interaction, and enhancer behavior with Vitest.
This commit is contained in:
Jason Swartz 2026-06-15 12:27:04 -07:00
parent bc9cff7ce5
commit e625eb1059
14 changed files with 3389 additions and 0 deletions

156
src/drag-resize.ts Normal file
View file

@ -0,0 +1,156 @@
import { clampModalRect, type ModalRect } from "./geometry";
export type DragResizeMode = "drag" | "resize";
export interface DragResizeSessionOptions {
isEnabled: () => boolean;
handleEl: HTMLElement;
modalEl: HTMLElement;
mode: DragResizeMode;
canStart?: (target: Element) => boolean;
getCurrentRect: () => ModalRect;
getHostBounds: () => {
x: number;
y: number;
width: number;
height: number;
};
onUpdate: (rect: ModalRect) => void;
onCommit?: (rect: ModalRect) => void;
onStateChange?: (active: boolean) => void;
}
export interface DragResizeSession {
destroy(): void;
}
interface ActiveInteraction {
pointerId: number;
startX: number;
startY: number;
startRect: ModalRect;
}
export function createDragResizeSession(
options: DragResizeSessionOptions,
): DragResizeSession {
let active: ActiveInteraction | null = null;
const onPointerDown = (event: PointerEvent): void => {
if (!options.isEnabled() || event.button !== 0) {
return;
}
const target = event.target;
if (!(target instanceof options.modalEl.ownerDocument.defaultView!.Element)) {
return;
}
if (options.canStart && !options.canStart(target)) {
return;
}
if (options.mode === "resize" && !target.closest("[data-setmove-role='resize-handle']")) {
return;
}
active = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startRect: options.getCurrentRect(),
};
options.handleEl.setPointerCapture?.(event.pointerId);
options.modalEl.classList.add("setmove--is-interacting");
options.modalEl.classList.add(`setmove--is-${options.mode === "drag" ? "dragging" : "resizing"}`);
options.modalEl.style.userSelect = "none";
options.onStateChange?.(true);
event.preventDefault();
};
const onPointerMove = (event: PointerEvent): void => {
if (!active || event.pointerId !== active.pointerId) {
return;
}
const deltaX = event.clientX - active.startX;
const deltaY = event.clientY - active.startY;
const nextRect =
options.mode === "drag"
? {
...active.startRect,
x: active.startRect.x + deltaX,
y: active.startRect.y + deltaY,
}
: {
...active.startRect,
width: active.startRect.width + deltaX,
height: active.startRect.height + deltaY,
};
const clamped = clampModalRect(nextRect, options.getHostBounds());
options.onUpdate(clamped);
};
const onPointerUp = (event: PointerEvent): void => {
if (!active || event.pointerId !== active.pointerId) {
return;
}
const deltaX = event.clientX - active.startX;
const deltaY = event.clientY - active.startY;
const nextRect =
options.mode === "drag"
? {
...active.startRect,
x: active.startRect.x + deltaX,
y: active.startRect.y + deltaY,
}
: {
...active.startRect,
width: active.startRect.width + deltaX,
height: active.startRect.height + deltaY,
};
const clamped = clampModalRect(nextRect, options.getHostBounds());
options.onUpdate(clamped);
options.onCommit?.(clamped);
finishInteraction(event.pointerId);
};
const onPointerCancel = (event: PointerEvent): void => {
if (!active || event.pointerId !== active.pointerId) {
return;
}
options.onUpdate(active.startRect);
finishInteraction(event.pointerId);
};
const finishInteraction = (pointerId: number): void => {
options.handleEl.releasePointerCapture?.(pointerId);
options.modalEl.classList.remove("setmove--is-interacting");
options.modalEl.classList.remove("setmove--is-dragging", "setmove--is-resizing");
options.modalEl.style.userSelect = "";
options.onStateChange?.(false);
active = null;
};
options.handleEl.addEventListener("pointerdown", onPointerDown);
options.handleEl.addEventListener("pointermove", onPointerMove);
options.handleEl.addEventListener("pointerup", onPointerUp);
options.handleEl.addEventListener("pointercancel", onPointerCancel);
return {
destroy(): void {
if (active) {
finishInteraction(active.pointerId);
}
options.handleEl.removeEventListener("pointerdown", onPointerDown);
options.handleEl.removeEventListener("pointermove", onPointerMove);
options.handleEl.removeEventListener("pointerup", onPointerUp);
options.handleEl.removeEventListener("pointercancel", onPointerCancel);
},
};
}

232
src/geometry.ts Normal file
View file

@ -0,0 +1,232 @@
import type { PersistedGeometry } from "./settings";
export interface HostBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface ModalRect {
x: number;
y: number;
width: number;
height: number;
}
export interface Size {
width: number;
height: number;
}
export type GeometryPreset = "center" | "dock-left" | "dock-right" | "bottom-half";
// These minimums keep the Settings sidebar, content column, and footer controls usable.
export const MIN_MODAL_WIDTH = 640;
export const MIN_MODAL_HEIGHT = 420;
const HALF_HEIGHT_RATIO = 0.5;
export function measureHostBounds(viewport: Pick<Window, "innerWidth" | "innerHeight">): HostBounds {
return {
x: 0,
y: 0,
width: sanitizePositiveNumber(viewport.innerWidth) ?? 0,
height: sanitizePositiveNumber(viewport.innerHeight) ?? 0,
};
}
export function getMinimumModalSize(hostBounds: HostBounds): Size {
return {
width: Math.min(MIN_MODAL_WIDTH, Math.max(hostBounds.width, 0)),
height: Math.min(MIN_MODAL_HEIGHT, Math.max(hostBounds.height, 0)),
};
}
export function isUsableHostBounds(hostBounds: HostBounds): boolean {
return (
Number.isFinite(hostBounds.x) &&
Number.isFinite(hostBounds.y) &&
hostBounds.width > 0 &&
hostBounds.height > 0
);
}
export function clampModalRect(rect: ModalRect, hostBounds: HostBounds): ModalRect {
const normalizedHost = normalizeHostBounds(hostBounds);
const minimumSize = getMinimumModalSize(normalizedHost);
const width = clampDimension(rect.width, normalizedHost.width, minimumSize.width);
const height = clampDimension(rect.height, normalizedHost.height, minimumSize.height);
const maxX = normalizedHost.x + normalizedHost.width - width;
const maxY = normalizedHost.y + normalizedHost.height - height;
return {
x: clampNumber(rect.x, normalizedHost.x, maxX),
y: clampNumber(rect.y, normalizedHost.y, maxY),
width,
height,
};
}
export function getCenteredRect(
rect: Pick<ModalRect, "width" | "height">,
hostBounds: HostBounds,
): ModalRect {
const normalizedHost = normalizeHostBounds(hostBounds);
const clampedRect = clampModalRect(
{
x: normalizedHost.x,
y: normalizedHost.y,
width: rect.width,
height: rect.height,
},
normalizedHost,
);
return {
...clampedRect,
x: normalizedHost.x + (normalizedHost.width - clampedRect.width) / 2,
y: normalizedHost.y + (normalizedHost.height - clampedRect.height) / 2,
};
}
export function applyGeometryPreset(
preset: GeometryPreset,
currentRect: Pick<ModalRect, "width" | "height">,
hostBounds: HostBounds,
): ModalRect {
const normalizedHost = normalizeHostBounds(hostBounds);
const minimumSize = getMinimumModalSize(normalizedHost);
if (preset === "center") {
return getCenteredRect(currentRect, normalizedHost);
}
if (preset === "bottom-half") {
const height = clampDimension(
normalizedHost.height,
normalizedHost.height * HALF_HEIGHT_RATIO,
minimumSize.height,
);
return clampModalRect(
{
x: normalizedHost.x,
y: normalizedHost.y + normalizedHost.height - height,
width: normalizedHost.width,
height,
},
normalizedHost,
);
}
const width = minimumSize.width;
return clampModalRect(
{
x:
preset === "dock-right"
? normalizedHost.x + normalizedHost.width - width
: normalizedHost.x,
y: normalizedHost.y,
width,
height: normalizedHost.height,
},
normalizedHost,
);
}
export function getRestoredGeometry(
geometry: PersistedGeometry | null,
hostBounds: HostBounds,
): ModalRect | null {
if (geometry === null) {
return null;
}
if (!isUsableHostBounds(hostBounds)) {
return null;
}
if (
!isPersistedGeometryCompatible(geometry) ||
geometry.width < MIN_MODAL_WIDTH ||
geometry.height < MIN_MODAL_HEIGHT
) {
return null;
}
const maximumReasonableWidth = Math.max(
geometry.lastAppliedBounds.width,
hostBounds.width,
);
const maximumReasonableHeight = Math.max(
geometry.lastAppliedBounds.height,
hostBounds.height,
);
if (
geometry.width > maximumReasonableWidth ||
geometry.height > maximumReasonableHeight
) {
return null;
}
return clampModalRect(geometry, hostBounds);
}
function isPersistedGeometryCompatible(geometry: PersistedGeometry): boolean {
return (
isFiniteRect(geometry) &&
geometry.lastAppliedBounds.width > 0 &&
geometry.lastAppliedBounds.height > 0 &&
geometry.x >= 0 &&
geometry.y >= 0
);
}
function isFiniteRect(rect: Pick<ModalRect, "x" | "y" | "width" | "height">): boolean {
return (
Number.isFinite(rect.x) &&
Number.isFinite(rect.y) &&
Number.isFinite(rect.width) &&
Number.isFinite(rect.height)
);
}
function normalizeHostBounds(hostBounds: HostBounds): HostBounds {
return {
x: Number.isFinite(hostBounds.x) ? hostBounds.x : 0,
y: Number.isFinite(hostBounds.y) ? hostBounds.y : 0,
width: sanitizePositiveNumber(hostBounds.width) ?? 0,
height: sanitizePositiveNumber(hostBounds.height) ?? 0,
};
}
function clampDimension(
value: number,
available: number,
minimum: number,
): number {
const safeAvailable = sanitizePositiveNumber(available) ?? 0;
const safeMinimum = Math.min(sanitizePositiveNumber(minimum) ?? 0, safeAvailable);
const safeValue = sanitizePositiveNumber(value) ?? safeMinimum;
if (safeAvailable === 0) {
return 0;
}
return clampNumber(safeValue, safeMinimum, safeAvailable);
}
function clampNumber(value: number, minimum: number, maximum: number): number {
if (maximum < minimum) {
return minimum;
}
return Math.min(Math.max(value, minimum), maximum);
}
function sanitizePositiveNumber(value: number): number | null {
return Number.isFinite(value) && value > 0 ? value : null;
}

120
src/main.ts Normal file
View file

@ -0,0 +1,120 @@
import { Notice, Plugin, Platform } from "obsidian";
import { registerSettingsModalLifecycle } from "./modal-detector";
import {
enhanceSettingsModal,
type SettingsModalEnhancer,
type SettingsModalEnhancerOptions,
} from "./modal-enhancer";
import { SetmoveSettingTab } from "./settings-tab";
import {
DEFAULT_SETTINGS,
clearSavedGeometry,
loadSettings,
saveSettings,
type PersistedGeometry,
type SetmoveSettings,
} from "./settings";
export default class SetmovePlugin extends Plugin {
settings: SetmoveSettings = DEFAULT_SETTINGS;
private stopModalLifecycle: (() => void) | null = null;
private readonly activeEnhancers = new Set<SettingsModalEnhancer>();
async onload(): Promise<void> {
this.settings = await loadSettings(this);
await saveSettings(this, this.settings);
this.addSettingTab(new SetmoveSettingTab(this));
if (Platform.isMobile) {
new Notice("Setmove is disabled on mobile.");
return;
}
this.stopModalLifecycle = registerSettingsModalLifecycle(this.app.workspace, {
onAttach: (match) => {
const enhancer = enhanceSettingsModal(match, {
settings: this.getEnhancerSettings(match.kind),
onGeometryPersist: match.kind === "settings"
? async (geometry) => {
await this.persistGeometry(geometry);
}
: undefined,
onResetGeometry:
match.kind === "settings"
? async () => {
await this.resetSavedGeometryCommand();
}
: undefined,
});
this.activeEnhancers.add(enhancer);
return () => {
this.activeEnhancers.delete(enhancer);
enhancer.destroy();
};
},
});
this.register(() => {
this.stopModalLifecycle?.();
this.stopModalLifecycle = null;
});
console.log(`[${this.manifest.name}] loaded`);
}
onunload(): void {
this.stopModalLifecycle?.();
this.stopModalLifecycle = null;
console.log(`[${this.manifest.name}] unloaded`);
}
async updateSettings(partial: Partial<SetmoveSettings>): Promise<void> {
this.settings = {
...this.settings,
...partial,
};
await saveSettings(this, this.settings);
this.applySettingsToOpenEnhancers();
}
async resetSavedGeometryCommand(): Promise<void> {
this.settings = clearSavedGeometry(this.settings);
await saveSettings(this, this.settings);
const enhancer = this.getOpenEnhancer();
if (enhancer) {
enhancer.reset();
}
this.applySettingsToOpenEnhancers();
new Notice("Reset saved Settings window geometry.");
}
private async persistGeometry(geometry: PersistedGeometry): Promise<void> {
this.settings = {
...this.settings,
geometry,
};
await saveSettings(this, this.settings);
}
private applySettingsToOpenEnhancers(): void {
for (const enhancer of this.activeEnhancers) {
enhancer.updateSettings(this.getEnhancerSettings(enhancer.kind));
}
}
private getOpenEnhancer(): SettingsModalEnhancer | null {
return this.activeEnhancers.values().next().value ?? null;
}
private getEnhancerSettings(
kind: SettingsModalEnhancer["kind"],
): SettingsModalEnhancerOptions["settings"] {
return {
...this.settings,
geometry: kind === "settings" ? this.settings.geometry : null,
rememberGeometry: kind === "settings" && this.settings.rememberGeometry,
};
}
}

314
src/modal-detector.ts Normal file
View file

@ -0,0 +1,314 @@
import type { Workspace } from "obsidian";
export const SETTINGS_MODAL_SELECTORS = Object.freeze({
modal: ".modal.mod-settings",
modalContainer: ".modal-container",
closeButton: ".modal-close-button",
sidebar: ".vertical-tab-header",
content: ".vertical-tab-content-container",
anyModal: ".modal",
searchInput: "input[type='search'], input[type='text'], input:not([type])",
});
export type EnhancedModalKind = "settings" | "catalog";
export interface SettingsModalMatch {
modalEl: HTMLElement;
containerEl: HTMLElement;
contentEl: HTMLElement | null;
doc: Document;
kind: EnhancedModalKind;
win: Window;
}
export interface SettingsModalLifecycleCallbacks {
onAttach(match: SettingsModalMatch): void | (() => void);
onDetach?(match: SettingsModalMatch): void;
}
interface AttachedModalRecord {
cleanup?: () => void;
match: SettingsModalMatch;
}
export class SettingsModalLifecycle {
private readonly observers = new Map<Document, MutationObserver>();
private readonly attached = new Map<HTMLElement, AttachedModalRecord>();
constructor(private readonly callbacks: SettingsModalLifecycleCallbacks) {}
trackDocument(doc: Document): void {
if (this.observers.has(doc)) {
this.scanDocument(doc);
return;
}
const root = doc.body ?? doc.documentElement;
if (!root) {
return;
}
const ViewMutationObserver = doc.defaultView?.MutationObserver ?? MutationObserver;
const observer = new ViewMutationObserver(() => {
this.scanDocument(doc);
});
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["class", "placeholder", "type"],
});
this.observers.set(doc, observer);
this.scanDocument(doc);
}
untrackDocument(doc: Document): void {
const observer = this.observers.get(doc);
observer?.disconnect();
this.observers.delete(doc);
for (const [modalEl, record] of this.attached) {
if (record.match.doc === doc) {
this.detachModal(modalEl, record);
}
}
}
stop(): void {
for (const observer of this.observers.values()) {
observer.disconnect();
}
this.observers.clear();
for (const [modalEl, record] of this.attached) {
this.detachModal(modalEl, record);
}
}
private scanDocument(doc: Document): void {
const matches = findSettingsModals(doc);
const activeModals = new Set(matches.map((match) => match.modalEl));
for (const match of matches) {
if (this.attached.has(match.modalEl)) {
continue;
}
const cleanup = this.callbacks.onAttach(match) ?? undefined;
this.attached.set(match.modalEl, { cleanup, match });
}
for (const [modalEl, record] of this.attached) {
if (record.match.doc !== doc) {
continue;
}
if (!modalEl.isConnected || !activeModals.has(modalEl)) {
this.detachModal(modalEl, record);
}
}
}
private detachModal(
modalEl: HTMLElement,
record: AttachedModalRecord,
): void {
this.attached.delete(modalEl);
record.cleanup?.();
this.callbacks.onDetach?.(record.match);
}
}
export function registerSettingsModalLifecycle(
workspace: Workspace,
callbacks: SettingsModalLifecycleCallbacks,
): () => void {
const lifecycle = new SettingsModalLifecycle(callbacks);
lifecycle.trackDocument(window.document);
const openRef = workspace.on("window-open", (workspaceWindow, popoutWindow) => {
lifecycle.trackDocument(workspaceWindow.doc ?? popoutWindow.document);
});
const closeRef = workspace.on(
"window-close",
(workspaceWindow, popoutWindow) => {
lifecycle.untrackDocument(workspaceWindow.doc ?? popoutWindow.document);
},
);
return () => {
workspace.offref(openRef);
workspace.offref(closeRef);
lifecycle.stop();
};
}
export function findSettingsModal(root: ParentNode): SettingsModalMatch | null {
return findSettingsModals(root)[0] ?? null;
}
export function findSettingsModals(root: ParentNode): SettingsModalMatch[] {
const matches: SettingsModalMatch[] = [];
const modalEls = Array.from(
root.querySelectorAll<HTMLElement>(SETTINGS_MODAL_SELECTORS.anyModal),
);
for (const modalEl of modalEls) {
const match = toEnhancedModalMatch(modalEl);
if (match) {
matches.push(match);
}
}
return matches;
}
export function isSettingsModalElement(element: Element | null): element is HTMLElement {
return toSettingsModalMatch(element) !== null;
}
function toSettingsModalMatch(element: Element | null): SettingsModalMatch | null {
if (!isHTMLElement(element)) {
return null;
}
if (!element.matches(SETTINGS_MODAL_SELECTORS.modal)) {
return null;
}
const containerEl = element.closest<HTMLElement>(
SETTINGS_MODAL_SELECTORS.modalContainer,
);
if (!containerEl) {
return null;
}
const contentEl = element.querySelector(SETTINGS_MODAL_SELECTORS.content);
const hasRequiredStructure =
isHTMLElement(element.querySelector(SETTINGS_MODAL_SELECTORS.sidebar)) &&
isHTMLElement(contentEl) &&
isHTMLElement(element.querySelector(SETTINGS_MODAL_SELECTORS.closeButton));
if (!hasRequiredStructure) {
return null;
}
const doc = element.ownerDocument;
const win = doc.defaultView;
if (!win) {
return null;
}
return {
modalEl: element,
containerEl,
contentEl,
doc,
kind: "settings",
win,
};
}
function toEnhancedModalMatch(element: Element | null): SettingsModalMatch | null {
return toSettingsModalMatch(element) ?? toCatalogModalMatch(element);
}
function toCatalogModalMatch(element: Element | null): SettingsModalMatch | null {
if (!isHTMLElement(element)) {
return null;
}
if (!element.matches(SETTINGS_MODAL_SELECTORS.anyModal)) {
return null;
}
if (element.matches(SETTINGS_MODAL_SELECTORS.modal)) {
return null;
}
const containerEl = element.closest<HTMLElement>(
SETTINGS_MODAL_SELECTORS.modalContainer,
);
if (!containerEl) {
return null;
}
if (!isHTMLElement(element.querySelector(SETTINGS_MODAL_SELECTORS.closeButton))) {
return null;
}
if (!looksLikeCatalogBrowser(element)) {
return null;
}
const doc = element.ownerDocument;
const win = doc.defaultView;
if (!win) {
return null;
}
return {
modalEl: element,
containerEl,
contentEl: null,
doc,
kind: "catalog",
win,
};
}
function looksLikeCatalogBrowser(modalEl: HTMLElement): boolean {
const text = modalEl.textContent ?? "";
const hasCatalogCount = /\bShowing\s+[\d,]+\s+(themes|plugins)\b/i.test(text);
const hasCatalogControls =
/\bShow installed only\b/i.test(text) &&
(/\bLight themes only\b/i.test(text) ||
/\bthemes\b/i.test(text) ||
/\bplugins\b/i.test(text));
const hasCatalogResults = Boolean(
modalEl.querySelector(
[
".community-modal-search-results",
".community-item",
".community-item-name",
".theme-card",
".theme-list",
].join(", "),
),
);
const hasSearchInput = Array.from(
modalEl.querySelectorAll<HTMLInputElement>(SETTINGS_MODAL_SELECTORS.searchInput),
).some((input) => {
const placeholder = input.getAttribute("placeholder") ?? "";
return /filter|search community plugins|search/i.test(placeholder);
});
return hasSearchInput && (hasCatalogCount || hasCatalogControls || hasCatalogResults);
}
function isHTMLElement(value: unknown): value is HTMLElement {
if (
typeof value !== "object" ||
value === null ||
!("ownerDocument" in value) ||
!("nodeType" in value) ||
value.nodeType !== Node.ELEMENT_NODE
) {
return false;
}
const element = value as {
ownerDocument: Document;
};
const view = element.ownerDocument.defaultView;
return Boolean(view && value instanceof view.HTMLElement);
}

535
src/modal-enhancer.ts Normal file
View file

@ -0,0 +1,535 @@
import { createDragResizeSession, type DragResizeSession } from "./drag-resize";
import {
applyGeometryPreset,
clampModalRect,
getCenteredRect,
getRestoredGeometry,
measureHostBounds,
type GeometryPreset,
type ModalRect,
} from "./geometry";
import type { SettingsModalMatch } from "./modal-detector";
import type { PersistedGeometry, SetmoveSettings } from "./settings";
const ENHANCED_CLASS = "setmove--settings-modal";
const HANDLE_CLASS = "setmove--drag-handle";
const RESIZE_HANDLE_CLASS = "setmove--resize-handle";
const CONTENT_CLASS = "setmove--settings-content";
const NON_DRAGGABLE_SELECTOR = [
"input",
"textarea",
"select",
"button",
"a[href]",
"[contenteditable='true']",
".setting-item",
".setting-item-control",
".vertical-tab-nav-item",
".clickable-icon",
".slider",
".dropdown",
"[data-setmove-role='resize-handle']",
"[data-setmove-role='preset-control']",
].join(", ");
const CATALOG_NON_DRAGGABLE_SELECTOR = [
".community-item",
".community-modal-search-results",
".community-modal-details",
".theme-card",
".theme-list",
].join(", ");
const ENHANCEABLE_MODAL_SELECTOR = ".modal.mod-settings, .modal.setmove--settings-modal";
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
const PRESET_ICON_COLOR = "#777799";
type IconDefinition = {
fillPaths?: string[];
fillRects?: Array<{ height: number; width: number; x: number; y: number }>;
paths?: string[];
};
const ICON_DEFINITIONS = {
center: {
fillRects: [{ x: 8, y: 7, width: 8, height: 10 }],
paths: ["M4 5h16v14H4z"],
},
left: {
fillRects: [{ x: 4, y: 5, width: 5, height: 14 }],
paths: ["M4 5h16v14H4z", "M9 5v14"],
},
right: {
fillRects: [{ x: 15, y: 5, width: 5, height: 14 }],
paths: ["M4 5h16v14H4z", "M15 5v14"],
},
reset: {
fillPaths: [
"M6.7 7.4A8.6 8.6 0 1 1 5 16.6l2.3-1.3a5.9 5.9 0 1 0 1-6.3l2.1 2.1H4.2V4.9z",
],
},
} as const satisfies Record<string, IconDefinition>;
type PresetControlsPlacement =
| "top-right-horizontal"
| "bottom-right-horizontal"
| "bottom-left-vertical";
const VISIBLE_PRESET_CONTROLS_PLACEMENTS = new Set<PresetControlsPlacement>([
"bottom-right-horizontal",
]);
const ENHANCER_BY_MODAL = new WeakMap<HTMLElement, SettingsModalEnhancer>();
interface InlineStyleSnapshot {
height: string;
left: string;
maxHeight: string;
maxWidth: string;
position: string;
top: string;
width: string;
}
export interface SettingsModalEnhancerOptions {
settings: Pick<
SetmoveSettings,
"movable" | "resizable" | "rememberGeometry" | "geometry"
>;
onGeometryPersist?: (geometry: PersistedGeometry) => void | Promise<void>;
onResetGeometry?: () => void | Promise<void>;
}
export class SettingsModalEnhancer {
readonly modalEl: HTMLElement;
readonly containerEl: HTMLElement;
readonly doc: Document;
readonly kind: SettingsModalMatch["kind"];
readonly win: Window;
private readonly contentEl: HTMLElement | null;
private readonly dragHandleEl: HTMLDivElement;
private readonly defaultSize: Pick<ModalRect, "width" | "height">;
private readonly presetControlsEls: HTMLDivElement[];
private readonly resizeHandleEl: HTMLButtonElement;
private readonly styleSnapshot: InlineStyleSnapshot;
private readonly dragSession: DragResizeSession;
private readonly resizeSession: DragResizeSession;
private readonly onWindowResize = (): void => {
if (!this.enabled || this.currentRect === null) {
return;
}
this.currentRect = clampModalRect(this.currentRect, this.getHostBounds());
this.applyRect(this.currentRect);
};
private settings: SettingsModalEnhancerOptions["settings"];
private readonly onGeometryPersist?: SettingsModalEnhancerOptions["onGeometryPersist"];
private readonly onResetGeometry?: SettingsModalEnhancerOptions["onResetGeometry"];
private enabled = true;
private currentRect: ModalRect | null = null;
constructor(match: SettingsModalMatch, options: SettingsModalEnhancerOptions) {
this.modalEl = match.modalEl;
this.containerEl = match.containerEl;
this.doc = match.doc;
this.kind = match.kind;
this.win = match.win;
this.settings = { ...options.settings };
this.onGeometryPersist = options.onGeometryPersist;
this.onResetGeometry = options.onResetGeometry;
this.contentEl = match.contentEl;
this.dragHandleEl = this.createDragHandle();
this.defaultSize = this.measureModalRect();
this.presetControlsEls = [
this.createPresetControls("top-right-horizontal"),
this.createPresetControls("bottom-right-horizontal"),
this.createPresetControls("bottom-left-vertical"),
];
this.resizeHandleEl = this.createResizeHandle();
this.styleSnapshot = this.captureInlineStyles();
this.dragSession = this.createSession("drag", this.dragHandleEl);
this.resizeSession = this.createSession("resize", this.resizeHandleEl);
this.attach();
}
center(): ModalRect {
const nextRect = getCenteredRect(this.getCurrentSize(), this.getHostBounds());
this.currentRect = nextRect;
this.applyRect(nextRect);
return nextRect;
}
reset(): ModalRect {
const nextRect = getCenteredRect(this.defaultSize, this.getHostBounds());
this.currentRect = nextRect;
this.applyRect(nextRect);
return nextRect;
}
dockLeft(): ModalRect {
return this.applyPreset("dock-left");
}
dockRight(): ModalRect {
return this.applyPreset("dock-right");
}
async persistCurrentGeometry(): Promise<void> {
if (this.currentRect) {
await this.persistGeometry(this.currentRect);
}
}
toggleEnabled(force?: boolean): boolean {
this.enabled = force ?? !this.enabled;
this.modalEl.classList.toggle(`${ENHANCED_CLASS}--disabled`, !this.enabled);
this.syncHandleState(
this.dragHandleEl,
this.enabled && this.settings.movable,
"move",
);
this.syncHandleState(
this.resizeHandleEl,
this.enabled && this.settings.resizable,
"nwse-resize",
);
for (const controlsEl of this.presetControlsEls) {
const isVisible =
this.enabled &&
VISIBLE_PRESET_CONTROLS_PLACEMENTS.has(
controlsEl.dataset.setmovePlacement as PresetControlsPlacement,
);
controlsEl.hidden = !isVisible;
controlsEl.style.display = isVisible ? "" : "none";
}
if (this.enabled) {
this.currentRect = this.currentRect ?? this.resolveInitialRect();
this.applyRect(this.currentRect);
} else {
this.restoreInlineStyles();
}
return this.enabled;
}
updateSettings(
settings: Partial<SettingsModalEnhancerOptions["settings"]>,
): void {
this.settings = {
...this.settings,
...settings,
};
this.toggleEnabled(this.enabled);
}
destroy(): void {
this.win.removeEventListener("resize", this.onWindowResize);
this.dragSession.destroy();
this.resizeSession.destroy();
ENHANCER_BY_MODAL.delete(this.modalEl);
this.dragHandleEl.remove();
for (const controlsEl of this.presetControlsEls) {
controlsEl.remove();
}
this.resizeHandleEl.remove();
this.modalEl.classList.remove(ENHANCED_CLASS, `${ENHANCED_CLASS}--disabled`);
if (this.contentEl) {
this.contentEl.classList.remove(CONTENT_CLASS);
this.contentEl.style.minHeight = "";
this.contentEl.style.overflow = "";
}
this.restoreInlineStyles();
}
private attach(): void {
this.modalEl.classList.add(ENHANCED_CLASS);
if (this.contentEl) {
this.contentEl.classList.add(CONTENT_CLASS);
this.contentEl.style.minHeight = "0";
this.contentEl.style.overflow = "auto";
}
this.modalEl.prepend(this.dragHandleEl);
this.modalEl.append(...this.presetControlsEls);
this.modalEl.append(this.resizeHandleEl);
this.currentRect = this.resolveInitialRect();
this.applyRect(this.currentRect);
this.toggleEnabled(true);
this.win.addEventListener("resize", this.onWindowResize);
}
private applyPreset(preset: GeometryPreset): ModalRect {
const nextRect = applyGeometryPreset(
preset,
this.getCurrentSize(),
this.getHostBounds(),
);
this.currentRect = nextRect;
this.applyRect(nextRect);
return nextRect;
}
private applyRect(rect: ModalRect): void {
this.currentRect = rect;
this.modalEl.style.position = "fixed";
this.modalEl.style.left = `${rect.x}px`;
this.modalEl.style.top = `${rect.y}px`;
this.modalEl.style.width = `${rect.width}px`;
this.modalEl.style.height = `${rect.height}px`;
this.modalEl.style.maxWidth = "none";
this.modalEl.style.maxHeight = "none";
}
private resolveInitialRect(): ModalRect {
if (this.settings.rememberGeometry) {
const restored = getRestoredGeometry(this.settings.geometry, this.getHostBounds());
if (restored) {
return restored;
}
}
return getCenteredRect(this.measureModalRect(), this.getHostBounds());
}
private getCurrentSize(): Pick<ModalRect, "width" | "height"> {
return this.currentRect ?? this.measureModalRect();
}
private getCurrentRect(): ModalRect {
return this.currentRect
? { ...this.currentRect }
: {
...this.measureModalRect(),
x: this.modalEl.offsetLeft || 0,
y: this.modalEl.offsetTop || 0,
};
}
private measureModalRect(): Pick<ModalRect, "width" | "height"> {
const rect = this.modalEl.getBoundingClientRect();
return {
width: rect.width || this.modalEl.offsetWidth || 900,
height: rect.height || this.modalEl.offsetHeight || 640,
};
}
private getHostBounds() {
return measureHostBounds(this.win);
}
private createDragHandle(): HTMLDivElement {
const handle = this.doc.createElement("div");
handle.className = HANDLE_CLASS;
handle.dataset.setmoveRole = "drag-handle";
handle.setAttribute("role", "presentation");
return handle;
}
private createPresetControls(
placement: PresetControlsPlacement,
): HTMLDivElement {
const controls = this.doc.createElement("div");
controls.className = `setmove--preset-controls setmove--preset-controls-${placement}`;
controls.dataset.setmoveRole = "preset-controls";
controls.dataset.setmovePlacement = placement;
this.addPresetButton(controls, "Dock window left", "left", async () => {
this.dockLeft();
await this.persistCurrentGeometry();
});
this.addPresetButton(controls, "Dock window right", "right", async () => {
this.dockRight();
await this.persistCurrentGeometry();
});
this.addPresetButton(controls, "Reset window geometry", "reset", async () => {
if (this.onResetGeometry) {
await this.onResetGeometry();
return;
}
this.reset();
await this.persistCurrentGeometry();
});
return controls;
}
private addPresetButton(
controls: HTMLElement,
label: string,
icon: keyof typeof ICON_DEFINITIONS,
onClick: () => void | Promise<void>,
): void {
const button = this.doc.createElement("button");
button.type = "button";
button.className = "setmove--preset-control";
button.dataset.setmoveRole = "preset-control";
button.setAttribute("aria-label", label);
button.append(this.createIcon(icon));
button.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
void onClick();
});
controls.append(button);
}
private createIcon(icon: keyof typeof ICON_DEFINITIONS): SVGSVGElement {
const iconDefinition: IconDefinition = ICON_DEFINITIONS[icon];
const svg = this.doc.createElementNS(SVG_NAMESPACE, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("aria-hidden", "true");
for (const rectData of iconDefinition.fillRects ?? []) {
const rect = this.doc.createElementNS(SVG_NAMESPACE, "rect");
rect.setAttribute("x", String(rectData.x));
rect.setAttribute("y", String(rectData.y));
rect.setAttribute("width", String(rectData.width));
rect.setAttribute("height", String(rectData.height));
rect.setAttribute("rx", "0.75");
rect.setAttribute("fill", PRESET_ICON_COLOR);
rect.setAttribute("opacity", "0.85");
svg.append(rect);
}
for (const pathData of iconDefinition.fillPaths ?? []) {
const path = this.doc.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("d", pathData);
path.setAttribute("fill", PRESET_ICON_COLOR);
svg.append(path);
}
for (const pathData of iconDefinition.paths ?? []) {
const path = this.doc.createElementNS(SVG_NAMESPACE, "path");
path.setAttribute("d", pathData);
path.setAttribute("fill", "none");
path.setAttribute("stroke", PRESET_ICON_COLOR);
path.setAttribute("stroke-width", "1.8");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
svg.append(path);
}
return svg;
}
private createResizeHandle(): HTMLButtonElement {
const handle = this.doc.createElement("button");
handle.type = "button";
handle.className = RESIZE_HANDLE_CLASS;
handle.dataset.setmoveRole = "resize-handle";
handle.setAttribute("aria-label", "Resize settings window");
return handle;
}
private syncHandleState(
handleEl: HTMLElement,
isVisible: boolean,
cursor: string,
): void {
handleEl.hidden = !isVisible;
handleEl.style.display = isVisible ? "" : "none";
handleEl.style.pointerEvents = isVisible ? "" : "none";
handleEl.style.cursor = isVisible ? cursor : "default";
}
private createSession(
mode: "drag" | "resize",
handleEl: HTMLElement,
): DragResizeSession {
const interactionEl = mode === "drag" ? this.modalEl : handleEl;
return createDragResizeSession({
isEnabled: () =>
this.enabled &&
(mode === "drag" ? this.settings.movable : this.settings.resizable),
handleEl: interactionEl,
modalEl: this.modalEl,
mode,
canStart: (target) =>
mode === "drag" ? this.canStartDragFrom(target) : this.canStartResizeFrom(target),
getCurrentRect: () => this.getCurrentRect(),
getHostBounds: () => this.getHostBounds(),
onUpdate: (rect) => {
this.currentRect = rect;
this.applyRect(rect);
},
onCommit: (rect) => {
this.currentRect = rect;
void this.persistGeometry(rect);
},
});
}
private canStartDragFrom(target: Element): boolean {
if (target.closest("[data-setmove-role='drag-handle']")) {
return true;
}
if (target.closest(NON_DRAGGABLE_SELECTOR)) {
return false;
}
if (this.kind === "catalog" && target.closest(CATALOG_NON_DRAGGABLE_SELECTOR)) {
return false;
}
return target.closest(ENHANCEABLE_MODAL_SELECTOR) === this.modalEl;
}
private canStartResizeFrom(target: Element): boolean {
return Boolean(target.closest("[data-setmove-role='resize-handle']"));
}
private async persistGeometry(rect: ModalRect): Promise<void> {
if (!this.settings.rememberGeometry || !this.onGeometryPersist) {
return;
}
await this.onGeometryPersist({
schemaVersion: 1,
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
lastAppliedBounds: this.getHostBounds(),
});
}
private captureInlineStyles(): InlineStyleSnapshot {
return {
height: this.modalEl.style.height,
left: this.modalEl.style.left,
maxHeight: this.modalEl.style.maxHeight,
maxWidth: this.modalEl.style.maxWidth,
position: this.modalEl.style.position,
top: this.modalEl.style.top,
width: this.modalEl.style.width,
};
}
private restoreInlineStyles(): void {
this.modalEl.style.height = this.styleSnapshot.height;
this.modalEl.style.left = this.styleSnapshot.left;
this.modalEl.style.maxHeight = this.styleSnapshot.maxHeight;
this.modalEl.style.maxWidth = this.styleSnapshot.maxWidth;
this.modalEl.style.position = this.styleSnapshot.position;
this.modalEl.style.top = this.styleSnapshot.top;
this.modalEl.style.width = this.styleSnapshot.width;
}
}
export function enhanceSettingsModal(
match: SettingsModalMatch,
options: SettingsModalEnhancerOptions,
): SettingsModalEnhancer {
const existing = ENHANCER_BY_MODAL.get(match.modalEl);
if (existing) {
existing.updateSettings(options.settings);
return existing;
}
const enhancer = new SettingsModalEnhancer(match, options);
ENHANCER_BY_MODAL.set(match.modalEl, enhancer);
return enhancer;
}

90
src/settings-tab.ts Normal file
View file

@ -0,0 +1,90 @@
import { PluginSettingTab, Setting } from "obsidian";
import type SetmovePlugin from "./main";
export class SetmoveSettingTab extends PluginSettingTab {
private readonly pluginRef: SetmovePlugin;
constructor(plugin: SetmovePlugin) {
super(plugin.app, plugin);
this.pluginRef = plugin;
}
display(): void {
const { containerEl } = this;
const plugin = this.pluginRef;
containerEl.empty();
containerEl.createEl("h2", {
cls: "setmove--settings-title",
text: "Settings Float",
});
containerEl.createEl("p", {
cls: "setmove--settings-description",
text: "Move and resize Obsidian Settings and catalog dialogs so you can adjust options while keeping your notes and workspace visible.",
});
new Setting(containerEl)
.setName("Enable movable dialogs")
.setDesc("Allow supported Settings and catalog dialogs to be dragged from safe empty space.")
.addToggle((toggle) =>
toggle
.setValue(plugin.settings.movable)
.onChange(async (value) => {
await plugin.updateSettings({ movable: value });
}),
);
new Setting(containerEl)
.setName("Enable resizable dialogs")
.setDesc("Allow supported Settings and catalog dialogs to be resized from the bottom-right handle.")
.addToggle((toggle) =>
toggle
.setValue(plugin.settings.resizable)
.onChange(async (value) => {
await plugin.updateSettings({ resizable: value });
}),
);
new Setting(containerEl)
.setName("Remember window geometry")
.setDesc("Persist the last valid Settings position and size for this vault.")
.addToggle((toggle) =>
toggle
.setValue(plugin.settings.rememberGeometry)
.onChange(async (value) => {
await plugin.updateSettings({ rememberGeometry: value });
}),
);
new Setting(containerEl)
.setName("Disable on narrow windows")
.setDesc("Keep the plugin conservative on cramped desktop windows.")
.addToggle((toggle) =>
toggle
.setValue(plugin.settings.disableOnNarrowWindows)
.onChange(async (value) => {
await plugin.updateSettings({ disableOnNarrowWindows: value });
}),
);
new Setting(containerEl)
.setName("Disable on mobile")
.setDesc("Leave mobile behavior as a no-op for this release.")
.addToggle((toggle) =>
toggle
.setValue(plugin.settings.disableOnMobile)
.onChange(async (value) => {
await plugin.updateSettings({ disableOnMobile: value });
}),
);
new Setting(containerEl)
.setName("Reset saved geometry")
.setDesc("Forget the saved position and size so Settings reopens with the default layout.")
.addButton((button) =>
button.setButtonText("Reset").onClick(async () => {
await plugin.resetSavedGeometryCommand();
}),
);
}
}

217
src/settings.ts Normal file
View file

@ -0,0 +1,217 @@
export const SETTINGS_SCHEMA_VERSION = 1;
export const GEOMETRY_SCHEMA_VERSION = 1;
export interface GeometryBoundsMetadata {
width: number;
height: number;
}
export interface PersistedGeometry {
schemaVersion: typeof GEOMETRY_SCHEMA_VERSION;
x: number;
y: number;
width: number;
height: number;
lastAppliedBounds: GeometryBoundsMetadata;
}
export interface SetmoveSettings {
schemaVersion: typeof SETTINGS_SCHEMA_VERSION;
movable: boolean;
resizable: boolean;
rememberGeometry: boolean;
showPresetControls: boolean;
disableOnMobile: boolean;
disableOnNarrowWindows: boolean;
narrowWindowThreshold: number;
geometry: PersistedGeometry | null;
}
export interface PluginDataStore {
loadData(): Promise<unknown>;
saveData(data: SetmoveSettings): Promise<void>;
}
const DEFAULT_NARROW_WINDOW_THRESHOLD = 720;
export const DEFAULT_SETTINGS: SetmoveSettings = Object.freeze({
schemaVersion: SETTINGS_SCHEMA_VERSION,
movable: true,
resizable: true,
rememberGeometry: true,
showPresetControls: true,
disableOnMobile: true,
disableOnNarrowWindows: true,
narrowWindowThreshold: DEFAULT_NARROW_WINDOW_THRESHOLD,
geometry: null,
});
type RecordLike = Record<string, unknown>;
export function migrateSettingsData(data: unknown): SetmoveSettings {
if (!isRecord(data)) {
return cloneSettings(DEFAULT_SETTINGS);
}
return {
schemaVersion: SETTINGS_SCHEMA_VERSION,
movable: readBoolean(data.movable, DEFAULT_SETTINGS.movable),
resizable: readBoolean(data.resizable, DEFAULT_SETTINGS.resizable),
rememberGeometry: readBoolean(
data.rememberGeometry,
DEFAULT_SETTINGS.rememberGeometry,
),
showPresetControls: readBoolean(
data.showPresetControls,
DEFAULT_SETTINGS.showPresetControls,
),
disableOnMobile: readBoolean(
data.disableOnMobile,
DEFAULT_SETTINGS.disableOnMobile,
),
disableOnNarrowWindows: readBoolean(
data.disableOnNarrowWindows,
DEFAULT_SETTINGS.disableOnNarrowWindows,
),
narrowWindowThreshold: readPositiveNumber(
data.narrowWindowThreshold,
DEFAULT_SETTINGS.narrowWindowThreshold,
),
geometry: parseGeometry(data.geometry),
};
}
export async function loadSettings(
dataStore: PluginDataStore,
): Promise<SetmoveSettings> {
return migrateSettingsData(await dataStore.loadData());
}
export async function saveSettings(
dataStore: PluginDataStore,
settings: SetmoveSettings,
): Promise<void> {
await dataStore.saveData(serializeSettings(settings));
}
export function clearSavedGeometry(settings: SetmoveSettings): SetmoveSettings {
return {
...serializeSettings(settings),
geometry: null,
};
}
export async function resetSavedGeometry(
dataStore: PluginDataStore,
settings: SetmoveSettings,
): Promise<SetmoveSettings> {
const nextSettings = clearSavedGeometry(settings);
await saveSettings(dataStore, nextSettings);
return nextSettings;
}
export function serializeSettings(settings: SetmoveSettings): SetmoveSettings {
return {
schemaVersion: SETTINGS_SCHEMA_VERSION,
movable: settings.movable,
resizable: settings.resizable,
rememberGeometry: settings.rememberGeometry,
showPresetControls: settings.showPresetControls,
disableOnMobile: settings.disableOnMobile,
disableOnNarrowWindows: settings.disableOnNarrowWindows,
narrowWindowThreshold: settings.narrowWindowThreshold,
geometry: settings.geometry ? cloneGeometry(settings.geometry) : null,
};
}
function parseGeometry(value: unknown): PersistedGeometry | null {
if (!isRecord(value)) {
return null;
}
const x = readFiniteNumber(value.x);
const y = readFiniteNumber(value.y);
const width = readPositiveNumber(value.width);
const height = readPositiveNumber(value.height);
const lastAppliedBounds = parseBoundsMetadata(value.lastAppliedBounds);
if (
x === null ||
y === null ||
width === null ||
height === null ||
lastAppliedBounds === null
) {
return null;
}
return {
schemaVersion: GEOMETRY_SCHEMA_VERSION,
x,
y,
width,
height,
lastAppliedBounds,
};
}
function parseBoundsMetadata(value: unknown): GeometryBoundsMetadata | null {
if (!isRecord(value)) {
return null;
}
const width = readPositiveNumber(value.width);
const height = readPositiveNumber(value.height);
if (width === null || height === null) {
return null;
}
return { width, height };
}
function cloneSettings(settings: SetmoveSettings): SetmoveSettings {
return serializeSettings(settings);
}
function cloneGeometry(geometry: PersistedGeometry): PersistedGeometry {
return {
schemaVersion: GEOMETRY_SCHEMA_VERSION,
x: geometry.x,
y: geometry.y,
width: geometry.width,
height: geometry.height,
lastAppliedBounds: {
width: geometry.lastAppliedBounds.width,
height: geometry.lastAppliedBounds.height,
},
};
}
function isRecord(value: unknown): value is RecordLike {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readBoolean(value: unknown, fallback: boolean): boolean {
return typeof value === "boolean" ? value : fallback;
}
function readFiniteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readPositiveNumber(value: unknown, fallback: number): number;
function readPositiveNumber(value: unknown): number | null;
function readPositiveNumber(
value: unknown,
fallback?: number,
): number | null {
const numberValue = readFiniteNumber(value);
if (numberValue !== null && numberValue > 0) {
return numberValue;
}
return fallback ?? null;
}

185
styles.css Normal file
View file

@ -0,0 +1,185 @@
.vertical-tab-content .setmove--settings-title {
margin-left: 0;
margin-inline-start: 0;
padding-left: 0;
padding-inline-start: 0;
}
.setmove--settings-description {
max-width: 640px;
margin: 0 0 1.5em;
color: var(--text-muted);
line-height: var(--line-height-normal);
}
.setmove--settings-modal {
position: relative;
overflow: visible;
border: 1px solid var(--background-modifier-border);
box-shadow: var(--shadow-l);
cursor: grab;
}
.setmove--settings-modal .setmove--settings-content {
min-height: 0;
overflow: auto;
}
.setmove--drag-handle {
display: none;
}
.setmove--drag-handle:hover {
display: none;
}
.setmove--resize-handle {
position: absolute;
right: -1px;
bottom: -1px;
display: grid;
width: 24px;
height: 24px;
place-items: center;
padding: 0;
border: 1px solid var(--background-modifier-border);
border-right: 0;
border-bottom: 0;
border-top-left-radius: 10px;
border-top-right-radius: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: inherit;
background: linear-gradient(135deg,
color-mix(in srgb, var(--background-secondary) 82%, transparent) 0%,
var(--background-secondary) 72%);
box-shadow: none;
color: #b8b8b8;
cursor: nwse-resize;
z-index: 2;
}
.setmove--resize-handle::before {
content: "";
width: 12px;
height: 12px;
background-color: currentColor;
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cg stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Cline x1='1.5' y1='10.5' x2='10.5' y2='1.5'/%3E%3Cline x1='5' y1='10.5' x2='10.5' y2='5'/%3E%3Cline x1='8' y1='10.5' x2='10.5' y2='8'/%3E%3C/g%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'%3E%3Cg stroke='black' stroke-width='1.4' stroke-linecap='round'%3E%3Cline x1='1.5' y1='10.5' x2='10.5' y2='1.5'/%3E%3Cline x1='5' y1='10.5' x2='10.5' y2='5'/%3E%3Cline x1='8' y1='10.5' x2='10.5' y2='8'/%3E%3C/g%3E%3C/svg%3E");
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 12px 12px;
mask-size: 12px 12px;
opacity: 0.85;
}
.setmove--resize-handle:hover {
border-color: var(--interactive-accent-hover);
background: linear-gradient(135deg,
color-mix(in srgb, var(--background-secondary-alt) 70%, var(--interactive-accent-hover) 30%) 0%,
var(--background-secondary-alt) 72%);
color: #a9a9a9;
}
.setmove--settings-modal.setmove--is-interacting {
box-shadow: 0 0 0 1px var(--interactive-accent), var(--shadow-l);
}
.setmove--settings-modal.setmove--is-dragging,
.setmove--settings-modal.setmove--is-dragging * {
cursor: grabbing;
}
.setmove--settings-modal.setmove--is-resizing,
.setmove--settings-modal.setmove--is-resizing * {
cursor: nwse-resize;
}
.setmove--settings-modal input,
.setmove--settings-modal textarea,
.setmove--settings-modal select,
.setmove--settings-modal button,
.setmove--settings-modal a[href],
.setmove--settings-modal [contenteditable="true"],
.setmove--settings-modal .setting-item,
.setmove--settings-modal .setting-item-control,
.setmove--settings-modal .vertical-tab-nav-item,
.setmove--settings-modal .clickable-icon,
.setmove--settings-modal .slider,
.setmove--settings-modal .dropdown {
cursor: auto;
}
.setmove--settings-modal button,
.setmove--settings-modal a[href],
.setmove--settings-modal .clickable-icon,
.setmove--settings-modal .vertical-tab-nav-item {
cursor: pointer;
}
.setmove--settings-modal .setmove--resize-handle {
cursor: nwse-resize;
}
.setmove--preset-controls {
position: absolute;
z-index: 3;
display: flex;
gap: 4px;
align-items: center;
padding: 3px;
border: 1px solid var(--background-modifier-border);
border-radius: 8px;
background: color-mix(in srgb, var(--background-secondary) 92%, transparent);
box-shadow: var(--shadow-s);
opacity: 0;
pointer-events: auto;
transition: opacity 120ms ease;
}
.setmove--preset-controls:hover,
.setmove--preset-controls:focus-within {
opacity: 1;
}
.setmove--preset-controls-top-right-horizontal {
top: 10px;
right: 42px;
flex-direction: row;
display: none;
}
.setmove--preset-controls-bottom-right-horizontal {
bottom: 10px;
right: 42px;
flex-direction: row;
}
.setmove--preset-controls-bottom-left-vertical {
bottom: 52px;
left: 10px;
flex-direction: column;
display: none;
}
.setmove--preset-control {
display: grid;
width: 24px;
height: 24px;
place-items: center;
padding: 0;
border: 0;
border-radius: 5px;
background: transparent;
color: #777799;
cursor: pointer;
}
.setmove--preset-control:hover {
background: var(--background-modifier-hover);
color: #777799;
}
.setmove--preset-control svg {
width: 15px;
height: 15px;
}

453
tests/drag-resize.test.ts Normal file
View file

@ -0,0 +1,453 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createDragResizeSession } from "../src/drag-resize";
import { enhanceSettingsModal } from "../src/modal-enhancer";
import { DEFAULT_SETTINGS } from "../src/settings";
describe("drag and resize sessions", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("updates during drag, commits on pointerup, and toggles interaction state", () => {
const handleEl = document.createElement("div");
const modalEl = document.createElement("div");
const onUpdate = vi.fn();
const onCommit = vi.fn();
const onStateChange = vi.fn();
stubPointerCapture(handleEl);
const session = createDragResizeSession({
isEnabled: () => true,
handleEl,
modalEl,
mode: "drag",
canStart: () => true,
getCurrentRect: () => ({
x: 100,
y: 120,
width: 900,
height: 640,
}),
getHostBounds: () => ({
x: 0,
y: 0,
width: 1440,
height: 900,
}),
onUpdate,
onCommit,
onStateChange,
});
handleEl.dispatchEvent(pointerEvent("pointerdown", { pointerId: 7, clientX: 20, clientY: 30 }));
expect(modalEl.classList.contains("setmove--is-dragging")).toBe(true);
handleEl.dispatchEvent(pointerEvent("pointermove", { pointerId: 7, clientX: 70, clientY: 90 }));
handleEl.dispatchEvent(pointerEvent("pointerup", { pointerId: 7, clientX: 70, clientY: 90 }));
expect(onStateChange).toHaveBeenNthCalledWith(1, true);
expect(onUpdate).toHaveBeenLastCalledWith({
x: 150,
y: 180,
width: 900,
height: 640,
});
expect(onCommit).toHaveBeenCalledWith({
x: 150,
y: 180,
width: 900,
height: 640,
});
expect(onStateChange).toHaveBeenLastCalledWith(false);
expect(modalEl.classList.contains("setmove--is-interacting")).toBe(false);
expect(modalEl.classList.contains("setmove--is-dragging")).toBe(false);
session.destroy();
});
it("ignores secondary buttons and cancels back to the starting rect", () => {
const handleEl = document.createElement("div");
handleEl.dataset.setmoveRole = "resize-handle";
const modalEl = document.createElement("div");
const onUpdate = vi.fn();
const onCommit = vi.fn();
stubPointerCapture(handleEl);
const session = createDragResizeSession({
isEnabled: () => true,
handleEl,
modalEl,
mode: "resize",
canStart: () => true,
getCurrentRect: () => ({
x: 20,
y: 30,
width: 900,
height: 640,
}),
getHostBounds: () => ({
x: 0,
y: 0,
width: 1000,
height: 700,
}),
onUpdate,
onCommit,
});
handleEl.dispatchEvent(pointerEvent("pointerdown", { button: 2, pointerId: 2, clientX: 10, clientY: 10 }));
handleEl.dispatchEvent(pointerEvent("pointermove", { pointerId: 2, clientX: 50, clientY: 50 }));
expect(onUpdate).not.toHaveBeenCalled();
handleEl.dispatchEvent(pointerEvent("pointerdown", { pointerId: 3, clientX: 10, clientY: 10 }));
expect(modalEl.classList.contains("setmove--is-resizing")).toBe(true);
handleEl.dispatchEvent(pointerEvent("pointermove", { pointerId: 3, clientX: 60, clientY: 70 }));
handleEl.dispatchEvent(pointerEvent("pointercancel", { pointerId: 3, clientX: 60, clientY: 70 }));
expect(onUpdate).toHaveBeenLastCalledWith({
x: 20,
y: 30,
width: 900,
height: 640,
});
expect(modalEl.classList.contains("setmove--is-resizing")).toBe(false);
expect(onCommit).not.toHaveBeenCalled();
session.destroy();
});
});
describe("modal enhancer interactions", () => {
beforeEach(() => {
document.body.innerHTML = "";
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1440,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 900,
});
});
it("persists geometry after drag ends and ignores disabled drag", () => {
const match = createSettingsFixture();
const onGeometryPersist = vi.fn();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
onGeometryPersist,
});
const dragHandle = match.modalEl.querySelector<HTMLElement>('[data-setmove-role="drag-handle"]');
if (!dragHandle) {
throw new Error("Missing drag handle");
}
stubPointerCapture(dragHandle);
dragHandle.dispatchEvent(pointerEvent("pointerdown", { pointerId: 9, clientX: 10, clientY: 10 }));
dragHandle.dispatchEvent(pointerEvent("pointermove", { pointerId: 9, clientX: 110, clientY: 90 }));
dragHandle.dispatchEvent(pointerEvent("pointerup", { pointerId: 9, clientX: 110, clientY: 90 }));
expect(match.modalEl.style.left).toBe("370px");
expect(match.modalEl.style.top).toBe("210px");
expect(onGeometryPersist).toHaveBeenCalledTimes(1);
expect(onGeometryPersist.mock.calls[0]?.[0]).toMatchObject({
x: 370,
y: 210,
width: 900,
height: 640,
});
enhancer.updateSettings({ movable: false });
dragHandle.dispatchEvent(pointerEvent("pointerdown", { pointerId: 10, clientX: 10, clientY: 10 }));
dragHandle.dispatchEvent(pointerEvent("pointermove", { pointerId: 10, clientX: 200, clientY: 200 }));
dragHandle.dispatchEvent(pointerEvent("pointerup", { pointerId: 10, clientX: 200, clientY: 200 }));
expect(onGeometryPersist).toHaveBeenCalledTimes(1);
enhancer.destroy();
});
it("allows dragging from whitespace but not from setting rows", () => {
const match = createSettingsFixture({
extraContent: `
<div class="settings-view">
<div class="settings-view-header"></div>
</div>
<div class="setting-item">
<button class="clickable-icon" type="button">Action</button>
</div>
`,
});
const onGeometryPersist = vi.fn();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
onGeometryPersist,
});
stubPointerCapture(match.modalEl);
match.contentEl.dispatchEvent(
pointerEvent("pointerdown", { pointerId: 20, clientX: 10, clientY: 10 }),
);
match.contentEl.dispatchEvent(
pointerEvent("pointermove", { pointerId: 20, clientX: 110, clientY: 90 }),
);
match.contentEl.dispatchEvent(
pointerEvent("pointerup", { pointerId: 20, clientX: 110, clientY: 90 }),
);
expect(match.modalEl.style.left).toBe("370px");
expect(match.modalEl.style.top).toBe("210px");
const nestedWhitespace = match.modalEl.querySelector<HTMLElement>(".settings-view-header");
if (!nestedWhitespace) {
throw new Error("Missing nested whitespace");
}
nestedWhitespace.dispatchEvent(
pointerEvent("pointerdown", { pointerId: 22, clientX: 20, clientY: 20 }),
);
nestedWhitespace.dispatchEvent(
pointerEvent("pointermove", { pointerId: 22, clientX: 70, clientY: 70 }),
);
nestedWhitespace.dispatchEvent(
pointerEvent("pointerup", { pointerId: 22, clientX: 70, clientY: 70 }),
);
expect(match.modalEl.style.left).toBe("420px");
expect(match.modalEl.style.top).toBe("260px");
const settingButton = match.modalEl.querySelector<HTMLElement>(".clickable-icon");
if (!settingButton) {
throw new Error("Missing setting button");
}
const persistedCalls = onGeometryPersist.mock.calls.length;
settingButton.dispatchEvent(
pointerEvent("pointerdown", { pointerId: 21, clientX: 20, clientY: 20 }),
);
settingButton.dispatchEvent(
pointerEvent("pointermove", { pointerId: 21, clientX: 200, clientY: 200 }),
);
settingButton.dispatchEvent(
pointerEvent("pointerup", { pointerId: 21, clientX: 200, clientY: 200 }),
);
expect(onGeometryPersist).toHaveBeenCalledTimes(persistedCalls);
enhancer.destroy();
});
it("persists geometry after resize and clamps it into the host bounds", () => {
const match = createSettingsFixture();
const onGeometryPersist = vi.fn();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
onGeometryPersist,
});
const resizeHandle = match.modalEl.querySelector<HTMLElement>('[data-setmove-role="resize-handle"]');
if (!resizeHandle) {
throw new Error("Missing resize handle");
}
stubPointerCapture(resizeHandle);
resizeHandle.dispatchEvent(pointerEvent("pointerdown", { pointerId: 11, clientX: 10, clientY: 10 }));
resizeHandle.dispatchEvent(pointerEvent("pointermove", { pointerId: 11, clientX: 800, clientY: 800 }));
resizeHandle.dispatchEvent(pointerEvent("pointerup", { pointerId: 11, clientX: 800, clientY: 800 }));
expect(match.modalEl.style.width).toBe("1440px");
expect(match.modalEl.style.height).toBe("900px");
expect(onGeometryPersist).toHaveBeenCalledTimes(1);
enhancer.destroy();
});
it("allows catalog header dragging without hijacking catalog cards", () => {
const match = createCatalogFixture();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
geometry: null,
rememberGeometry: false,
},
});
stubPointerCapture(match.modalEl);
const header = match.modalEl.querySelector<HTMLElement>(".catalog-header");
const card = match.modalEl.querySelector<HTMLElement>(".community-item");
if (!header || !card) {
throw new Error("Missing catalog fixture elements");
}
header.dispatchEvent(
pointerEvent("pointerdown", { pointerId: 30, clientX: 10, clientY: 10 }),
);
header.dispatchEvent(
pointerEvent("pointermove", { pointerId: 30, clientX: 110, clientY: 90 }),
);
header.dispatchEvent(
pointerEvent("pointerup", { pointerId: 30, clientX: 110, clientY: 90 }),
);
expect(match.modalEl.style.left).toBe("370px");
expect(match.modalEl.style.top).toBe("210px");
card.dispatchEvent(
pointerEvent("pointerdown", { pointerId: 31, clientX: 10, clientY: 10 }),
);
card.dispatchEvent(
pointerEvent("pointermove", { pointerId: 31, clientX: 210, clientY: 210 }),
);
card.dispatchEvent(
pointerEvent("pointerup", { pointerId: 31, clientX: 210, clientY: 210 }),
);
expect(match.modalEl.style.left).toBe("370px");
expect(match.modalEl.style.top).toBe("210px");
enhancer.destroy();
});
});
function stubPointerCapture(element: HTMLElement): void {
Object.defineProperty(element, "setPointerCapture", {
configurable: true,
value: vi.fn(),
});
Object.defineProperty(element, "releasePointerCapture", {
configurable: true,
value: vi.fn(),
});
}
function pointerEvent(
type: string,
init: { button?: number; clientX?: number; clientY?: number; pointerId?: number },
): Event {
const event = new MouseEvent(type, {
bubbles: true,
cancelable: true,
button: init.button ?? 0,
clientX: init.clientX ?? 0,
clientY: init.clientY ?? 0,
}) as MouseEvent & { pointerId: number };
Object.defineProperty(event, "pointerId", {
configurable: true,
value: init.pointerId ?? 1,
});
return event;
}
function createSettingsFixture(options?: { extraContent?: string }) {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal mod-settings">
<button class="modal-close-button" aria-label="Close"></button>
<div class="vertical-tab-header"></div>
<div class="vertical-tab-content-container">${options?.extraContent ?? ""}</div>
</div>
</div>
`;
const modalEl = document.querySelector<HTMLElement>(".modal.mod-settings");
const containerEl = document.querySelector<HTMLElement>(".modal-container");
const contentEl = document.querySelector<HTMLElement>(".vertical-tab-content-container");
if (!modalEl || !containerEl || !contentEl) {
throw new Error("Missing settings fixture.");
}
Object.defineProperty(modalEl, "offsetWidth", {
configurable: true,
value: 900,
});
Object.defineProperty(modalEl, "offsetHeight", {
configurable: true,
value: 640,
});
modalEl.getBoundingClientRect = () =>
({
x: 0,
y: 0,
width: 900,
height: 640,
top: 0,
right: 900,
bottom: 640,
left: 0,
toJSON() {
return {};
},
}) as DOMRect;
return {
modalEl,
containerEl,
contentEl,
doc: document,
kind: "settings" as const,
win: window,
};
}
function createCatalogFixture() {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal">
<button class="modal-close-button" aria-label="Close"></button>
<div class="catalog-header">Showing 366 themes</div>
<input type="text" placeholder="Filter..." />
<div class="community-modal-search-results">
<div class="community-item">Minimal</div>
</div>
</div>
</div>
`;
const modalEl = document.querySelector<HTMLElement>(".modal");
const containerEl = document.querySelector<HTMLElement>(".modal-container");
if (!modalEl || !containerEl) {
throw new Error("Missing catalog fixture.");
}
Object.defineProperty(modalEl, "offsetWidth", {
configurable: true,
value: 900,
});
Object.defineProperty(modalEl, "offsetHeight", {
configurable: true,
value: 640,
});
modalEl.getBoundingClientRect = () =>
({
x: 0,
y: 0,
width: 900,
height: 640,
top: 0,
right: 900,
bottom: 640,
left: 0,
toJSON() {
return {};
},
}) as DOMRect;
return {
modalEl,
containerEl,
contentEl: null,
doc: document,
kind: "catalog" as const,
win: window,
};
}

280
tests/geometry.test.ts Normal file
View file

@ -0,0 +1,280 @@
import { describe, expect, it } from "vitest";
import {
MIN_MODAL_HEIGHT,
MIN_MODAL_WIDTH,
applyGeometryPreset,
clampModalRect,
getCenteredRect,
getMinimumModalSize,
getRestoredGeometry,
measureHostBounds,
type HostBounds,
} from "../src/geometry";
import type { PersistedGeometry } from "../src/settings";
const hostBounds: HostBounds = {
x: 0,
y: 0,
width: 1440,
height: 900,
};
const savedGeometry: PersistedGeometry = {
schemaVersion: 1,
x: 48,
y: 72,
width: 900,
height: 640,
lastAppliedBounds: {
width: 1440,
height: 900,
},
};
describe("geometry measurement", () => {
it("measures host bounds from a viewport", () => {
expect(
measureHostBounds({
innerWidth: 1280,
innerHeight: 720,
}),
).toEqual({
x: 0,
y: 0,
width: 1280,
height: 720,
});
});
it("caps minimum size to the available host space", () => {
expect(
getMinimumModalSize({
x: 0,
y: 0,
width: 500,
height: 360,
}),
).toEqual({
width: 500,
height: 360,
});
});
});
describe("geometry clamping", () => {
it("clamps size and position into the visible host", () => {
expect(
clampModalRect(
{
x: -200,
y: 700,
width: 1800,
height: 1000,
},
hostBounds,
),
).toEqual({
x: 0,
y: 0,
width: 1440,
height: 900,
});
});
it("enforces minimum dimensions when the host allows them", () => {
expect(
clampModalRect(
{
x: 10,
y: 10,
width: 320,
height: 260,
},
hostBounds,
),
).toEqual({
x: 10,
y: 10,
width: MIN_MODAL_WIDTH,
height: MIN_MODAL_HEIGHT,
});
});
it("reclamps geometry after a host resize", () => {
expect(
clampModalRect(savedGeometry, {
x: 0,
y: 0,
width: 820,
height: 640,
}),
).toEqual({
x: 0,
y: 0,
width: 820,
height: 640,
});
});
});
describe("geometry presets", () => {
it("centers while preserving the current size when it fits", () => {
expect(
getCenteredRect(
{
width: 900,
height: 640,
},
hostBounds,
),
).toEqual({
x: 270,
y: 130,
width: 900,
height: 640,
});
});
it("calculates left and right dock presets predictably", () => {
expect(
applyGeometryPreset(
"dock-left",
{
width: 900,
height: 640,
},
hostBounds,
),
).toEqual({
x: 0,
y: 0,
width: 640,
height: 900,
});
expect(
applyGeometryPreset(
"dock-right",
{
width: 900,
height: 640,
},
hostBounds,
),
).toEqual({
x: 800,
y: 0,
width: 640,
height: 900,
});
});
it("keeps dock presets as narrow as the modal allows on wide screens", () => {
const wideHostBounds = {
x: 0,
y: 0,
width: 2048,
height: 1200,
};
expect(
applyGeometryPreset(
"dock-left",
{
width: 1100,
height: 720,
},
wideHostBounds,
),
).toEqual({
x: 0,
y: 0,
width: 640,
height: 1200,
});
expect(
applyGeometryPreset(
"dock-right",
{
width: 1100,
height: 720,
},
wideHostBounds,
),
).toEqual({
x: 1408,
y: 0,
width: 640,
height: 1200,
});
});
it("keeps the optional bottom-half preset inside bounds", () => {
expect(
applyGeometryPreset(
"bottom-half",
{
width: 900,
height: 640,
},
hostBounds,
),
).toEqual({
x: 0,
y: 450,
width: 1440,
height: 450,
});
});
});
describe("saved geometry restoration", () => {
it("restores valid saved geometry and reclamps it for the current host", () => {
expect(
getRestoredGeometry(savedGeometry, {
x: 0,
y: 0,
width: 1000,
height: 700,
}),
).toEqual({
x: 48,
y: 60,
width: 900,
height: 640,
});
});
it("ignores saved geometry that is too small, non-finite, or otherwise incompatible", () => {
expect(
getRestoredGeometry(
{
...savedGeometry,
width: 320,
},
hostBounds,
),
).toBeNull();
expect(
getRestoredGeometry(
{
...savedGeometry,
height: Number.POSITIVE_INFINITY,
} as PersistedGeometry,
hostBounds,
),
).toBeNull();
expect(
getRestoredGeometry(
{
...savedGeometry,
width: 2000,
},
hostBounds,
),
).toBeNull();
});
});

View file

@ -0,0 +1,210 @@
import { JSDOM } from "jsdom";
import { describe, expect, it, vi } from "vitest";
import {
SettingsModalLifecycle,
findSettingsModal,
findSettingsModals,
isSettingsModalElement,
} from "../src/modal-detector";
describe("settings modal detection", () => {
it("matches the core Settings modal shape", () => {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal mod-settings">
<button class="modal-close-button" aria-label="Close"></button>
<div class="vertical-tab-header"></div>
<div class="vertical-tab-content-container"></div>
</div>
</div>
`;
const match = findSettingsModal(document);
expect(match).not.toBeNull();
expect(match?.modalEl.classList.contains("mod-settings")).toBe(true);
expect(match?.contentEl?.classList.contains("vertical-tab-content-container")).toBe(
true,
);
expect(match?.kind).toBe("settings");
expect(match?.doc).toBe(document);
expect(match?.win).toBe(window);
});
it("matches theme and community plugin catalog browser dialogs", () => {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal" id="theme-browser">
<button class="modal-close-button" aria-label="Close"></button>
<input type="text" placeholder="Filter..." />
<div>Showing 366 themes</div>
</div>
</div>
<div class="modal-container mod-dim">
<div class="modal" id="plugin-browser">
<button class="modal-close-button" aria-label="Close"></button>
<input type="text" placeholder="Search community plugins..." />
<div>Showing 4,717 plugins</div>
</div>
</div>
`;
const dialogs = findSettingsModals(document);
expect(dialogs).toHaveLength(2);
expect(dialogs.map((dialog) => dialog.modalEl.id)).toEqual([
"theme-browser",
"plugin-browser",
]);
expect(dialogs.every((dialog) => dialog.kind === "catalog")).toBe(true);
expect(dialogs.every((dialog) => dialog.contentEl === null)).toBe(true);
});
it("matches catalog dialogs before the showing count is populated", () => {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal" id="theme-browser">
<button class="modal-close-button" aria-label="Close"></button>
<input type="text" placeholder="Filter..." />
<div>Show installed only</div>
<div>Light themes only</div>
</div>
</div>
`;
const match = findSettingsModal(document);
expect(match?.modalEl.id).toBe("theme-browser");
expect(match?.kind).toBe("catalog");
});
it("attaches to catalog dialogs when their text content updates after open", async () => {
document.body.innerHTML = "";
const onAttach = vi.fn();
const lifecycle = new SettingsModalLifecycle({ onAttach });
lifecycle.trackDocument(document);
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal" id="theme-browser">
<button class="modal-close-button" aria-label="Close"></button>
<input type="text" placeholder="Filter..." />
<div id="count"></div>
</div>
</div>
`;
await Promise.resolve();
expect(onAttach).toHaveBeenCalledTimes(0);
const countEl = document.getElementById("count");
if (!countEl) {
throw new Error("Missing count fixture.");
}
countEl.textContent = "Showing 367 themes";
await Promise.resolve();
expect(onAttach).toHaveBeenCalledTimes(1);
expect(onAttach.mock.calls[0]?.[0].kind).toBe("catalog");
lifecycle.stop();
});
it("rejects nested child dialogs and ordinary modals", () => {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal mod-settings" id="settings-modal">
<button class="modal-close-button" aria-label="Close"></button>
<div class="vertical-tab-header"></div>
<div class="vertical-tab-content-container"></div>
</div>
</div>
<div class="modal-container mod-dim">
<div class="modal" id="font-dialog">
<button class="modal-close-button" aria-label="Close"></button>
<div class="setting-item">Font picker</div>
</div>
</div>
<div class="modal-container mod-dim">
<div class="modal mod-settings" id="fake-settings">
<button class="modal-close-button" aria-label="Close"></button>
<div class="setting-item">Missing settings structure</div>
</div>
</div>
`;
const dialogs = findSettingsModals(document);
const fontDialog = document.getElementById("font-dialog");
const fakeSettings = document.getElementById("fake-settings");
expect(dialogs).toHaveLength(1);
expect(dialogs[0]?.modalEl.id).toBe("settings-modal");
expect(isSettingsModalElement(fontDialog)).toBe(false);
expect(isSettingsModalElement(fakeSettings)).toBe(false);
});
});
describe("settings modal lifecycle", () => {
it("attaches once and detaches when the modal closes", async () => {
document.body.innerHTML = "";
const onAttach = vi.fn(() => vi.fn());
const onDetach = vi.fn();
const lifecycle = new SettingsModalLifecycle({ onAttach, onDetach });
lifecycle.trackDocument(document);
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal mod-settings">
<button class="modal-close-button"></button>
<div class="vertical-tab-header"></div>
<div class="vertical-tab-content-container"></div>
</div>
</div>
`;
await Promise.resolve();
expect(onAttach).toHaveBeenCalledTimes(1);
document
.querySelector(".modal.mod-settings")
?.classList.add("is-still-the-same-modal");
await Promise.resolve();
expect(onAttach).toHaveBeenCalledTimes(1);
document.querySelector(".modal-container")?.remove();
await Promise.resolve();
expect(onDetach).toHaveBeenCalledTimes(1);
lifecycle.stop();
});
it("uses the owning document and window for popout matches", async () => {
document.body.innerHTML = "";
const popout = new JSDOM(`<!doctype html><html><body></body></html>`);
const onAttach = vi.fn();
const lifecycle = new SettingsModalLifecycle({ onAttach });
popout.window.document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal mod-settings">
<button class="modal-close-button"></button>
<div class="vertical-tab-header"></div>
<div class="vertical-tab-content-container"></div>
</div>
</div>
`;
lifecycle.trackDocument(popout.window.document);
expect(onAttach).toHaveBeenCalledTimes(1);
expect(onAttach.mock.calls[0]?.[0].doc).toBe(popout.window.document);
expect(onAttach.mock.calls[0]?.[0].win).toBe(popout.window);
lifecycle.stop();
popout.window.close();
});
});

View file

@ -0,0 +1,419 @@
import { beforeEach, describe, expect, it } from "vitest";
import { enhanceSettingsModal } from "../src/modal-enhancer";
import type { SettingsModalMatch } from "../src/modal-detector";
import { DEFAULT_SETTINGS, type PersistedGeometry } from "../src/settings";
const savedGeometry: PersistedGeometry = {
schemaVersion: 1,
x: 48,
y: 72,
width: 900,
height: 640,
lastAppliedBounds: {
width: 1440,
height: 900,
},
};
describe("settings modal enhancer", () => {
beforeEach(() => {
document.body.innerHTML = "";
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1440,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 900,
});
});
it("adds classes, handles, and centered geometry", () => {
const match = createSettingsMatch();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
});
expect(match.modalEl.classList.contains("setmove--settings-modal")).toBe(true);
expect(match.modalEl.querySelector('[data-setmove-role="drag-handle"]')).not.toBeNull();
expect(match.modalEl.querySelector('[data-setmove-role="resize-handle"]')).not.toBeNull();
expect(
Array.from(
match.modalEl.querySelectorAll<HTMLElement>(
'[data-setmove-role="preset-controls"]',
),
).map((controls) => controls.dataset.setmovePlacement),
).toEqual([
"top-right-horizontal",
"bottom-right-horizontal",
"bottom-left-vertical",
]);
expect(match.modalEl.querySelectorAll('[data-setmove-role="preset-control"]')).toHaveLength(9);
expect(
Array.from(
match.modalEl.querySelectorAll<HTMLElement>(
'[data-setmove-role="preset-controls"]',
),
).map((controls) => [controls.dataset.setmovePlacement, controls.hidden]),
).toEqual([
["top-right-horizontal", true],
["bottom-right-horizontal", false],
["bottom-left-vertical", true],
]);
expect(
Array.from(
match.modalEl.querySelectorAll<HTMLElement>(
'[data-setmove-placement="top-right-horizontal"] [data-setmove-role="preset-control"]',
),
).map((control) => control.getAttribute("aria-label")),
).toEqual([
"Dock window left",
"Dock window right",
"Reset window geometry",
]);
expect(match.modalEl.style.position).toBe("fixed");
expect(match.modalEl.style.left).toBe("270px");
expect(match.modalEl.style.top).toBe("130px");
expect(match.modalEl.style.width).toBe("900px");
expect(match.modalEl.style.height).toBe("640px");
enhancer.destroy();
});
it("restores remembered geometry and exposes preset methods", () => {
const match = createSettingsMatch();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
geometry: savedGeometry,
},
});
expect(match.modalEl.style.left).toBe("48px");
expect(match.modalEl.style.top).toBe("72px");
enhancer.dockRight();
expect(match.modalEl.style.left).toBe("800px");
expect(match.modalEl.style.top).toBe("0px");
expect(match.modalEl.style.width).toBe("640px");
expect(match.modalEl.style.height).toBe("900px");
enhancer.center();
expect(match.modalEl.style.left).toBe("400px");
expect(match.modalEl.style.top).toBe("0px");
expect(match.modalEl.style.width).toBe("640px");
expect(match.modalEl.style.height).toBe("900px");
enhancer.reset();
expect(match.modalEl.style.left).toBe("270px");
expect(match.modalEl.style.top).toBe("130px");
expect(match.modalEl.style.width).toBe("900px");
expect(match.modalEl.style.height).toBe("640px");
enhancer.destroy();
});
it("reuses one enhancer per modal and does not duplicate handles", () => {
const match = createSettingsMatch();
const first = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
});
const second = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
movable: false,
},
});
expect(second).toBe(first);
expect(
match.modalEl.querySelectorAll('[data-setmove-role="drag-handle"]'),
).toHaveLength(1);
expect(
match.modalEl.querySelectorAll('[data-setmove-role="resize-handle"]'),
).toHaveLength(1);
expect(
match.modalEl.querySelectorAll('[data-setmove-role="preset-controls"]'),
).toHaveLength(3);
expect(
match.modalEl.querySelectorAll('[data-setmove-role="preset-control"]'),
).toHaveLength(9);
expect(
(match.modalEl.querySelector('[data-setmove-role="drag-handle"]') as HTMLElement)
.hidden,
).toBe(true);
expect(
(match.modalEl.querySelector('[data-setmove-role="resize-handle"]') as HTMLElement)
.style.display,
).not.toBe("none");
first.destroy();
});
it("applies preset button actions and routes reset through the callback", () => {
const match = createSettingsMatch();
let resetCalls = 0;
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
onResetGeometry: () => {
resetCalls += 1;
},
});
const bottomHorizontalControls = match.modalEl.querySelector<HTMLElement>(
'[data-setmove-placement="bottom-right-horizontal"]',
);
const dockRightButton = bottomHorizontalControls?.querySelector<HTMLButtonElement>(
'[aria-label="Dock window right"]',
);
dockRightButton?.click();
expect(match.modalEl.style.left).toBe("800px");
expect(match.modalEl.style.top).toBe("0px");
const resetButton = bottomHorizontalControls?.querySelector<HTMLButtonElement>(
'[aria-label="Reset window geometry"]',
);
resetButton?.click();
expect(resetCalls).toBe(1);
enhancer.destroy();
});
it("renders position icons as a consistent window-thirds family", () => {
const match = createSettingsMatch();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
});
const controls = match.modalEl.querySelector<HTMLElement>(
'[data-setmove-placement="bottom-right-horizontal"]',
);
const leftIcon = controls?.querySelector<SVGSVGElement>(
'[aria-label="Dock window left"] svg',
);
const rightIcon = controls?.querySelector<SVGSVGElement>(
'[aria-label="Dock window right"] svg',
);
const resetIcon = controls?.querySelector<SVGSVGElement>(
'[aria-label="Reset window geometry"] svg',
);
expect(controls?.querySelector('[aria-label="Center window"]')).toBeNull();
expect(leftIcon?.querySelector("rect")?.getAttribute("x")).toBe("4");
expect(rightIcon?.querySelector("rect")?.getAttribute("x")).toBe("15");
expect(leftIcon?.querySelectorAll("path")).toHaveLength(2);
expect(rightIcon?.querySelectorAll("path")).toHaveLength(2);
expect(resetIcon?.querySelector("path")?.getAttribute("fill")).toBe("#777799");
expect(leftIcon?.querySelector("path")?.getAttribute("stroke")).toBe("#777799");
expect(resetIcon?.querySelector("path")?.getAttribute("d")).toContain(
"H4.2V4.9z",
);
enhancer.destroy();
});
it("fully hides the resize handle when resizing is disabled", () => {
const match = createSettingsMatch();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
});
enhancer.updateSettings({ resizable: false });
const resizeHandle = match.modalEl.querySelector<HTMLElement>(
'[data-setmove-role="resize-handle"]',
);
expect(resizeHandle?.hidden).toBe(true);
expect(resizeHandle?.style.display).toBe("none");
expect(resizeHandle?.style.pointerEvents).toBe("none");
expect(resizeHandle?.style.cursor).toBe("default");
enhancer.destroy();
});
it("enhances catalog dialogs without settings content rewrites", () => {
const match = createCatalogMatch();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
geometry: null,
rememberGeometry: false,
},
});
expect(enhancer.kind).toBe("catalog");
expect(match.modalEl.classList.contains("setmove--settings-modal")).toBe(true);
expect(match.modalEl.querySelector('[data-setmove-role="drag-handle"]')).not.toBeNull();
expect(match.modalEl.querySelector('[data-setmove-role="resize-handle"]')).not.toBeNull();
expect(match.modalEl.querySelectorAll('[data-setmove-role="preset-controls"]')).toHaveLength(3);
expect(match.modalEl.querySelector(".setmove--settings-content")).toBeNull();
enhancer.destroy();
});
it("reclamps current geometry when the host window shrinks", () => {
const match = createSettingsMatch();
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
geometry: savedGeometry,
},
});
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 820,
});
Object.defineProperty(window, "innerHeight", {
configurable: true,
value: 640,
});
window.dispatchEvent(new Event("resize"));
expect(match.modalEl.style.left).toBe("0px");
expect(match.modalEl.style.top).toBe("0px");
expect(match.modalEl.style.width).toBe("820px");
expect(match.modalEl.style.height).toBe("640px");
enhancer.destroy();
});
it("cleans up classes, styles, handles, and settings-shell state on destroy", () => {
const match = createSettingsMatch();
match.modalEl.style.position = "relative";
match.modalEl.style.left = "12px";
const enhancer = enhanceSettingsModal(match, {
settings: {
...DEFAULT_SETTINGS,
},
});
enhancer.destroy();
expect(match.modalEl.classList.contains("setmove--settings-modal")).toBe(false);
expect(match.modalEl.querySelector('[data-setmove-role="drag-handle"]')).toBeNull();
expect(match.modalEl.querySelector('[data-setmove-role="resize-handle"]')).toBeNull();
expect(match.modalEl.querySelector('[data-setmove-role="preset-controls"]')).toBeNull();
expect(match.modalEl.style.position).toBe("relative");
expect(match.modalEl.style.left).toBe("12px");
expect(match.contentEl.style.overflow).toBe("");
});
});
function createSettingsMatch(): SettingsModalMatch & { contentEl: HTMLElement } {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal mod-settings">
<button class="modal-close-button" aria-label="Close"></button>
<div class="vertical-tab-header"></div>
<div class="vertical-tab-content-container"></div>
</div>
</div>
`;
const modalEl = document.querySelector<HTMLElement>(".modal.mod-settings");
const containerEl = document.querySelector<HTMLElement>(".modal-container");
const contentEl = document.querySelector<HTMLElement>(".vertical-tab-content-container");
if (!modalEl || !containerEl || !contentEl) {
throw new Error("Missing settings modal fixture.");
}
Object.defineProperty(modalEl, "offsetWidth", {
configurable: true,
value: 900,
});
Object.defineProperty(modalEl, "offsetHeight", {
configurable: true,
value: 640,
});
modalEl.getBoundingClientRect = () =>
({
x: 0,
y: 0,
width: 900,
height: 640,
top: 0,
right: 900,
bottom: 640,
left: 0,
toJSON() {
return {};
},
}) as DOMRect;
return {
modalEl,
containerEl,
contentEl,
doc: document,
kind: "settings",
win: window,
};
}
function createCatalogMatch(): SettingsModalMatch {
document.body.innerHTML = `
<div class="modal-container mod-dim">
<div class="modal">
<button class="modal-close-button" aria-label="Close"></button>
<input type="text" placeholder="Filter..." />
<div>Showing 366 themes</div>
</div>
</div>
`;
const modalEl = document.querySelector<HTMLElement>(".modal");
const containerEl = document.querySelector<HTMLElement>(".modal-container");
if (!modalEl || !containerEl) {
throw new Error("Missing catalog modal fixture.");
}
Object.defineProperty(modalEl, "offsetWidth", {
configurable: true,
value: 1100,
});
Object.defineProperty(modalEl, "offsetHeight", {
configurable: true,
value: 720,
});
modalEl.getBoundingClientRect = () =>
({
x: 0,
y: 0,
width: 1100,
height: 720,
top: 0,
right: 1100,
bottom: 720,
left: 0,
toJSON() {
return {};
},
}) as DOMRect;
return {
modalEl,
containerEl,
contentEl: null,
doc: document,
kind: "catalog",
win: window,
};
}

16
tests/scaffold.test.ts Normal file
View file

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import manifest from "../manifest.json";
import versions from "../versions.json";
describe("scaffold metadata", () => {
it("keeps the plugin id stable", () => {
expect(manifest.id).toBe("settings-float");
expect(manifest.name).toBe("Settings Float");
});
it("maps the current version to the minimum supported Obsidian version", () => {
expect(versions[manifest.version as keyof typeof versions]).toBe(
manifest.minAppVersion,
);
});
});

162
tests/settings.test.ts Normal file
View file

@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
clearSavedGeometry,
loadSettings,
migrateSettingsData,
resetSavedGeometry,
saveSettings,
serializeSettings,
type PluginDataStore,
type PersistedGeometry,
type SetmoveSettings,
} from "../src/settings";
class MemoryDataStore implements PluginDataStore {
constructor(private data: unknown = null) {}
async loadData(): Promise<unknown> {
return this.data;
}
async saveData(data: SetmoveSettings): Promise<void> {
this.data = data;
}
peek(): unknown {
return this.data;
}
}
const savedGeometry: PersistedGeometry = {
schemaVersion: 1,
x: 48,
y: 72,
width: 900,
height: 640,
lastAppliedBounds: {
width: 1440,
height: 900,
},
};
describe("settings persistence", () => {
it("uses defaults when no saved data exists", () => {
expect(migrateSettingsData(null)).toEqual(DEFAULT_SETTINGS);
});
it("merges partial saved data with current defaults", () => {
expect(
migrateSettingsData({
movable: false,
rememberGeometry: false,
geometry: savedGeometry,
}),
).toEqual({
...DEFAULT_SETTINGS,
movable: false,
rememberGeometry: false,
geometry: savedGeometry,
});
});
it("loads and saves a typed, unknown-free settings payload", async () => {
const dataStore = new MemoryDataStore({
movable: false,
extraFutureField: "ignored",
geometry: {
...savedGeometry,
unknownGeometryField: true,
},
});
const settings = await loadSettings(dataStore);
await saveSettings(dataStore, settings);
expect(dataStore.peek()).toEqual({
...DEFAULT_SETTINGS,
movable: false,
geometry: savedGeometry,
});
expect(JSON.stringify(dataStore.peek())).not.toContain("extraFutureField");
expect(JSON.stringify(dataStore.peek())).not.toContain(
"unknownGeometryField",
);
});
it("clears saved geometry without changing unrelated settings", () => {
const settings = {
...DEFAULT_SETTINGS,
movable: false,
resizable: false,
geometry: savedGeometry,
};
expect(clearSavedGeometry(settings)).toEqual({
...settings,
geometry: null,
});
});
it("persists a geometry reset without clearing unrelated settings", async () => {
const dataStore = new MemoryDataStore();
const settings = {
...DEFAULT_SETTINGS,
showPresetControls: false,
disableOnNarrowWindows: false,
geometry: savedGeometry,
};
const nextSettings = await resetSavedGeometry(dataStore, settings);
expect(nextSettings).toEqual({
...settings,
geometry: null,
});
expect(dataStore.peek()).toEqual(nextSettings);
});
it("recovers from invalid saved values", () => {
expect(
migrateSettingsData({
movable: "yes",
resizable: 0,
rememberGeometry: false,
showPresetControls: false,
disableOnMobile: false,
disableOnNarrowWindows: false,
narrowWindowThreshold: -1,
geometry: {
x: Number.NaN,
y: 40,
width: 0,
height: 500,
lastAppliedBounds: {
width: 1000,
height: Infinity,
},
},
}),
).toEqual({
...DEFAULT_SETTINGS,
rememberGeometry: false,
showPresetControls: false,
disableOnMobile: false,
disableOnNarrowWindows: false,
geometry: null,
});
});
it("serializes settings as a deep copy", () => {
const settings = {
...DEFAULT_SETTINGS,
geometry: savedGeometry,
};
const serialized = serializeSettings(settings);
expect(serialized).toEqual(settings);
expect(serialized).not.toBe(settings);
expect(serialized.geometry).not.toBe(settings.geometry);
});
});