项目简介

imv 是一款极简主义图片查看器,支持拖拽上传、多图浏览、缩放旋转等功能。本项目采用 Electron 核心功能 + 鸿蒙壳工程 的架构模式,与 ristretto 形成差异化定位:imv 强调键盘驱动和极简交互,而 ristretto 采用传统桌面应用风格。

欢迎加入开源鸿蒙PC社区:https://harmonypc.csdn.net/

欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_imv_electron

核心功能

  • 🖼️ 拖拽上传 - 支持单张/多张图片拖拽
  • 📂 点击上传 - 点击拖拽区域选择文件
  • ◀▶ 智能导航 - 左右箭头自动显示/隐藏
  • 🔍 缩放旋转 - 快捷键驱动,实时预览
  • ℹ️ 图片信息 - 文件名、尺寸、大小、类型
  • ⌨️ 键盘驱动 - 完整快捷键支持
  • 🎨 极简设计 - 纯黑背景 + 悬停控制栏

一、技术架构

1.1 原始架构(Linux GNOME)

imv (C/GTK)
├── 图片渲染:GDK Pixbuf
├── UI 渲染:GTK+ 3.0
├── 文件浏览:GIO
└── 配置管理:Config files

1.2 目标架构(鸿蒙 Electron)

鸿蒙壳工程 (ArkTS)
└── web_engine 模块 (XComponent WebView)
    └── Electron 应用 (HTML/CSS/JavaScript)
        ├── main.js - Electron 主进程
        ├── renderer.js - 渲染进程(核心逻辑)
        ├── index.html - UI 界面
        └── styles/imv.css - 样式文件

1.3 架构优势

  • 跨平台:Electron 代码可在 Windows/macOS/Linux 复用
  • 快速开发:Web 技术栈,开发效率高
  • 极简设计:悬停控制栏 + 键盘驱动,差异化体验
  • 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题

二、环境准备

2.1 开发环境要求

  • 操作系统:Windows 10
  • 开发工具:DevEco Studio(鸿蒙官方 IDE)
  • HarmonyOS SDK:API 15+
  • Node.js:v24+(Electron 依赖)

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── imv/                        # Electron 图片查看器源码
│       ├── main.js                 # 主进程(窗口管理、IPC)
│       ├── renderer.js             # 渲染进程(UI 交互逻辑)
│       ├── index.html              # 界面结构
│       ├── package.json            # 项目配置
│       └── styles/
│           └── imv.css             # 样式文件
├── web_engine/                     # 鸿蒙 web_engine 模块
│   └── src/main/resources/
│       └── resfile/resources/app/  # 部署目录
│           ├── main.js
│           ├── renderer.js
│           ├── index.html
│           └── styles/imv.css
└── build-profile.json5             # 鸿蒙构建配置

三、核心适配流程

3.1 第一步:创建 Electron 主进程

// imv Image Viewer - Electron 主进程
const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const path = require('path');
const fs = require('fs');

let mainWindow = null;

function createWindow() {
  console.log('imv: Creating window...');
  
  // 获取屏幕尺寸
  const primaryDisplay = screen.getPrimaryDisplay();
  const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize;
  
  // 窗口配置:占据大部分屏幕
  const windowWidth = Math.floor(screenWidth * 0.9);
  const windowHeight = Math.floor(screenHeight * 0.9);
  
  mainWindow = new BrowserWindow({
    width: windowWidth,
    height: windowHeight,
    x: Math.floor((screenWidth - windowWidth) / 2),
    y: Math.floor((screenHeight - windowHeight) / 2),
    frame: true,
    transparent: false,
    alwaysOnTop: false,
    hasShadow: true,
    resizable: true,
    focusable: true,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
      backgroundThrottling: false
    }
  });

  console.log('imv: Loading index.html from:', path.join(__dirname, 'index.html'));
  mainWindow.loadFile(path.join(__dirname, 'index.html'));
  
  console.log('imv: Window created with size:', windowWidth, 'x', windowHeight);

  mainWindow.on('closed', () => {
    console.log('imv: Window closed');
    mainWindow = null;
  });
  
  mainWindow.webContents.on('did-finish-load', () => {
    console.log('imv: Page loaded successfully');
  });
  
  mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
    console.error('imv: Page failed to load:', errorCode, errorDescription);
  });

  setupIpcHandlers();
}

