From 5402fc417a31fb39c9c46320b45c0e59a467276c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:21:03 +0000 Subject: [PATCH 1/3] Initial plan From 043224e0d06a433252b7e39498a4fcc6f4abc44c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:27:06 +0000 Subject: [PATCH 2/3] Add zoom (+/-), fit, and fullscreen controls to visual notes graph view --- plugins/obsidian-plugin/src/renderer.ts | 86 +++++++++++++++++ plugins/obsidian-plugin/styles.css | 49 ++++++++++ .../test/feature/renderer.test.ts | 92 +++++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/plugins/obsidian-plugin/src/renderer.ts b/plugins/obsidian-plugin/src/renderer.ts index e9241cb..6696019 100644 --- a/plugins/obsidian-plugin/src/renderer.ts +++ b/plugins/obsidian-plugin/src/renderer.ts @@ -16,6 +16,8 @@ export class VisualNotesRenderChild extends MarkdownRenderChild { private readonly sourcePath: string; private readonly removeContainerOnUnload: boolean; private readonly removeDuplicates: boolean; + private isFullscreen = false; + private fullscreenBtn: HTMLElement | null = null; constructor( containerEl: HTMLElement, @@ -39,10 +41,19 @@ export class VisualNotesRenderChild extends MarkdownRenderChild { }); this.registerEvent(this.sidecarEventRef); this.registerEvent(this.plugin.app.workspace.on("css-change", () => this.applyTheme())); + this.registerDomEvent(document, "keydown", (e: KeyboardEvent) => { + if (e.key === "Escape" && this.isFullscreen) { + this.toggleFullscreen(); + } + }); void this.render(); } onunload(): void { + if (this.isFullscreen) { + this.isFullscreen = false; + this.containerEl.removeClass("visual-notes-fullscreen"); + } this.destroyGraph(); if (this.removeContainerOnUnload) { this.containerEl.remove(); @@ -88,6 +99,7 @@ export class VisualNotesRenderChild extends MarkdownRenderChild { } this.renderLegend(header); + this.renderControls(header); const graphEl = this.containerEl.createDiv({ cls: "visual-notes-graph" }); const elements: cytoscape.ElementDefinition[] = [ @@ -176,6 +188,79 @@ export class VisualNotesRenderChild extends MarkdownRenderChild { }); } + private renderControls(parent: HTMLElement): void { + const controls = parent.createDiv({ cls: "visual-notes-controls" }); + + const zoomOutBtn = controls.createEl("button", { + cls: "visual-notes-btn", + attr: { "aria-label": "Zoom out", title: "Zoom out" }, + text: "−", + }); + + const zoomInBtn = controls.createEl("button", { + cls: "visual-notes-btn", + attr: { "aria-label": "Zoom in", title: "Zoom in" }, + text: "+", + }); + + const fitBtn = controls.createEl("button", { + cls: "visual-notes-btn", + attr: { "aria-label": "Fit to view", title: "Fit to view" }, + text: "⊡", + }); + + this.fullscreenBtn = controls.createEl("button", { + cls: "visual-notes-btn", + attr: { + "aria-label": this.isFullscreen ? "Exit fullscreen" : "Enter fullscreen", + title: this.isFullscreen ? "Exit fullscreen" : "Enter fullscreen", + }, + text: this.isFullscreen ? "✕" : "⛶", + }); + + zoomOutBtn.addEventListener("click", () => this.zoomOut()); + zoomInBtn.addEventListener("click", () => this.zoomIn()); + fitBtn.addEventListener("click", () => this.refitGraph()); + this.fullscreenBtn.addEventListener("click", () => this.toggleFullscreen()); + } + + private zoomIn(): void { + if (!this.cy) return; + this.cy.zoom({ + level: this.cy.zoom() * 1.25, + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }, + }); + } + + private zoomOut(): void { + if (!this.cy) return; + this.cy.zoom({ + level: this.cy.zoom() / 1.25, + renderedPosition: { x: this.cy.width() / 2, y: this.cy.height() / 2 }, + }); + } + + private toggleFullscreen(): void { + this.isFullscreen = !this.isFullscreen; + if (this.isFullscreen) { + this.containerEl.addClass("visual-notes-fullscreen"); + } else { + this.containerEl.removeClass("visual-notes-fullscreen"); + } + if (this.fullscreenBtn) { + this.fullscreenBtn.textContent = this.isFullscreen ? "✕" : "⛶"; + this.fullscreenBtn.setAttribute( + "aria-label", + this.isFullscreen ? "Exit fullscreen" : "Enter fullscreen", + ); + this.fullscreenBtn.setAttribute( + "title", + this.isFullscreen ? "Exit fullscreen" : "Enter fullscreen", + ); + } + this.scheduleRefitGraph(); + } + private applyTheme(): void { if (!this.cy) { return; @@ -397,6 +482,7 @@ export class VisualNotesRenderChild extends MarkdownRenderChild { this.refitScheduled = false; this.cy?.destroy(); this.cy = null; + this.fullscreenBtn = null; } private removeDuplicateContainersForSource(): void { diff --git a/plugins/obsidian-plugin/styles.css b/plugins/obsidian-plugin/styles.css index f64719e..5c6e344 100644 --- a/plugins/obsidian-plugin/styles.css +++ b/plugins/obsidian-plugin/styles.css @@ -165,3 +165,52 @@ color: var(--text-muted); padding: 1rem; } + +.visual-notes-controls { + display: flex; + gap: 0.25rem; + align-items: center; + flex-shrink: 0; + padding-top: 0.1rem; +} + +.visual-notes-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-secondary); + color: var(--text-muted); + font-size: 0.85rem; + line-height: 1; + cursor: pointer; + user-select: none; +} + +.visual-notes-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.visual-notes-fullscreen { + position: fixed; + inset: 0; + z-index: var(--layer-notice, 200); + margin: 0; + border-radius: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: auto; +} + +.visual-notes-fullscreen .visual-notes-graph { + flex: 1; + height: auto; + min-height: 0; +} diff --git a/plugins/obsidian-plugin/test/feature/renderer.test.ts b/plugins/obsidian-plugin/test/feature/renderer.test.ts index 11e60ae..5db65a4 100644 --- a/plugins/obsidian-plugin/test/feature/renderer.test.ts +++ b/plugins/obsidian-plugin/test/feature/renderer.test.ts @@ -229,3 +229,95 @@ describe("VisualNotesPlugin — main.ts mount strategy contract", () => { ); }); }); + +describe("VisualNotesRenderChild — zoom and fullscreen controls", () => { + it("renders a controls toolbar in the header", () => { + assert.match( + rendererSource, + /renderControls/, + "renderer must define renderControls", + ); + assert.match( + rendererSource, + /this\.renderControls\(header\)/, + "renderGraph must call this.renderControls(header)", + ); + }); + + it("provides zoom-in and zoom-out methods that operate on cy.zoom", () => { + assert.match(rendererSource, /private zoomIn\(\): void/, "renderer must define zoomIn"); + assert.match(rendererSource, /private zoomOut\(\): void/, "renderer must define zoomOut"); + assert.match( + rendererSource, + /this\.cy\.zoom\(.*level.*this\.cy\.zoom\(\) \* 1\.25/s, + "zoomIn must scale cy.zoom by ×1.25", + ); + assert.match( + rendererSource, + /this\.cy\.zoom\(.*level.*this\.cy\.zoom\(\) \/ 1\.25/s, + "zoomOut must scale cy.zoom by ÷1.25", + ); + }); + + it("provides a toggleFullscreen method that adds/removes the fullscreen CSS class", () => { + assert.match( + rendererSource, + /private toggleFullscreen\(\): void/, + "renderer must define toggleFullscreen", + ); + assert.match( + rendererSource, + /visual-notes-fullscreen/, + "renderer must reference the visual-notes-fullscreen CSS class", + ); + assert.match( + rendererSource, + /this\.containerEl\.addClass\("visual-notes-fullscreen"\)/, + "toggleFullscreen must call addClass('visual-notes-fullscreen') when entering fullscreen", + ); + assert.match( + rendererSource, + /this\.containerEl\.removeClass\("visual-notes-fullscreen"\)/, + "toggleFullscreen must call removeClass('visual-notes-fullscreen') when exiting fullscreen", + ); + }); + + it("exits fullscreen on Escape keydown via registerDomEvent", () => { + assert.match( + rendererSource, + /registerDomEvent\(document, "keydown"/, + "renderer must register a keydown listener on document for Escape handling", + ); + assert.match( + rendererSource, + /e\.key === "Escape" && this\.isFullscreen/, + "keydown handler must check for Escape key and fullscreen state", + ); + }); + + it("clears fullscreen state in onunload to prevent stuck overlay on component destruction", () => { + const onunloadMatch = rendererSource.match(/onunload\(\): void \{[\s\S]*?\n \}/); + assert.ok(onunloadMatch, "renderer must define onunload"); + const body = onunloadMatch[0]; + assert.match( + body, + /this\.isFullscreen/, + "onunload must check this.isFullscreen before cleanup", + ); + assert.match( + body, + /removeClass\("visual-notes-fullscreen"\)/, + "onunload must remove the fullscreen class on component destruction", + ); + }); + + it("includes zoom and fullscreen buttons with accessible labels in renderControls", () => { + const fnMatch = rendererSource.match(/private renderControls\([\s\S]*?\n \}/); + assert.ok(fnMatch, "renderer must define renderControls"); + const body = fnMatch[0]; + assert.match(body, /"Zoom in"/, "renderControls must include a zoom-in button"); + assert.match(body, /"Zoom out"/, "renderControls must include a zoom-out button"); + assert.match(body, /"Fit to view"/, "renderControls must include a fit-to-view button"); + assert.match(body, /fullscreen/, "renderControls must include a fullscreen button"); + }); +}); From b6cf7de54580bf0ed25ab178f1082d63fea97187 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:28:31 +0000 Subject: [PATCH 3/3] Prevent Escape event propagation in fullscreen; tighten test regex patterns --- plugins/obsidian-plugin/src/renderer.ts | 1 + plugins/obsidian-plugin/test/feature/renderer.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/plugins/obsidian-plugin/src/renderer.ts b/plugins/obsidian-plugin/src/renderer.ts index 6696019..f4aadcf 100644 --- a/plugins/obsidian-plugin/src/renderer.ts +++ b/plugins/obsidian-plugin/src/renderer.ts @@ -43,6 +43,7 @@ export class VisualNotesRenderChild extends MarkdownRenderChild { this.registerEvent(this.plugin.app.workspace.on("css-change", () => this.applyTheme())); this.registerDomEvent(document, "keydown", (e: KeyboardEvent) => { if (e.key === "Escape" && this.isFullscreen) { + e.preventDefault(); this.toggleFullscreen(); } }); diff --git a/plugins/obsidian-plugin/test/feature/renderer.test.ts b/plugins/obsidian-plugin/test/feature/renderer.test.ts index 5db65a4..f816c4d 100644 --- a/plugins/obsidian-plugin/test/feature/renderer.test.ts +++ b/plugins/obsidian-plugin/test/feature/renderer.test.ts @@ -249,12 +249,12 @@ describe("VisualNotesRenderChild — zoom and fullscreen controls", () => { assert.match(rendererSource, /private zoomOut\(\): void/, "renderer must define zoomOut"); assert.match( rendererSource, - /this\.cy\.zoom\(.*level.*this\.cy\.zoom\(\) \* 1\.25/s, + /this\.cy\.zoom\([^)]*level[^)]*this\.cy\.zoom\(\) \* 1\.25/s, "zoomIn must scale cy.zoom by ×1.25", ); assert.match( rendererSource, - /this\.cy\.zoom\(.*level.*this\.cy\.zoom\(\) \/ 1\.25/s, + /this\.cy\.zoom\([^)]*level[^)]*this\.cy\.zoom\(\) \/ 1\.25/s, "zoomOut must scale cy.zoom by ÷1.25", ); });