在 Web 端构建高保真 PDF 阅览器与数字墨迹批注系统(如类 GoodNotes / Notability 体验)时,存在两个极为隐蔽且破坏用户体验的核心瓶颈:

  1. 手势动力学失真:批注模式下为防止误触落墨,常试图在 JavaScript 层监听触摸事件并手工计算 scrollTop -= dy 模拟滚动。这种手工位移彻底剥离了浏览器的原生弹性物理曲线与甩动惯性(Momentum / Inertia Fling),导致翻页极其生硬滞涩;
  2. 布局跃迁与销毁致盲:侧栏折叠、窗口 Resize 自适应重算(Fit-Width)以及多标签页切换销毁视图时,DOM 容器高度瞬间塌陷或脱离文档树,浏览器引擎将容器的 scrollTop 强行钳位归零,导致用户正在阅读的页码与垂直段落进度被无情吞噬。

本文记录这两个核心问题的底层根因分析与工业级落地解法。


一、双轨手势解耦:原生惯性滚动与触控笔落墨分流 (Dual-Track Gesture Decoupling)

1.1 伪惯性模拟的失效根因

传统手写画布在开启批注时,通常将全层样式设为 touch-action: none 并拦截所有指针事件:

// 错误模式:在 JS 层手工拦截并模拟滚动
if (isTouch) {
  scroller.scrollTop -= dy; // 无阻尼、无加速度曲线、提笔即停、无边界回弹!
  e.preventDefault();
}

即使在 JS 中引入复杂物理积分模拟,也无法匹敌浏览器在合成器线程(Compositor Thread)上执行的原生触控渲染,极易产生掉帧与不自然的加速度断层。

1.2 W3C 指针事件规范的解耦原理

根据 W3C Pointer Events Level 2 & 3 规范:

  • touch-action 属性仅约束浏览器对触摸接触(pointerType === 'touch')的原生手势识别(平移、缩放);
  • 触控笔(pointerType =<mark> 'pen',如 Apple Pencil、Surface Pen)与鼠标(pointerType </mark>= 'mouse')不受 touch-action: pan-y 限制,浏览器依然如实派发带有压力值(pressure)与倾角(tilt)的高频指针事件。

因此,完全无需在 JavaScript 中拦截单指平移,应当利用 CSS 规范声明直接放行原生滚动引擎:

/* 无论是浏览模式还是批注模式,对单指触摸放行系统原生纵向平移与惯性滚动 */
.pdf-ink-layer .ink-canvas,
.pdf-ink-layer .ink-canvas > .ink-canvas-layer {
  touch-action: pan-x pan-y !important;
}
// InkCanvas 内部指针分流核心
private handlePointerDown(e: PointerEvent): void {
  const isTouch = e.pointerType === 'touch';

  if (isTouch) {
    this.activeTouches.set(e.pointerId, { clientX: e.clientX, clientY: e.clientY });
    const allowFinger = (getSettings().fingerDrawing ?? false) && !this.palmRejection;

    // 单指触摸且未开启指绘(默认手掌防误触开启):
    if (!allowFinger) {
      const scroller = this.getScrollContainer();
      if (scroller) {
        // 关键:宿主存在原生滚动容器时,绝对不调用 e.preventDefault()!
        // 不进行任何手工位移,完整放行给浏览器原生滚动引擎
        this.isSingleTouchPanning = false;
        return; 
      }
    }
  }

  // 触控笔 (pen) 与鼠标 (mouse):
  e.preventDefault();
  this.isDrawing = true;
  this.activePointerId = e.pointerId;
  this.activeCanvas.setPointerCapture(e.pointerId);
  // 执行贝塞尔墨流落笔与动力学笔触渲染
}

1.3 带来的交互质变

  1. 手指与阅读模式 100% 同构:手指在批注模式下翻页、划动具有完整的甩动手势惯性与橡皮筋回弹,毫无割裂感;
  2. 掌压自然免疫 (Hardware-Level Palm Rejection):书写时手掌压在屏幕上属于 pointerType === 'touch',直接被放行忽略,绝不在画板上遗留多余墨点;
  3. 触控笔落笔即墨:触控笔落笔触发 pointerType === 'pen',毫秒级响应落墨;
  4. 阅读态无感唤醒:在阅读态(Browse Mode)下,通过视口根容器全局捕获 pointerdown(e.pointerType === 'pen'),可以在笔尖落下的第一刻自动由阅读态无感切入批注态,实现“想读直接滑、想写直接画”。

二、阅读进度无损保全架构 (Reading Progress Preservation Architecture)

2.1 视口清零的三大陷阱

在 SPA 中管理多页连续排版文档时,常见的三大致盲场景:

  1. DOM 清空坍塌:自适应重算宽度时调用 pagesWrapper.innerHTML = '',视口总可滚动高度(scrollHeight)瞬间缩至 0,浏览器同步重置 scrollTop = 0;
  2. 跨缩放段落漂移:仅记录整数页码,缩放比例由 100% 变至 150% 时,原本停留在页面正中 50% 位置的内容被直接推向视口下方,用户视野发生严重跳动;
  3. 组件卸载时序脱靶:多标签页切换时,父容器先以 innerHTML = ... 渲染新页面结构,再异步或延后调用旧 PDF 视图的 destroy()。此时旧视口已被脱离 DOM 树(isConnected === false),其 clientHeight 与 scrollTop 均为 0,若此时提取位置直接回写,会将上次存储的进度洗成 Page 1。