app.whenReady().then(() => {
  createWindow();
  console.log('imv Image Viewer 已启动');
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

关键要点

  • 使用 screen.getPrimaryDisplay() 获取屏幕尺寸,动态计算窗口大小
  • 窗口占据 90% 屏幕宽度和高度,并自动居中
  • 设置 backgroundThrottling: false 保证后台性能
  • 添加详细的 console.log 用于调试和日志追踪

3.2 第二步:实现文件对话框与图片信息

function setupIpcHandlers() {
  console.log('imv: Setting up IPC handlers');
  
  // 打开文件对话框
  ipcMain.handle('open-file-dialog', async () => {
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openFile', 'multiSelections'],
      filters: [
        { 
          name: 'Images', 
          extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif'] 
        },
        { name: 'All Files', extensions: ['*'] }
      ]
    });
    
    if (!result.canceled && result.filePaths.length > 0) {
      return result.filePaths;
    }
    return null;
  });

  // 打开文件夹对话框
  ipcMain.handle('open-directory-dialog', async () => {
    const result = await dialog.showOpenDialog(mainWindow, {
      properties: ['openDirectory']
    });
    
    if (!result.canceled && result.filePaths.length > 0) {
      return result.filePaths[0];
    }
    return null;
  });

  // 读取图片文件信息
  ipcMain.handle('get-image-info', async (event, filePath) => {
    try {
      const stats = fs.statSync(filePath);
      return {
        path: filePath,
        name: path.basename(filePath),
        size: stats.size,
        modified: stats.mtime,
        ext: path.extname(filePath)
      };
    } catch (error) {
      console.error('imv: Failed to get image info:', error);
      return null;
    }
  });

  // 获取目录中的所有图片
  ipcMain.handle('get-directory-images', async (event, dirPath) => {
    try {
      const files = fs.readdirSync(dirPath);
      const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.ico', '.tiff', '.tif'];
      
      const imageFiles = files
        .filter(file => imageExtensions.includes(path.extname(file).toLowerCase()))
        .map(file => path.join(dirPath, file));
      
      return imageFiles;
    } catch (error) {
      console.error('imv: Failed to read directory:', error);
      return [];
    }
  });

  // 保存文件对话框
  ipcMain.handle('save-file-dialog', async (event, defaultPath) => {
    const result = await dialog.showSaveDialog(mainWindow, {
      defaultPath: defaultPath,
      filters: [
        { name: 'PNG', extensions: ['png'] },
        { name: 'JPEG', extensions: ['jpg', 'jpeg'] },
        { name: 'WebP', extensions: ['webp'] }
      ]
    });
    
    return result.filePath;
  });
}

关键要点

  • 支持多文件选择(multiSelections)
  • 图片格式过滤:JPG、PNG、GIF、WebP、SVG 等 10 种格式
  • 新增 get-image-info 获取文件元数据(大小、修改时间、扩展名)
  • 新增 get-directory-images 批量读取文件夹中的图片
  • 新增 save-file-dialog 支持图片导出

3.3 第三步:设计极简主义 UI

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' file: data:;">
  <title>imv - 极简图片查看器</title>
  <link rel="stylesheet" href="styles/imv.css">
