From 1d7848b87132b79e901611c0ed0cdae4781e4350 Mon Sep 17 00:00:00 2001 From: vorotamoroz Date: Wed, 4 Nov 2026 05:04:55 +0000 Subject: [PATCH] fix: preserve native spacing and safe areas in restore selector --- src/RestoreView.ts | 5 +- styles.css | 32 +++++ test/e2e-obsidian/README.md | 2 +- test/e2e-obsidian/restore-confirmation.mts | 154 +++++++++++++++++++++ updates.md | 6 + 5 files changed, 196 insertions(+), 3 deletions(-) diff --git a/src/RestoreView.ts b/src/RestoreView.ts index 368f5fd..00f0cb7 100644 --- a/src/RestoreView.ts +++ b/src/RestoreView.ts @@ -13,6 +13,7 @@ export class RestoreDialog extends Modal { public plugin: DiffZipBackupPlugin ) { super(app); + this.containerEl.addClass("diffzip-restore-modal-container"); } component?: ReturnType; @@ -20,9 +21,9 @@ export class RestoreDialog extends Modal { async onOpen() { const toc = await this.plugin.loadTOC(); - const containerEl = this.modalEl; + const containerEl = this.contentEl; containerEl.empty(); - this.titleEl.setText("Restore (Revision Selector)"); + this.setTitle("Restore (Revision Selector)"); this.component = mount(RestoreRevisionDialogComponent, { target: containerEl, diff --git a/styles.css b/styles.css index 6a21260..b775d48 100644 --- a/styles.css +++ b/styles.css @@ -145,6 +145,38 @@ padding-bottom: 3px; } +.is-mobile .diffzip-restore-modal-container { + box-sizing: border-box; + /* The bottom inset can already include the keyboard; reserve their union once. */ + max-height: calc( + 100vh - max(var(--keyboard-height, 0px), var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px))) + ); + padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px)); + padding-right: var(--safe-area-inset-right, env(safe-area-inset-right, 0px)); + padding-bottom: 0; + padding-left: var(--safe-area-inset-left, env(safe-area-inset-left, 0px)); +} + +.is-mobile .diffzip-restore-modal-container > .modal { + max-height: 100%; + min-height: 0; +} + +.is-mobile .diffzip-restore-modal-container .diffzip-restore-search { + width: 100%; + min-width: 0; +} + +.is-mobile .diffzip-restore-modal-container .diffzip-restore-search input { + flex: 1; + min-width: 0; +} + +.is-mobile .diffzip-restore-modal-container .diffzip-restore-table .col-rev { + width: auto; + min-width: 0; +} + .diffzip-restore-controls { justify-content: space-between; flex-wrap: wrap; diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 8ec077c..9c09a8b 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -2,7 +2,7 @@ This local-only suite installs the built DiffZip plug-in into an isolated vault and profile through `@vrtmrz/obsidian-test-session`. It is not part of the default CI gate. -The restore-confirmation scenario seeds 60 restore entries across three ZIPs and 24 local files represented by mirror deletion records. It invokes the real normal-restore and delete-missing confirmation paths, verifies their rendered Markdown summaries and boundary paths, expands the long file lists, and checks explicit cancellation and Escape dismissal in a real Obsidian Modal. At a fixed phone viewport, the public test-session assertions require the title, Close control, and action row to respect supplied safe-area insets, prevent horizontal overflow in the expanded content and action row, and require 44 CSS-pixel Close, Restore, and Cancel touch targets. It does not use scripted `UiInteractions` responses. +The restore-confirmation scenario seeds 60 restore entries across three ZIPs and 24 local files represented by mirror deletion records. It first opens the revision selector through the real command, expands its folders, and checks that the dialogue, search field, and scrolled action row fit within supplied safe-area insets in portrait, landscape, and with a simulated keyboard height. It compares the content spacing against Obsidian's native `.modal-content` padding and checks that the standard title and Close control remain inside the safe area. It also checks horizontal overflow and 44 CSS-pixel Close, Restore, and Cancel touch targets. It then invokes the real normal-restore and delete-missing confirmation paths, verifies their rendered Markdown summaries and boundary paths, expands the long file lists, and checks explicit cancellation and Escape dismissal in a real Obsidian Modal. At a fixed phone viewport, the public test-session assertions require the title, Close control, and action row to respect supplied safe-area insets, prevent horizontal overflow in the expanded content and action row, and require 44 CSS-pixel Close, Restore, and Cancel touch targets. It does not use scripted `UiInteractions` responses. The mirror-delete-semantics scenario opens the real revision selector and verifies that 'All and delete extra' enables deletion without enabling new-files-only mode. It verifies that the selected historical revision, rather than the current TOC state, decides whether a file is restored or deleted. Missing and unreadable archives, missing ZIP entries, and Vault write failures must reject the restore and preserve deletion candidates. Planning and execution each hold one wake-lock lease, while the confirmation wait holds none. The final large-fixture pass observes the extraction plan without reading fixture ZIPs, verifies that no deletion record is sent for extraction, and verifies that all 24 applicable local files are removed through the active Vault storage accessor. diff --git a/test/e2e-obsidian/restore-confirmation.mts b/test/e2e-obsidian/restore-confirmation.mts index 89039a6..3f08e88 100644 --- a/test/e2e-obsidian/restore-confirmation.mts +++ b/test/e2e-obsidian/restore-confirmation.mts @@ -69,6 +69,7 @@ async function leavePhoneReview(page: Page): Promise { "--safe-area-inset-right", "--safe-area-inset-bottom", "--safe-area-inset-left", + "--keyboard-height", ]) { document.body.style.removeProperty(property); } @@ -76,6 +77,158 @@ async function leavePhoneReview(page: Page): Promise { await page.setViewportSize({ width: 1280, height: 960 }); } +async function verifyRevisionSelectorLayout(testSession: DiffZipTestSession): Promise { + const scenarios = [ + { + label: "portrait", + viewport: { width: 390, height: 844 }, + safeAreaInsets: PHONE_SAFE_AREA_INSETS, + keyboardHeight: 0, + closeWithNativeButton: true, + }, + { + label: "landscape", + viewport: { width: 844, height: 390 }, + safeAreaInsets: { top: 0, right: 47, bottom: 21, left: 47 }, + keyboardHeight: 0, + closeWithNativeButton: false, + }, + { + label: "portrait with keyboard", + viewport: { width: 390, height: 844 }, + safeAreaInsets: PHONE_SAFE_AREA_INSETS, + keyboardHeight: 320, + closeWithNativeButton: false, + }, + ]; + await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + await enterPhoneReview(page); + try { + for (const scenario of scenarios) { + const safeAreaInsets = { + ...scenario.safeAreaInsets, + bottom: Math.max(scenario.safeAreaInsets.bottom, scenario.keyboardHeight), + }; + await page.setViewportSize(scenario.viewport); + await page.evaluate(({ safeAreaInsets, keyboardHeight }) => { + for (const [edge, inset] of Object.entries(safeAreaInsets)) { + document.body.style.setProperty(`--safe-area-inset-${edge}`, `${inset}px`); + } + document.body.style.setProperty("--keyboard-height", `${keyboardHeight}px`); + }, scenario); + const executed = await page.evaluate(() => { + const app = ( + globalThis as typeof globalThis & { + app?: { commands?: { executeCommandById(id: string): boolean } }; + } + ).app; + return app?.commands?.executeCommandById("diffzip:a-find-from-backups") ?? false; + }); + if (!executed) throw new Error("Restore from Backup command was unavailable"); + const modal = page + .locator(".modal-container") + .filter({ has: page.locator(".diffzip-sync-dialog") }) + .last(); + await modal.getByLabel("Restore Mode").waitFor({ state: "visible", timeout: 10_000 }); + await modal.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .filter((animation) => animation.effect?.getComputedTiming().iterations !== Infinity) + .map((animation) => animation.finished.catch(() => undefined)) + ) + ); + await modal.getByRole("button", { name: "Expand All", exact: true }).click(); + const dialogue = modal.locator(".modal"); + await dialogue.evaluate((element) => { + element.scrollTop = 0; + }); + await assertLocatorWithinSafeArea(page, dialogue, { + label: `${scenario.label} revision selector`, + safeAreaInsets, + }); + await assertLocatorWithinSafeArea(page, modal.locator(".diffzip-restore-search"), { + label: `${scenario.label} revision search`, + safeAreaInsets, + }); + await assertNativeContentSpacing(dialogue, modal.locator(".diffzip-restore-search"), "top"); + await assertNoHorizontalOverflow(page, dialogue, { label: `${scenario.label} revision selector` }); + await assertNoHorizontalOverflow(page, modal.locator(".diffzip-sync-table-wrap"), { + label: `${scenario.label} revision list`, + }); + const actions = modal.locator(".diffzip-sync-footer"); + await actions.scrollIntoViewIfNeeded(); + await assertLocatorWithinSafeArea(page, actions, { + label: `${scenario.label} revision actions`, + safeAreaInsets, + }); + await assertNativeContentSpacing(dialogue, actions, "bottom"); + await modal.getByText("Restore (Revision Selector)", { exact: true }).waitFor({ state: "visible" }); + for (const selector of [".modal-title", ".modal-close-button"]) { + await assertLocatorWithinSafeArea(page, modal.locator(selector), { + label: `${scenario.label} revision selector ${selector}`, + safeAreaInsets, + }); + } + await assertLocatorHasMinimumTouchTarget(page, modal.locator(".modal-close-button"), { + label: `${scenario.label} revision selector close button`, + }); + for (const name of ["Restore", "Cancel"]) { + await assertLocatorHasMinimumTouchTarget(page, modal.getByRole("button", { name, exact: true }), { + label: `${scenario.label} ${name} button`, + }); + } + if (scenario.closeWithNativeButton) { + await modal.locator(".modal-close-button").click(); + } else { + await modal.getByRole("button", { name: "Cancel", exact: true }).click(); + } + await modal.waitFor({ state: "detached", timeout: 10_000 }); + } + } finally { + await leavePhoneReview(page); + } + }); +} + +async function assertNativeContentSpacing( + dialogue: Locator, + control: Locator, + verticalEdge: "top" | "bottom" +): Promise { + const padding = await dialogue.evaluate((element) => { + const reference = document.createElement("div"); + reference.className = "modal-content"; + Object.assign(reference.style, { position: "absolute", visibility: "hidden", pointerEvents: "none" }); + element.appendChild(reference); + const style = getComputedStyle(reference); + const result = { + left: Number.parseFloat(style.paddingLeft), + right: Number.parseFloat(style.paddingRight), + top: Number.parseFloat(style.paddingTop), + bottom: Number.parseFloat(style.paddingBottom), + }; + reference.remove(); + return result; + }); + const frame = await dialogue.boundingBox(); + const box = await control.boundingBox(); + if (!frame || !box) throw new Error("Revision selector spacing could not be measured"); + const actual = { + left: box.x - frame.x, + right: frame.x + frame.width - box.x - box.width, + top: box.y - frame.y, + bottom: frame.y + frame.height - box.y - box.height, + }; + for (const edge of ["left", "right", verticalEdge] as const) { + if (actual[edge] + 1 < padding[edge]) { + throw new Error( + `Revision selector ${edge} spacing is ${actual[edge]}px; native content requires ${padding[edge]}px` + ); + } + } +} + async function assertLargeConfirmationLayout(page: Page, modal: Locator, deleteMissing: boolean): Promise { const dialogue = modal.locator(".modal"); const title = dialogue.locator(".modal-title"); @@ -188,6 +341,7 @@ async function main(): Promise { let testSession: DiffZipTestSession | undefined; try { testSession = await startDiffZipTestSession({ restorePlan: "large" }); + await verifyRevisionSelectorLayout(testSession); await verifyCancellation(testSession, { deleteMissing: false, dismissWithEscape: false }); await verifyCancellation(testSession, { deleteMissing: true, dismissWithEscape: true }); console.log("DiffZip large restore and mirror confirmations passed in real Obsidian"); diff --git a/updates.md b/updates.md index 8a97ce6..dc0ac2c 100644 --- a/updates.md +++ b/updates.md @@ -1,5 +1,11 @@ # Updates +## Unreleased + +### Fixed + +- The restore revision selector uses Obsidian's standard content spacing and keeps its search field and action buttons within mobile safe areas when the file list is expanded or the keyboard is shown. + ## 0.1.12 ### Maintenance