零依赖的艺术:用原生 JS 打造“ZenReader”沉浸式阅读器

摘要 :在这个前端框架横行的时代,我们是否还记得原生 HTML/CSS/JS 的力量?本文将剖析一个单文件级应用 ZenReader Pro ,展示如何仅用 600 行代码实现一个包含高亮标注、拖拽笔记、本地书库、Markdown 导入导出沉浸式排版 的现代阅读器。

"C:\Users\86182\Downloads\ai_studio_code (7).html"


一、 项目背景与设计理念

在信息爆炸的今天,阅读体验往往被广告、弹窗和复杂的 UI 割裂。ZenReader 的设计初衷回归阅读本质,核心理念遵循 "Less is More"

  1. 视觉降噪:默认隐藏所有工具栏,仅在鼠标悬停底部时浮现。
  2. 纸感阅读 :采用米黄色温背景(#f5f4f1)与深灰文字,配合开源字体"霞鹜文楷",模拟实体书的阅读质感。
  3. 心流交互:通过快捷键(H/S)和拖拽操作,保持用户的阅读"心流"不被打断。

二、 核心技术架构分析

本项目完全摒弃了 React/Vue 等框架和 Webpack 打包工具,采用 Single File Component (SFC) 的变体------即单 HTML 文件架构。

1. 响应式布局与 CSS 变量系统

为了实现"一键换肤"和"动态调整",CSS 变量(Custom Properties)发挥了巨大作用。

css 复制代码
:root {
    --bg-color: #f5f4f1;       /* 背景色 */
    --drawer-width: 360px;     /* 抽屉宽度(动态可调) */
    --anim: cubic-bezier(0.25, 0.8, 0.25, 1); /* 统一的缓动函数 */
}

亮点 :右侧笔记栏的宽度调整并没有写死在 JS 里去操作 DOM 的 width,而是通过 JS 修改 --drawer-width 变量,CSS 自动响应。这极大减少了重排(Reflow)的性能开销。

2. 高亮系统的核心逻辑

如何在一个 contenteditable 或普通 div 中高亮文字?核心在于 window.getSelection()Range 对象。

难点解决:智能切换(Toggle)

代码中实现了一个智能判断:如果用户选中的文字已经被高亮了,再次触发时应该是"取消高亮"。

javascript 复制代码
// 核心逻辑片段
function toggleHighlightKey() {
    const selection = window.getSelection();
    if (!selection.rangeCount) return;

    const range = selection.getRangeAt(0);
    const parent = range.commonAncestorContainer.parentNode;

    // 状态检测:是否已在高亮标签内?
    if (parent.classList.contains('highlight-mark')) {
        // 解包逻辑 (Unwrap):用文本节点替换掉 span 标签
        const text = document.createTextNode(parent.innerText);
        parent.parentNode.replaceChild(text, parent);
    } else {
        // 包裹逻辑 (Wrap):创建 span 包裹选区
        const span = document.createElement('span');
        span.className = 'highlight-mark';
        range.surroundContents(span); 
    }
}

这段代码展示了原生 DOM 操作的精髓,无需 Virtual DOM 也能高效处理节点变更。

3. HTML5 原生拖拽与数据驱动

笔记栏支持拖拽排序。这里使用了 HTML5 Native Drag & Drop API,但最关键的设计决策是:数据驱动视图

当发生拖拽(Drop)时,我们不直接交换 DOM 节点,而是操作背后的 currentSummaries 数组,然后重新渲染整个列表。

javascript 复制代码
// 拖拽结束的处理逻辑
function handleDrop(e) {
    // 1. 获取源索引和目标索引
    const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
    const toIndex = parseInt(this.dataset.index);
    
    // 2. 数组操作 (Splice)
    if (fromIndex !== toIndex) {
        const item = currentSummaries.splice(fromIndex, 1)[0];
        currentSummaries.splice(toIndex, 0, item);
        
        // 3. 重新渲染
        renderSummaryList(); 
    }
    return false; // 阻止浏览器默认行为
}

这种模式确保了导出的 Markdown 文件顺序永远与界面显示的顺序一致。

4. 健壮的视图状态管理

为了解决"返回编辑按钮失灵"的 Bug,我们重构了视图切换逻辑。

  • 问题:之前的逻辑是根据输入框是否有值来判断是否渲染。
  • 修复 :改为强制同步。
    • 进入阅读时:无论之前有没有 HTML,都重新解析输入框内容。
    • 退出阅读时 :必须将 contentBody.innerHTML(包含用户做的高亮)反向同步回 textarea,确保数据不丢失。

三、 交互体验的微创新

作为高级前端设计,功能实现只是基础,体验才是护城河。

1. 隐形交互 (Invisible Interface)

底部的 control-bar 默认 opacity: 0,配合 backdrop-filter: blur(10px)(毛玻璃特效)。只有当阅读者需要时(鼠标靠近底部),工具栏才会显现。这种设计最大程度地保护了阅读者的注意力。

2. 轻量级反馈 (Toast)

为了配合快捷键(H/S),引入了一个极其轻量的 Toast 系统。

css 复制代码
.toast {
    /* 默认隐藏在视口下方 */
    transform: translateX(-50%) translateY(20px); 
    opacity: 0;
    transition: all 0.3s;
}
.toast.show {
    /* 上浮显示 */
    transform: translateX(-50%) translateY(0);
    opacity: 1;
}

这比浏览器自带的 alert() 优雅无数倍,且不会打断用户的操作流。

3. 正则表达式的魔法 (Markdown 导入/导出)

为了兼容 Obsidian/Notion 等工具,我们手写了一个轻量级解析器:

  • 导出 :将 <span class="highlight-mark">text</span> 替换为 Markdown 的高亮语法 ==text==
  • 导入 :使用正则 replace(/^[-*>\d.]+\s+/, '') 智能去除列表符,只提取纯文本内容到笔记卡片中。

四、 运行界面

相关推荐
奶糖 肥晨25 分钟前
JS自动检测用户国家并显示电话前缀教程|vue uniapp react可用
javascript·vue.js·uni-app
啊花是条龙41 分钟前
《产品经理说“Tool 分组要一条会渐变的彩虹轴,还要能 zoom!”——我 3 步把它拆成 1024 个像素》
前端·javascript·echarts
青茶3601 小时前
【js教程】如何用jq的js方法获取url链接上的参数值?
开发语言·前端·javascript
晴栀ay2 小时前
React性能优化三剑客:useMemo、memo与useCallback
前端·javascript·react.js
知其然亦知其所以然2 小时前
别再死记硬背了,一篇文章搞懂 JS 乘性操作符
前端·javascript·程序员
json{shen:"jing"}2 小时前
08_组件基础
前端·javascript·vue.js
菩提祖师_2 小时前
基于VR的虚拟会议系统设计
开发语言·javascript·c++·爬虫
hongkid2 小时前
React Native 如何打包正式apk
javascript·react native·react.js
菩提祖师_3 小时前
量子机器学习在时间序列预测中的应用
开发语言·javascript·爬虫·flutter
未来之窗软件服务3 小时前
幽冥大陆(九十二 )Gitee 自动化打包JS对接IDE —东方仙盟练气期
javascript·gitee·自动化·仙盟创梦ide·东方仙盟