</head>
<body>
  <!-- 全屏图片显示区 -->
  <div id="image-viewer" class="image-viewer">
    <img id="current-image" class="current-image" src="" alt="">
    
    <!-- 左右导航箭头 -->
    <button id="nav-prev" class="nav-arrow nav-prev" title="上一张 (←)">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <polyline points="15 18 9 12 15 6"></polyline>
      </svg>
    </button>
    <button id="nav-next" class="nav-arrow nav-next" title="下一张 (→)">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
        <polyline points="9 18 15 12 9 6"></polyline>
      </svg>
    </button>
    
    <!-- 初始占位符 -->
    <div id="placeholder" class="placeholder">
      <div class="logo">imv</div>
      <div class="tagline">极简图片查看器</div>
      <div class="drop-zone" id="drop-zone">
        <div class="drop-icon">📂</div>
        <div class="drop-text">点击或拖拽图片到此处</div>
        <div class="drop-hint">支持 JPGPNGGIF、WebP 等格式</div>
      </div>
    </div>

    <!-- 图片信息覆盖层 -->
    <div id="info-overlay" class="info-overlay hidden">
      <div class="info-content">
        <div class="info-row">
          <span class="info-label">文件名</span>
          <span id="info-name" class="info-value"></span>
        </div>
        <div class="info-row">
          <span class="info-label">尺寸</span>
          <span id="info-dimensions" class="info-value"></span>
        </div>
        <div class="info-row">
          <span class="info-label">大小</span>
          <span id="info-size" class="info-value"></span>
        </div>
        <div class="info-row">
          <span class="info-label">类型</span>
          <span id="info-type" class="info-value"></span>
        </div>
        <div class="info-hint"><kbd>i</kbd> 关闭</div>
      </div>
    </div>

    <!-- 缩放指示器 -->
    <div id="zoom-indicator" class="zoom-indicator hidden">100%</div>
  </div>

  <!-- 顶部控制栏(鼠标悬停显示) -->
  <div id="top-bar" class="top-bar">
    <button id="btn-open" class="control-btn" title="打开 (o)">📂 打开</button>
    <button id="btn-prev" class="control-btn" title="上一张 (h)"></button>
    <button id="btn-next" class="control-btn" title="下一张 (l)"></button>
    <div class="control-separator"></div>
    <button id="btn-zoom-out" class="control-btn" title="缩小 (-)">🔍-</button>
    <button id="btn-zoom-fit" class="control-btn" title="适应 (f)"></button>
    <button id="btn-zoom-in" class="control-btn" title="放大 (+)">🔍+</button>
    <div class="control-separator"></div>
    <button id="btn-rotate-left" class="control-btn" title="左旋 (R)"></button>
    <button id="btn-rotate-right" class="control-btn" title="右旋 (r)"></button>
    <div class="control-spacer"></div>
    <span id="image-counter" class="image-counter"></span>
  </div>

  <!-- 底部状态栏(鼠标悬停显示) -->
  <div id="bottom-bar" class="bottom-bar">
    <span id="status-text">就绪</span>
    <span id="zoom-level">100%</span>
  </div>

  <!-- 快捷键帮助(固定底部显示) -->
  <div id="shortcuts-bar" class="shortcuts-bar">
    <div class="shortcuts-content">
      <span class="shortcut-item"><kbd></kbd> <kbd></kbd>/下一张</span>
      <span class="shortcut-separator">·</span>
      <span class="shortcut-item"><kbd>+</kbd> <kbd>-</kbd> 放大/缩小</span>
      <span class="shortcut-separator">·</span>
      <span class="shortcut-item"><kbd>r</kbd> 旋转</span>
      <span class="shortcut-separator">·</span>
      <span class="shortcut-item"><kbd>i</kbd> 信息</span>
      <span class="shortcut-separator">·</span>
      <span class="shortcut-item"><kbd>q</kbd> 返回</span>
      <span class="shortcut-separator">·</span>
      <span class="shortcut-item"><kbd>F11</kbd> 全屏</span>
    </div>
  </div>

  <script src="renderer.js"></script>
</body>
</html>

关键要点

  • 使用 SVG 矢量图标作为导航箭头
  • 占位符包含 Logo、拖拽区域,垂直居中偏上显示
  • 顶部/底部控制栏默认隐藏,鼠标悬停显示
  • 快捷键栏固定底部,始终可见
  • 图片信息覆盖层按 i 键显示

3.4 第四步:实现拖拽上传与图片加载

// imv - 极简图片查看器渲染进程
const { ipcRenderer } = require('electron');

let imageList = [];
let currentIndex = -1;
let zoomLevel = 100;
let rotation = 0;
let showInfo = false;
let zoomTimeout = null;

const currentImage = document.getElementById('current-image');
const placeholder = document.getElementById('placeholder');
const infoOverlay = document.getElementById('info-overlay');
const zoomIndicator = document.getElementById('zoom-indicator');
const zoomLevelEl = document.getElementById('zoom-level');
const imageCounter = document.getElementById('image-counter');
const statusText = document.getElementById('status-text');
const navPrev = document.getElementById('nav-prev');
const navNext = document.getElementById('nav-next');

// 控制栏按钮
document.getElementById('btn-open').addEventListener('click', openFiles);
document.getElementById('btn-prev').addEventListener('click', prevImage);
document.getElementById('btn-next').addEventListener('click', nextImage);
document.getElementById('btn-zoom-in').addEventListener('click', () => setZoom(zoomLevel + 25));
document.getElementById('btn-zoom-out').addEventListener('click', () => setZoom(zoomLevel - 25));
document.getElementById('btn-zoom-fit').addEventListener('click', fitToWindow);
document.getElementById('btn-rotate-left').addEventListener('click', () => rotate(-90));
document.getElementById('btn-rotate-right').addEventListener('click', () => rotate(90));

// 导航箭头点击事件
navPrev.addEventListener('click', prevImage);
navNext.addEventListener('click', nextImage);

