From e625eb10595b43e83b71451cc8a4c935fb02f661 Mon Sep 17 00:00:00 2001 From: Jason Swartz Date: Mon, 15 Jun 2026 12:27:04 -0700 Subject: [PATCH] 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. --- src/drag-resize.ts | 156 ++++++++++ src/geometry.ts | 232 +++++++++++++++ src/main.ts | 120 ++++++++ src/modal-detector.ts | 314 ++++++++++++++++++++ src/modal-enhancer.ts | 535 +++++++++++++++++++++++++++++++++++ src/settings-tab.ts | 90 ++++++ src/settings.ts | 217 ++++++++++++++ styles.css | 185 ++++++++++++ tests/drag-resize.test.ts | 453 +++++++++++++++++++++++++++++ tests/geometry.test.ts | 280 ++++++++++++++++++ tests/modal-detector.test.ts | 210 ++++++++++++++ tests/modal-enhancer.test.ts | 419 +++++++++++++++++++++++++++ tests/scaffold.test.ts | 16 ++ tests/settings.test.ts | 162 +++++++++++ 14 files changed, 3389 insertions(+) create mode 100644 src/drag-resize.ts create mode 100644 src/geometry.ts create mode 100644 src/main.ts create mode 100644 src/modal-detector.ts create mode 100644 src/modal-enhancer.ts create mode 100644 src/settings-tab.ts create mode 100644 src/settings.ts create mode 100644 styles.css create mode 100644 tests/drag-resize.test.ts create mode 100644 tests/geometry.test.ts create mode 100644 tests/modal-detector.test.ts create mode 100644 tests/modal-enhancer.test.ts create mode 100644 tests/scaffold.test.ts create mode 100644 tests/settings.test.ts diff --git a/src/drag-resize.ts b/src/drag-resize.ts new file mode 100644 index 0000000..5b4c3b2 --- /dev/null +++ b/src/drag-resize.ts @@ -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); + }, + }; +} diff --git a/src/geometry.ts b/src/geometry.ts new file mode 100644 index 0000000..5890649 --- /dev/null +++ b/src/geometry.ts @@ -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): 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, + 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, + 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): 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; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..129b64f --- /dev/null +++ b/src/main.ts @@ -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(); + + async onload(): Promise { + 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): Promise { + this.settings = { + ...this.settings, + ...partial, + }; + await saveSettings(this, this.settings); + this.applySettingsToOpenEnhancers(); + } + + async resetSavedGeometryCommand(): Promise { + 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 { + 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, + }; + } +} diff --git a/src/modal-detector.ts b/src/modal-detector.ts new file mode 100644 index 0000000..704b9ef --- /dev/null +++ b/src/modal-detector.ts @@ -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(); + private readonly attached = new Map(); + + 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(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( + 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( + 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(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); +} diff --git a/src/modal-enhancer.ts b/src/modal-enhancer.ts new file mode 100644 index 0000000..e372264 --- /dev/null +++ b/src/modal-enhancer.ts @@ -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; + +type PresetControlsPlacement = + | "top-right-horizontal" + | "bottom-right-horizontal" + | "bottom-left-vertical"; +const VISIBLE_PRESET_CONTROLS_PLACEMENTS = new Set([ + "bottom-right-horizontal", +]); + +const ENHANCER_BY_MODAL = new WeakMap(); + +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; + onResetGeometry?: () => void | Promise; +} + +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; + 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 { + 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, + ): 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/settings-tab.ts b/src/settings-tab.ts new file mode 100644 index 0000000..524c969 --- /dev/null +++ b/src/settings-tab.ts @@ -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(); + }), + ); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..cbf535a --- /dev/null +++ b/src/settings.ts @@ -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; + saveData(data: SetmoveSettings): Promise; +} + +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; + +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 { + return migrateSettingsData(await dataStore.loadData()); +} + +export async function saveSettings( + dataStore: PluginDataStore, + settings: SetmoveSettings, +): Promise { + 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 { + 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; +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..2de0598 --- /dev/null +++ b/styles.css @@ -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; +} diff --git a/tests/drag-resize.test.ts b/tests/drag-resize.test.ts new file mode 100644 index 0000000..80d7056 --- /dev/null +++ b/tests/drag-resize.test.ts @@ -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('[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: ` +
+
+
+
+ +
+ `, + }); + 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(".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(".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('[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(".catalog-header"); + const card = match.modalEl.querySelector(".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 = ` + + `; + + const modalEl = document.querySelector(".modal.mod-settings"); + const containerEl = document.querySelector(".modal-container"); + const contentEl = document.querySelector(".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 = ` + + `; + + const modalEl = document.querySelector(".modal"); + const containerEl = document.querySelector(".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, + }; +} diff --git a/tests/geometry.test.ts b/tests/geometry.test.ts new file mode 100644 index 0000000..d6006ce --- /dev/null +++ b/tests/geometry.test.ts @@ -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(); + }); +}); diff --git a/tests/modal-detector.test.ts b/tests/modal-detector.test.ts new file mode 100644 index 0000000..8dfb034 --- /dev/null +++ b/tests/modal-detector.test.ts @@ -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 = ` + + `; + + 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 = ` + + + `; + + 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 = ` + + `; + + 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 = ` + + `; + + 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 = ` + + + + `; + + 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 = ` + + `; + + 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(``); + const onAttach = vi.fn(); + const lifecycle = new SettingsModalLifecycle({ onAttach }); + + popout.window.document.body.innerHTML = ` + + `; + + 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(); + }); +}); diff --git a/tests/modal-enhancer.test.ts b/tests/modal-enhancer.test.ts new file mode 100644 index 0000000..a983efa --- /dev/null +++ b/tests/modal-enhancer.test.ts @@ -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( + '[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( + '[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( + '[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( + '[data-setmove-placement="bottom-right-horizontal"]', + ); + const dockRightButton = bottomHorizontalControls?.querySelector( + '[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( + '[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( + '[data-setmove-placement="bottom-right-horizontal"]', + ); + const leftIcon = controls?.querySelector( + '[aria-label="Dock window left"] svg', + ); + const rightIcon = controls?.querySelector( + '[aria-label="Dock window right"] svg', + ); + const resetIcon = controls?.querySelector( + '[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( + '[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 = ` + + `; + + const modalEl = document.querySelector(".modal.mod-settings"); + const containerEl = document.querySelector(".modal-container"); + const contentEl = document.querySelector(".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 = ` + + `; + + const modalEl = document.querySelector(".modal"); + const containerEl = document.querySelector(".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, + }; +} diff --git a/tests/scaffold.test.ts b/tests/scaffold.test.ts new file mode 100644 index 0000000..06269f6 --- /dev/null +++ b/tests/scaffold.test.ts @@ -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, + ); + }); +}); diff --git a/tests/settings.test.ts b/tests/settings.test.ts new file mode 100644 index 0000000..99771bd --- /dev/null +++ b/tests/settings.test.ts @@ -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 { + return this.data; + } + + async saveData(data: SetmoveSettings): Promise { + 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); + }); +});