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); + }); +});