// 键盘快捷键
document.addEventListener('keydown', (e) => {
  switch(e.key) {
    case 'o': openFiles(); break;
    case 'h': case 'ArrowLeft': prevImage(); break;
    case 'l': case 'ArrowRight': nextImage(); break;
    case '+': case '=': setZoom(zoomLevel + 25); break;
    case '-': setZoom(zoomLevel - 25); break;
    case 'f': fitToWindow(); break;
    case '1': setZoom(100); break;
    case 'r': rotate(90); break;
    case 'R': rotate(-90); break;
    case 'i': toggleInfo(); break;
    case 'q': case 'Escape': closeImage(); break;
    case 'F11': toggleFullscreen(); break;
  }
});

// 拖拽支持
const dropZone = document.getElementById('drop-zone');
const imageViewer = document.getElementById('image-viewer');
let dragCounter = 0;

['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
  document.body.addEventListener(eventName, preventDefaults, false);
});

function preventDefaults(e) {
  e.preventDefault();
  e.stopPropagation();
}

document.body.addEventListener('dragenter', (e) => {
  dragCounter++;
  if (e.dataTransfer.types.includes('Files')) {
    document.body.classList.add('drag-over');
  }
});

document.body.addEventListener('dragleave', (e) => {
  dragCounter--;
  if (dragCounter === 0 || e.relatedTarget === null) {
    document.body.classList.remove('drag-over');
    dragCounter = 0;
  }
});

document.body.addEventListener('drop', async (e) => {
  dragCounter = 0;
  document.body.classList.remove('drag-over');
  const files = Array.from(e.dataTransfer.files);
  const imageFiles = files.filter(f => f.type.startsWith('image/'));
  
  if (imageFiles.length > 0) {
    // 保存所有拖拽的图片
    imageList = imageFiles;
    currentIndex = 0;
    
    // 读取第一张图片显示
    loadImageFromDrop(imageFiles[0]);
    
    // 更新计数器
    imageCounter.textContent = `1 / ${imageFiles.length}`;
    statusText.textContent = `已加载 ${imageFiles.length} 张图片`;
  }
});

关键要点

  • 使用 dragCounter 避免拖拽时闪烁问题
  • 拖拽多张图片时保存到 imageList 数组
  • 支持键盘快捷键:h/l 浏览、+/- 缩放、r 旋转、q 返回
  • 导航箭头绑定点击事件,支持鼠标操作

3.5 第五步:实现 FileReader 图片读取

// 从拖拽加载单张图片
function loadImageFromDrop(file) {
  const reader = new FileReader();
  
  reader.onload = (event) => {
    currentImage.src = event.target.result;
    currentImage.style.display = 'block';
    placeholder.classList.add('hidden');
    
    // 更新图片信息
    updateImageFileInfo(file);
    
    // 重置变换
    zoomLevel = 100;
    rotation = 0;
    updateImageTransform();
    
    // 显示缩放指示器
    showZoomIndicator();
  };
  
  reader.onerror = () => {
    statusText.textContent = '加载失败';
    console.error('读取图片失败:', reader.error);
  };
  
  reader.readAsDataURL(file);
}

// 点击上传
function handleUpload() {
  openFiles();
}

// 绑定点击事件到拖拽区域
dropZone.addEventListener('click', handleUpload);
dropZone.style.cursor = 'pointer';

// 打开文件
async function openFiles() {
  const filePaths = await ipcRenderer.invoke('open-file-dialog');
  if (filePaths && filePaths.length > 0) {
    imageList = filePaths;
    currentIndex = 0;
    loadImage(currentIndex);
  }
}

// 加载图片
function loadImage(index) {
  if (index < 0 || index >= imageList.length) return;
  
  const item = imageList[index];
  
  // 判断是 File 对象还是文件路径
  if (item instanceof File) {
    // 拖拽的文件,使用 FileReader 读取
    loadImageFromDrop(item);
  } else {
    // 文件路径,使用 file:// 协议
    currentImage.src = `file://${item}`;
    currentImage.style.display = 'block';
    placeholder.classList.add('hidden');
    
    // 获取图片信息
    ipcRenderer.invoke('get-image-info', item).then(info => {
      if (info) {
        updateImageInfo(info);
      }
    });
    
    // 重置变换
    zoomLevel = 100;
    rotation = 0;
    updateImageTransform();
  }
  
  // 更新计数器
  imageCounter.textContent = `${index + 1} / ${imageList.length}`;
  statusText.textContent = `已加载: ${index + 1}/${imageList.length}`;
  
  // 更新导航箭头
  updateNavArrows();
  
  // 显示缩放指示器
  showZoomIndicator();
}

