Skip to content

Commit d29e68a

Browse files
ozgesolidkeyclaude
andcommitted
Add image viewer with zoom for PNG, JPG, SVG, and other image formats
Image files now open in a dedicated viewer with toolbar controls: zoom in/out, fit to window, actual size (1:1), and mouse wheel zoom. Checkerboard background shows transparency. Images also appear in the folder panel file tree. Viewer properly cleans up when switching tabs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c925086 commit d29e68a

4 files changed

Lines changed: 216 additions & 16 deletions

File tree

src/main/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1612,6 +1612,10 @@ const TEXT_EXTENSIONS = new Set([
16121612
'.properties', '.env', '.gitignore', '.dockerignore',
16131613
]);
16141614

1615+
const IMAGE_EXTENSIONS = new Set([
1616+
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.bmp', '.webp', '.ico', '.avif',
1617+
]);
1618+
16151619
ipcMain.handle(IPC.READ_FOLDER, async (_, folderPath: string) => {
16161620
try {
16171621
const entries = await fs.promises.readdir(folderPath, { withFileTypes: true });
@@ -1626,7 +1630,7 @@ ipcMain.handle(IPC.READ_FOLDER, async (_, folderPath: string) => {
16261630
if (entry.isFile()) {
16271631
const ext = path.extname(entry.name).toLowerCase();
16281632
// Include text-based files or files without extension
1629-
if (TEXT_EXTENSIONS.has(ext) || ext === '') {
1633+
if (TEXT_EXTENSIONS.has(ext) || IMAGE_EXTENSIONS.has(ext) || ext === '') {
16301634
try {
16311635
const stat = await fs.promises.stat(fullPath);
16321636
files.push({

src/renderer/index.html

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,20 @@
274274
<div id="editor-container" class="editor-container">
275275
<!-- Markdown Preview -->
276276
<div id="markdown-preview" class="markdown-preview hidden"></div>
277+
<!-- Image Viewer -->
278+
<div id="image-viewer" class="image-viewer hidden">
279+
<div class="image-viewer-toolbar">
280+
<button id="btn-image-zoom-in" class="image-viewer-btn" title="Zoom in (+)">+</button>
281+
<span id="image-zoom-level" class="image-zoom-level">100%</span>
282+
<button id="btn-image-zoom-out" class="image-viewer-btn" title="Zoom out (-)"></button>
283+
<button id="btn-image-zoom-fit" class="image-viewer-btn" title="Fit to window">Fit</button>
284+
<button id="btn-image-zoom-actual" class="image-viewer-btn" title="Actual size (1:1)">1:1</button>
285+
<span id="image-dimensions" class="image-dimensions"></span>
286+
</div>
287+
<div class="image-viewer-container" id="image-viewer-container">
288+
<img id="image-viewer-img" draggable="false" />
289+
</div>
290+
</div>
277291
<div id="welcome-message" class="welcome-message">
278292
<img src="assets/logo.png" alt="LOGAN" class="welcome-logo">
279293
<h2>LOGAN</h2>

src/renderer/renderer.ts

Lines changed: 114 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6637,6 +6637,27 @@ async function loadFile(filePath: string, createNewTab: boolean = true): Promise
66376637
return;
66386638
}
66396639

6640+
// Handle image files directly (no backend indexing needed)
6641+
if (isImageFile(filePath)) {
6642+
if (state.activeTabId && createNewTab) {
6643+
saveCurrentTabState();
6644+
}
6645+
if (createNewTab && !existingTab) {
6646+
const newTab = createTab(filePath);
6647+
state.tabs.push(newTab);
6648+
state.activeTabId = newTab.id;
6649+
}
6650+
state.filePath = filePath;
6651+
hideImageViewer(); // reset first
6652+
elements.markdownPreview.classList.add('hidden');
6653+
elements.markdownPreview.innerHTML = '';
6654+
showImageViewer(filePath);
6655+
renderTabBar();
6656+
renderFolderTree();
6657+
updateStatusBar();
6658+
return;
6659+
}
6660+
66406661
showProgress('Indexing file...');
66416662

66426663
const unsubscribe = window.api.onIndexingProgress((progress) => {
@@ -6797,6 +6818,7 @@ async function loadFile(filePath: string, createNewTab: boolean = true): Promise
67976818
isMarkdownFile = false;
67986819
elements.markdownPreview.classList.add('hidden');
67996820
elements.markdownPreview.innerHTML = '';
6821+
hideImageViewer();
68006822
elements.btnWordWrap.textContent = 'Wrap';
68016823
elements.btnWordWrap.title = 'Toggle word wrap (⌥Z)';
68026824
const wrapper = document.querySelector('.log-viewer-wrapper') as HTMLElement;
@@ -9307,6 +9329,76 @@ function isMarkdownExtension(filePath: string): boolean {
93079329
return ext === 'md' || ext === 'markdown';
93089330
}
93099331

9332+
const IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'bmp', 'webp', 'ico', 'avif']);
9333+
9334+
function isImageFile(filePath: string): boolean {
9335+
const ext = filePath.toLowerCase().split('.').pop() || '';
9336+
return IMAGE_EXTENSIONS.has(ext);
9337+
}
9338+
9339+
// Image viewer state
9340+
let imageZoom = 1;
9341+
const imageViewerEl = document.getElementById('image-viewer') as HTMLDivElement;
9342+
const imageViewerContainer = document.getElementById('image-viewer-container') as HTMLDivElement;
9343+
const imageViewerImg = document.getElementById('image-viewer-img') as HTMLImageElement;
9344+
const imageZoomLevelEl = document.getElementById('image-zoom-level') as HTMLSpanElement;
9345+
const imageDimensionsEl = document.getElementById('image-dimensions') as HTMLSpanElement;
9346+
9347+
function showImageViewer(filePath: string): void {
9348+
imageZoom = 1;
9349+
imageViewerImg.src = '';
9350+
imageViewerEl.classList.remove('hidden');
9351+
9352+
// Hide log viewer
9353+
const wrapper = document.querySelector('.log-viewer-wrapper') as HTMLElement;
9354+
if (wrapper) wrapper.style.display = 'none';
9355+
9356+
// Load image using file:// protocol
9357+
imageViewerImg.onload = () => {
9358+
imageDimensionsEl.textContent = `${imageViewerImg.naturalWidth} × ${imageViewerImg.naturalHeight}`;
9359+
fitImageToWindow();
9360+
};
9361+
imageViewerImg.src = `file://${filePath}`;
9362+
}
9363+
9364+
function hideImageViewer(): void {
9365+
imageViewerEl.classList.add('hidden');
9366+
imageViewerImg.src = '';
9367+
imageDimensionsEl.textContent = '';
9368+
const wrapper = document.querySelector('.log-viewer-wrapper') as HTMLElement;
9369+
if (wrapper) wrapper.style.display = '';
9370+
}
9371+
9372+
function setImageZoom(zoom: number): void {
9373+
imageZoom = Math.max(0.05, Math.min(zoom, 20));
9374+
imageViewerImg.style.width = `${imageViewerImg.naturalWidth * imageZoom}px`;
9375+
imageViewerImg.style.height = `${imageViewerImg.naturalHeight * imageZoom}px`;
9376+
imageZoomLevelEl.textContent = `${Math.round(imageZoom * 100)}%`;
9377+
}
9378+
9379+
function fitImageToWindow(): void {
9380+
const containerW = imageViewerContainer.clientWidth - 40;
9381+
const containerH = imageViewerContainer.clientHeight - 40;
9382+
const imgW = imageViewerImg.naturalWidth;
9383+
const imgH = imageViewerImg.naturalHeight;
9384+
if (imgW === 0 || imgH === 0) return;
9385+
const scale = Math.min(containerW / imgW, containerH / imgH, 1);
9386+
setImageZoom(scale);
9387+
}
9388+
9389+
// Image viewer controls
9390+
document.getElementById('btn-image-zoom-in')?.addEventListener('click', () => setImageZoom(imageZoom * 1.25));
9391+
document.getElementById('btn-image-zoom-out')?.addEventListener('click', () => setImageZoom(imageZoom / 1.25));
9392+
document.getElementById('btn-image-zoom-fit')?.addEventListener('click', fitImageToWindow);
9393+
document.getElementById('btn-image-zoom-actual')?.addEventListener('click', () => setImageZoom(1));
9394+
9395+
// Mouse wheel zoom on image
9396+
imageViewerContainer?.addEventListener('wheel', (e) => {
9397+
e.preventDefault();
9398+
const delta = e.deltaY > 0 ? 0.9 : 1.1;
9399+
setImageZoom(imageZoom * delta);
9400+
}, { passive: false });
9401+
93109402
async function renderMarkdownPreview(): Promise<void> {
93119403
if (!state.filePath || !isMarkdownFile) return;
93129404

@@ -11211,23 +11303,30 @@ async function switchToTab(tabId: string): Promise<void> {
1121111303
// Restore tab state
1121211304
restoreTabState(tab);
1121311305

11214-
// Handle markdown preview state for the new tab
11215-
isMarkdownFile = isMarkdownExtension(tab.filePath);
11216-
if (isMarkdownFile) {
11217-
markdownPreviewMode = true;
11218-
elements.btnWordWrap.textContent = 'Raw';
11219-
elements.btnWordWrap.title = 'Show raw markdown';
11220-
await renderMarkdownPreview();
11221-
showMarkdownPreview();
11222-
} else {
11223-
markdownPreviewMode = false;
11306+
// Handle special file viewers for the new tab
11307+
if (isImageFile(tab.filePath)) {
1122411308
elements.markdownPreview.classList.add('hidden');
1122511309
elements.markdownPreview.innerHTML = '';
11226-
elements.btnWordWrap.textContent = 'Wrap';
11227-
elements.btnWordWrap.title = 'Toggle word wrap (⌥Z)';
11228-
const wrapper = document.querySelector('.log-viewer-wrapper') as HTMLElement;
11229-
if (wrapper) {
11230-
wrapper.style.display = '';
11310+
showImageViewer(tab.filePath);
11311+
} else {
11312+
hideImageViewer();
11313+
isMarkdownFile = isMarkdownExtension(tab.filePath);
11314+
if (isMarkdownFile) {
11315+
markdownPreviewMode = true;
11316+
elements.btnWordWrap.textContent = 'Raw';
11317+
elements.btnWordWrap.title = 'Show raw markdown';
11318+
await renderMarkdownPreview();
11319+
showMarkdownPreview();
11320+
} else {
11321+
markdownPreviewMode = false;
11322+
elements.markdownPreview.classList.add('hidden');
11323+
elements.markdownPreview.innerHTML = '';
11324+
elements.btnWordWrap.textContent = 'Wrap';
11325+
elements.btnWordWrap.title = 'Toggle word wrap (⌥Z)';
11326+
const wrapper = document.querySelector('.log-viewer-wrapper') as HTMLElement;
11327+
if (wrapper) {
11328+
wrapper.style.display = '';
11329+
}
1123111330
}
1123211331
}
1123311332

src/renderer/styles.css

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4123,6 +4123,89 @@ kbd {
41234123
height: auto;
41244124
}
41254125

4126+
/* Image Viewer */
4127+
.image-viewer {
4128+
position: absolute;
4129+
top: 0;
4130+
left: 0;
4131+
right: 0;
4132+
bottom: 0;
4133+
display: flex;
4134+
flex-direction: column;
4135+
background: var(--bg-primary);
4136+
z-index: 5;
4137+
}
4138+
4139+
.image-viewer.hidden {
4140+
display: none;
4141+
}
4142+
4143+
.image-viewer-toolbar {
4144+
display: flex;
4145+
align-items: center;
4146+
gap: 6px;
4147+
padding: 6px 12px;
4148+
background: var(--bg-secondary);
4149+
border-bottom: 1px solid var(--border-color);
4150+
flex-shrink: 0;
4151+
}
4152+
4153+
.image-viewer-btn {
4154+
padding: 4px 10px;
4155+
background: var(--bg-tertiary);
4156+
border: 1px solid var(--border-color);
4157+
border-radius: 3px;
4158+
color: var(--text-primary);
4159+
cursor: pointer;
4160+
font-size: 13px;
4161+
}
4162+
4163+
.image-viewer-btn:hover {
4164+
background: var(--bg-hover);
4165+
}
4166+
4167+
.image-zoom-level {
4168+
font-size: 12px;
4169+
color: var(--text-secondary);
4170+
min-width: 40px;
4171+
text-align: center;
4172+
}
4173+
4174+
.image-dimensions {
4175+
font-size: 11px;
4176+
color: var(--text-muted);
4177+
margin-left: auto;
4178+
}
4179+
4180+
.image-viewer-container {
4181+
flex: 1;
4182+
overflow: auto;
4183+
display: flex;
4184+
align-items: center;
4185+
justify-content: center;
4186+
cursor: grab;
4187+
/* Checkerboard background for transparency */
4188+
background-image:
4189+
linear-gradient(45deg, var(--bg-tertiary) 25%, transparent 25%),
4190+
linear-gradient(-45deg, var(--bg-tertiary) 25%, transparent 25%),
4191+
linear-gradient(45deg, transparent 75%, var(--bg-tertiary) 75%),
4192+
linear-gradient(-45deg, transparent 75%, var(--bg-tertiary) 75%);
4193+
background-size: 20px 20px;
4194+
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
4195+
}
4196+
4197+
.image-viewer-container:active {
4198+
cursor: grabbing;
4199+
}
4200+
4201+
.image-viewer-container img {
4202+
max-width: none;
4203+
max-height: none;
4204+
user-select: none;
4205+
-webkit-user-drag: none;
4206+
transition: transform 0.1s ease;
4207+
}
4208+
41264209
/* Advanced Filter Modal */
41274210
.advanced-filter-modal-content {
41284211
width: 600px;

0 commit comments

Comments
 (0)