Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions plugins/obsidian-plugin/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -39,10 +41,20 @@ 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) {
e.preventDefault();
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();
Expand Down Expand Up @@ -88,6 +100,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[] = [
Expand Down Expand Up @@ -176,6 +189,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;
Expand Down Expand Up @@ -397,6 +483,7 @@ export class VisualNotesRenderChild extends MarkdownRenderChild {
this.refitScheduled = false;
this.cy?.destroy();
this.cy = null;
this.fullscreenBtn = null;
}

private removeDuplicateContainersForSource(): void {
Expand Down
49 changes: 49 additions & 0 deletions plugins/obsidian-plugin/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
92 changes: 92 additions & 0 deletions plugins/obsidian-plugin/test/feature/renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});