关键要点

  • 拖拽使用 FileReader.readAsDataURL() 读取图片为 Base64
  • 对话框打开使用 file:// 协议直接加载
  • loadImage 自动判断是 File 对象还是文件路径
  • 点击拖拽区域触发文件选择对话框
  • 每次加载图片后更新导航箭头状态

3.6 第六步:实现智能导航箭头

// 更新导航箭头显示状态
function updateNavArrows() {
  if (imageList.length <= 1) {
    // 只有一张图片,隐藏所有箭头
    navPrev.classList.remove('visible');
    navNext.classList.remove('visible');
  } else if (currentIndex === 0) {
    // 第一张,只显示右箭头
    navPrev.classList.remove('visible');
    navNext.classList.add('visible');
  } else if (currentIndex === imageList.length - 1) {
    // 最后一张,只显示左箭头
    navPrev.classList.add('visible');
    navNext.classList.remove('visible');
  } else {
    // 中间图片,显示左右箭头
    navPrev.classList.add('visible');
    navNext.classList.add('visible');
  }
}

// 上一张
function prevImage() {
  if (imageList.length === 0) return;
  currentIndex = (currentIndex - 1 + imageList.length) % imageList.length;
  loadImage(currentIndex);
  showZoomIndicator();
}

// 下一张
function nextImage() {
  if (imageList.length === 0) return;
  currentIndex = (currentIndex + 1) % imageList.length;
  loadImage(currentIndex);
  showZoomIndicator();
}

关键要点

  • 单张图片时隐藏所有导航箭头
  • 第一张只显示右箭头,最后一张只显示左箭头
  • 中间图片显示左右双箭头
  • 使用 classList.add/remove(‘visible’) 控制显示

3.7 第七步:实现缩放旋转与信息面板

// 更新图片变换
function updateImageTransform() {
  currentImage.style.transform = `rotate(${rotation}deg) scale(${zoomLevel / 100})`;
  zoomLevelEl.textContent = `${zoomLevel}%`;
  
  // 显示缩放指示器
  showZoomIndicator();
}

// 设置缩放
function setZoom(level) {
  zoomLevel = Math.max(10, Math.min(500, level));
  updateImageTransform();
}

// 适应窗口
function fitToWindow() {
  zoomLevel = 100;
  updateImageTransform();
  statusText.textContent = '适应窗口';
}

// 旋转
function rotate(deg) {
  rotation = (rotation + deg) % 360;
  updateImageTransform();
}

// 更新文件信息(拖拽用)
function updateImageFileInfo(file) {
  const name = file.name;
  const ext = name.substring(name.lastIndexOf('.'));
  
  document.getElementById('info-name').textContent = name;
  document.getElementById('info-size').textContent = formatFileSize(file.size);
  document.getElementById('info-type').textContent = ext.toUpperCase();
  
  // 获取图片尺寸
  const img = new Image();
  img.onload = () => {
    document.getElementById('info-dimensions').textContent = `${img.naturalWidth} × ${img.naturalHeight}`;
  };
  img.src = currentImage.src;
}

// 更新图片信息面板
function updateImageInfo(info) {
  document.getElementById('info-name').textContent = info.name;
  document.getElementById('info-size').textContent = formatFileSize(info.size);
  document.getElementById('info-type').textContent = info.ext.toUpperCase().substring(1);
  
  // 获取图片尺寸
  const img = new Image();
  img.onload = () => {
    document.getElementById('info-dimensions').textContent = `${img.naturalWidth} × ${img.naturalHeight}`;
  };
  img.src = currentImage.src;
}

// 切换信息面板
function toggleInfo() {
  showInfo = !showInfo;
  if (showInfo) {
    infoOverlay.classList.remove('hidden');
  } else {
    infoOverlay.classList.add('hidden');
  }
}

// 格式化文件大小
function formatFileSize(bytes) {
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
  return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}

关键要点

  • 使用 CSS transform 实现缩放和旋转
  • 缩放范围限制在 10% - 500%
  • 拖拽和对话框两种方式更新图片信息
  • 使用 Image 对象获取图片宽高尺寸
  • 文件大小格式化:B → KB → MB

3.8 第八步:实现返回初始页与缩放指示器