2.2 段落相对比例(Offset Ratio)双向换算算法

定义阅读基准线为视口顶端下沉一定偏移处(例如 baselineY = containerRect.top + 24),以穿过该基准线的页面槽位及其内部相对偏移比例作为唯一真实状态源:

$$\text{offsetRatio} = \max\left(0, \min\left(1, \frac{\text{baselineY} - \text{slotRect.top}}{\text{slotRect.height}}\right)\right)$$

在视口缩放或重新挂载时,根据目标页槽的新高度计算绝对滚动目标:

$$\text{targetScrollTop} = \text{slotTopInContainer} - \text{BASELINE\_OFFSET} + (\text{offsetRatio} \times \text{slotRect.height})$$

public getReadingPosition(): { pageNum: number; offsetRatio: number } {
  if (!this.scrollContainer || !this.pagesWrapper || this.totalPages <= 0) {
    return this.lastValidReadingPosition;
  }

  // 脱离 DOM 树保护:若已被父级卸载,严禁重新测算,直接返送最新缓存值
  if (!this.scrollContainer.isConnected || this.scrollContainer.clientHeight <= 0) {
    return this.lastValidReadingPosition;
  }

  if (this.scrollContainer.scrollTop <= 2) {
    this.lastValidReadingPosition = { pageNum: 1, offsetRatio: 0 };
    return this.lastValidReadingPosition;
  }

  const containerRect = this.scrollContainer.getBoundingClientRect();
  const BASELINE_OFFSET = 24;
  const baselineY = containerRect.top + BASELINE_OFFSET;

  const slots = Array.from(this.pagesWrapper.querySelectorAll('.pdf-page-slot')) as HTMLElement[];
  for (let i = 0; i < slots.length; i++) {
    const slot = slots[i];
    const slotRect = slot.getBoundingClientRect();
    const pageNum = parseInt(slot.getAttribute('data-page-num') || String(i + 1), 10);
    const nextSlot = slots[i + 1];

    // 覆盖当前槽位及槽位之间的外边距空白间隙
    const isCurrentSlot = nextSlot
      ? slotRect.top <= baselineY && baselineY < nextSlot.getBoundingClientRect().top
      : slotRect.top <= baselineY;

    if (isCurrentSlot) {
      const height = slotRect.height || 1;
      const offsetInSlot = baselineY - slotRect.top;
      const offsetRatio = Math.max(0, Math.min(1, offsetInSlot / height));
      this.lastValidReadingPosition = { pageNum, offsetRatio };
      return this.lastValidReadingPosition;
    }
  }

  return this.lastValidReadingPosition;
}

2.3 DOM 槽位复用避免高度坍塌 (Slot Reuse Strategy)

自适应宽度重算时,若文档总页数已分配完成,复用已有 DOM 节点,仅修改 style.width 与 style.minHeight 并置换占位骨架,随后立即按新比例复位 scrollTop:

const existingSlots = Array.from(this.pagesWrapper.querySelectorAll('.pdf-page-slot')) as HTMLElement[];

if (existingSlots.length === this.totalPages) {
  // 槽位已存在:更新宽高,绝对不清空 innerHTML
  existingSlots.forEach((slot, index) => {
    slot.style.width = `${estimatedWidth}px`;
    slot.style.minHeight = `${estimatedHeight}px`;
    slot.innerHTML = `<div class="pdf-page-skeleton">...</div>`;
  });
} else {
  // 首次初始化全量构建
  this.pagesWrapper.innerHTML = '';
  // ... appendChild pageSlot
}

// 恢复位置(同步即时 + rAF 强化,消除多段动画重排延迟)
this.restoreReadingPosition(targetPos.pageNum, targetPos.offsetRatio);
window.requestAnimationFrame(() => {
  this.restoreReadingPosition(targetPos.pageNum, targetPos.offsetRatio);
});

2.4 父级生命周期协同 (Parent Pre-Destruction Guard)

在宿主工作区切换标签或重绘外壳前,必须在第一行优先执行旧视图的析构:

// workspaceView.ts
private renderEditor(): void {
  // 必须在替换 this.container.innerHTML 前完成 PDF 销毁与位置落盘
  if (this.customPdfViewer) {
    this.customPdfViewer.destroy();
    this.customPdfViewer = null;
  }
  this.container.innerHTML = `...`;
}

配合 pdfMemoryStateCache(内存零开销直取)与 sessionStorage(跨刷新保全),使跨标签切换、侧边栏拉伸、缩放挡位切换均具备毫秒级的精准段落接续。


本文归档于个人知识库 · 领域:CS & AI · 模块:04 Project_CS_AI/Frontend