// 关闭图片,回到初始页面
function closeImage() {
  // 清空图片列表
  imageList = [];
  currentIndex = -1;
  
  // 隐藏图片,显示占位符
  currentImage.src = '';
  currentImage.style.display = 'none';
  placeholder.classList.remove('hidden');
  
  // 隐藏导航箭头
  navPrev.classList.remove('visible');
  navNext.classList.remove('visible');
  
  // 重置状态
  zoomLevel = 100;
  rotation = 0;
  showInfo = false;
  infoOverlay.classList.add('hidden');
  
  // 更新状态栏
  statusText.textContent = '就绪 - 点击或拖拽图片';
  imageCounter.textContent = '';
  
  console.log('已关闭图片,回到初始页面');
}

// 显示缩放指示器
function showZoomIndicator() {
  zoomIndicator.textContent = `${zoomLevel}%`;
  zoomIndicator.classList.remove('hidden');
  
  // 强制重绘以重新触发动画
  void zoomIndicator.offsetWidth;
  zoomIndicator.style.animation = 'none';
  void zoomIndicator.offsetWidth;
  zoomIndicator.style.animation = '';
  
  if (zoomTimeout) clearTimeout(zoomTimeout);
  zoomTimeout = setTimeout(() => {
    zoomIndicator.classList.add('hidden');
  }, 800);
}

关键要点

  • closeImage 清空图片列表,回到欢迎页
  • 使用 classList 控制显隐,避免直接操作 display
  • 强制重绘确保缩放动画每次都能触发
  • 800ms 后自动隐藏缩放指示器

3.9 第九步:编写极简主义样式

/* imv - 极简主义风格 */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
  background: #000;
  color: #fff;
  overflow: hidden;
  height: 100vh;
  cursor: none;
  transition: cursor 0.3s;
}

body:hover {
  cursor: default;
}

/* 图片显示区 - 全屏 */
.image-viewer {
  width: 100vw;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  background: #000;
}

.current-image {
  max-width: 100vw;
  max-height: 100vh;
  object-fit: contain;
  transition: transform 0.15s ease-out;
  user-select: none;
  -webkit-user-drag: none;
}

/* 左右导航箭头 */
.nav-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 60px;
  height: 60px;
  background: rgba(0, 0, 0, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 50%;
  color: #fff;
  cursor: pointer;
  display: none;
  align-items: center;
  justify-content: center;
  transition: all 0.3s;
  backdrop-filter: blur(10px);
  z-index: 30;
}

.nav-arrow:hover {
  background: rgba(102, 126, 234, 0.6);
  border-color: rgba(102, 126, 234, 0.8);
  transform: translateY(-50%) scale(1.1);
}

.nav-arrow:active {
  transform: translateY(-50%) scale(0.95);
}

/* 显示导航箭头(当有多张图片时) */
.nav-arrow.visible {
  display: flex;
}

/* 占位符垂直居中偏上 */
.placeholder {
  text-align: center;
  padding: 40px;
  animation: fadeIn 0.5s ease-out;
  max-width: 700px;
  margin: 0 auto;
  position: absolute;
  top: 42%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 90%;
}

/* 隐藏占位符 */
.placeholder.hidden {
  display: none !important;
}

@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

.logo {
  font-size: 120px;
  font-weight: 900;
  letter-spacing: -6px;
  margin-bottom: 16px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
  line-height: 1;
}

/* 顶部控制栏 - 悬停显示 */
.top-bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  background: linear-gradient(180deg, rgba(0,0,0,0.95) 0%, rgba(0,0,0,0.7) 70%, rgba(0,0,0,0) 100%);
  padding: 16px 20px;
  display: flex;
  align-items: center;
  gap: 10px;
  opacity: 0;
  transition: opacity 0.3s;
  pointer-events: none;
  z-index: 50;
}

.image-viewer:hover .top-bar,
.top-bar:hover {
  opacity: 1;
  pointer-events: all;
}

.control-btn {
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.15);
  color: #fff;
  padding: 10px 14px;
  border-radius: 8px;
  cursor: pointer;
  font-size: 13px;
  font-family: inherit;
  transition: all 0.2s;
  backdrop-filter: blur(10px);
  min-width: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.control-btn:hover {
  background: rgba(255, 255, 255, 0.15);
  border-color: rgba(255, 255, 255, 0.3);
  transform: translateY(-1px);
  box-shadow: 0 2px 8px rgba(255, 255, 255, 0.1);
}

/* 快捷键栏 - 固定底部显示 */
.shortcuts-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  background: rgba(0, 0, 0, 0.8);
  backdrop-filter: blur(10px);
  padding: 10px 20px;
  z-index: 40;
  border-top: 1px solid rgba(255, 255, 255, 0.1);
}

.shortcuts-content {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  flex-wrap: wrap;
}

.shortcut-item {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: rgba(255, 255, 255, 0.7);
}

.shortcut-separator {
  color: rgba(255, 255, 255, 0.3);
  font-size: 14px;
  user-select: none;
}

/* 缩放指示器 */
.zoom-indicator {
  position: fixed;
  bottom: 50%;
  left: 50%;
  transform: translate(-50%, 50%);
  background: rgba(0, 0, 0, 0.85);
  color: #fff;
  padding: 16px 28px;
  border-radius: 12px;
  font-size: 20px;
  font-weight: 600;
  pointer-events: none;
  animation: zoomFlash 0.8s ease-out forwards;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
  border: 1px solid rgba(255, 255, 255, 0.1);
}

@keyframes zoomFlash {
  0% { opacity: 0; transform: translate(-50%, 50%) scale(0.8); }
  20% { opacity: 1; transform: translate(-50%, 50%) scale(1); }
  100% { opacity: 0; transform: translate(-50%, 50%) scale(1); }
}

.zoom-indicator.hidden {
  display: none;
}

关键要点

  • 纯黑背景(#000)+ 白色文字,极简风格
  • Logo 使用紫色渐变(#667eea → #764ba2)
  • 导航箭头悬停时紫色高亮 + 放大动效
  • 控制栏使用毛玻璃效果(backdrop-filter: blur)
  • 缩放指示器使用关键帧动画淡入淡出
  • 快捷键栏固定底部,半透明背景

四、部署到鸿蒙平台

4.1 文件同步

使用 PowerShell 脚本将 Electron 应用文件同步到鸿蒙项目:

# 同步 main.js
Copy-Item "electron-apps\imv\main.js" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\main.js" `
  -Force

# 同步 renderer.js
Copy-Item "electron-apps\imv\renderer.js" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\renderer.js" `
  -Force

# 同步 index.html
Copy-Item "electron-apps\imv\index.html" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\index.html" `
  -Force

# 同步 imv.css
Copy-Item "electron-apps\imv\styles\imv.css" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\styles\imv.css" `
  -Force

4.2 构建 HAP 包

在 DevEco Studio 中:

  1. 打开项目根目录
  2. 点击 Build > Build Hap(s)/APP(s)
  3. 选择 Build Hap(s)
  4. 等待构建完成

4.3 真机测试

  1. 连接鸿蒙设备(或启动模拟器)
  2. 点击 Run > Run ‘entry’
  3. 安装完成后,应用会自动启动
  4. 拖拽图片测试浏览、缩放、旋转功能

五、常见问题 FAQ

Q1:拖拽图片后页面全黑?

问题现象:拖拽图片到页面后,图片不显示,页面全黑

根本原因:使用了 file.path 属性,但浏览器环境 File 对象没有 path 属性

解决方案:

// 正确方式:使用 FileReader 读取
const reader = new FileReader();
reader.onload = (event) => {
  currentImage.src = event.target.result;  // Base64 DataURL
};
reader.readAsDataURL(file);

// 错误方式:直接访问 file.path
currentImage.src = `file://${file.path}`;  // ❌ 全黑

注意事项:

  • 拖拽使用 FileReader.readAsDataURL() 读取为 Base64
  • 对话框打开使用 file:// 协议加载文件路径
  • loadImage 函数自动判断是 File 对象还是路径

Q2:按 q 返回时占位符位置错乱?

问题现象:按 q 键关闭图片后,占位符出现在右下角而非居中

根本原因:使用 placeholder.style.display = ‘block’ 覆盖了 CSS 定位

解决方案:

/* CSS 添加 hidden 类 */
.placeholder.hidden {
  display: none !important;
}
// JavaScript 使用 classList
placeholder.classList.add('hidden');    // 隐藏
placeholder.classList.remove('hidden'); // 显示

关键点:

  • 使用 classList 而不是直接操作 display
  • 避免动画中的 transform 覆盖定位(移除 translateY)
  • 占位符使用 position: absolute + top: 42% 居中偏上

Q3:单张图片时导航箭头仍然显示?

问题现象:只打开一张图片,左右箭头仍然显示

解决方案:

function updateNavArrows() {
  if (imageList.length <= 1) {
    // 只有一张图片,隐藏所有箭头
    navPrev.classList.remove('visible');
    navNext.classList.remove('visible');
  } else if (currentIndex === 0) {
    // 第一张,只显示右箭头
    navPrev.classList.remove('visible');
    navNext.classList.add('visible');
  } else if (currentIndex === imageList.length - 1) {
    // 最后一张,只显示左箭头
    navPrev.classList.add('visible');
    navNext.classList.remove('visible');
  } else {
    // 中间图片,显示左右箭头
    navPrev.classList.add('visible');
    navNext.classList.add('visible');
  }
}

关键点:

  • 每次加载图片后调用 updateNavArrows()
  • 使用 classList.add/remove(‘visible’) 控制显示
  • CSS 中 .nav-arrow 默认 display: none

Q4:缩放指示器动画不触发?

问题现象:多次缩放时,缩放百分比指示器只显示一次动画

根本原因:CSS 动画结束后再次添加相同类不会重新触发

解决方案:

function showZoomIndicator() {
  zoomIndicator.textContent = `${zoomLevel}%`;
  zoomIndicator.classList.remove('hidden');
  
  // 强制重绘以重新触发动画
  void zoomIndicator.offsetWidth;
  zoomIndicator.style.animation = 'none';
  void zoomIndicator.offsetWidth;
  zoomIndicator.style.animation = '';
  
  if (zoomTimeout) clearTimeout(zoomTimeout);
  zoomTimeout = setTimeout(() => {
    zoomIndicator.classList.add('hidden');
  }, 800);
}

关键点:

  • 使用 void offsetWidth 强制浏览器重绘
  • 临时设置 animation: ‘none’ 再恢复
  • 800ms 后自动隐藏指示器

Q5:拖拽时页面闪烁?

问题现象:拖拽文件经过页面时,拖拽状态频繁切换

根本原因:dragenter/dragleave 事件在子元素间冒泡

解决方案:

let dragCounter = 0;

document.body.addEventListener('dragenter', (e) => {
  dragCounter++;
  if (e.dataTransfer.types.includes('Files')) {
    document.body.classList.add('drag-over');
  }
});

document.body.addEventListener('dragleave', (e) => {
  dragCounter--;
  if (dragCounter === 0 || e.relatedTarget === null) {
    document.body.classList.remove('drag-over');
    dragCounter = 0;
  }
});

关键点:

  • 使用计数器避免子元素导致的闪烁
  • 检查 e.dataTransfer.types 确保是文件拖拽
  • dragCounter 归零时才移除拖拽状态

Q6:快捷键与系统冲突?

问题现象:F11 打开浏览器全屏而非应用全屏

解决方案:

document.addEventListener('keydown', (e) => {
  switch(e.key) {
    case 'F11':
      e.preventDefault();  // 阻止默认行为
      toggleFullscreen();
      break;
  }
});

function toggleFullscreen() {
  const { remote } = require('electron');
  const win = remote.getCurrentWindow();
  win.setFullScreen(!win.isFullScreen());
}

注意事项:

  • 必须在 handler 最前面调用 event.preventDefault()
  • 使用 Electron remote 模块控制窗口全屏
  • 支持 q/Escape 返回初始页

Q7:图片信息面板显示异常?

问题现象:按 i 键后信息面板位置错误或样式丢失

解决方案:

.info-overlay {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: rgba(20, 20, 20, 0.98);
  border: 1px solid #333;
  border-radius: 16px;
  padding: 36px;
  backdrop-filter: blur(20px);
  box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
  z-index: 100;
  animation: slideIn 0.3s ease-out;
  min-width: 360px;
}

关键点:

  • 使用 position: fixed + transform 居中
  • 添加 backdrop-filter 毛玻璃效果
  • 使用 classList.toggle(‘hidden’) 控制显隐

Q8:鸿蒙平台构建失败?

问题现象:hvigor 构建时报错,无法找到文件

根本原因:文件未同步到鸿蒙项目或路径错误

解决方案:

# 1. 确认源文件存在
Test-Path "electron-apps\imv\main.js"

# 2. 同步文件
Copy-Item "electron-apps\imv\*.js" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\" `
  -Force

Copy-Item "electron-apps\imv\*.html" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\" `
  -Force

# 3. 验证同步结果
Get-ChildItem "web_engine\src\main\resources\resfile\resources\app\"

注意事项:

  • 每次修改后都需要同步文件
  • 检查 build-profile.json5 配置
  • 确保 CSS 中不使用 -webkit-scrollbar(鸿蒙不支持)
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