Files
leaudit-platform-backend/docs/Collabora/参考资料/Collabora canvas 滚动与增量更新机制.md
T

24 KiB

一、Canvas 滚动实现 (上拉下拉操作)

1. 核心滚动类: ScrollSection

// 初始化滚动属性
ScrollSection.prototype.initialize = function() {
  this.sectionProperties = {
    // 滚动条样式
    scrollBarThickness: 6 * app.roundedDpiScale,
    scrollBarRailwayThickness: 6 * app.roundedDpiScale,
    scrollBarRailwayColor: "#EFEFEF",
    
    // 显示控制
    drawVerticalScrollBar: window.mode.isDesktop() ? true : false,
    drawHorizontalScrollBar: window.mode.isDesktop() ? true : false,
    
    // 交互状态
    mouseIsOnVerticalScrollBar: false,
    mouseIsOnHorizontalScrollBar: false,
    clickScrollVertical: false,
    clickScrollHorizontal: false,
    
    // 滚动同步
    pointerSyncWithVerticalScrollBar: true,
    pointerSyncWithHorizontalScrollBar: true,
    
    // 最小滚动条大小
    minimumScrollSize: 80 * app.roundedDpiScale,
    
    // 滚动轮增量
    scrollWheelDelta: [0, 0]
  };
};

2. 滚动触发方式

方式 1: 鼠标滚轮

// 监听滚轮事件
ScrollSection.prototype.onMouseWheel = function(e) {
  var delta = e.deltaY || e.deltaX;
  
  // 垂直滚动
  if (e.deltaY !== 0) {
    this.sectionProperties.scrollWheelDelta[1] += e.deltaY;
    this.scrollVerticalWithOffset(delta);
  }
  
  // 水平滚动
  if (e.deltaX !== 0) {
    this.sectionProperties.scrollWheelDelta[0] += e.deltaX;
    this.scrollHorizontalWithOffset(delta);
  }
};

方式 2: 拖动滚动条

ScrollSection.prototype.onMouseMove = function(point, dragDistance, e) {
  if (this.sectionProperties.clickScrollVertical) {
    this.showVerticalScrollBar();
    
    var diffY = dragDistance[1] - this.sectionProperties.previousDragDistance[1];
    
    if (this.isMousePointerSyncedWithVerticalScrollBar(scrollProps, point)) {
      // 根据滚动条比例计算实际滚动距离
      this.scrollVerticalWithOffset(diffY * scrollProps.verticalScrollRatio);
    }
    
    this.sectionProperties.previousDragDistance[1] = dragDistance[1];
  }
  
  // 水平滚动类似
  if (this.sectionProperties.clickScrollHorizontal) {
    // ...
  }
};

方式 3: 点击滚动条轨道

ScrollSection.prototype.quickScrollVertical = function(point) {
  var localY = this.getLocalYOnVerticalScrollBar(point);
  var scrollProps = app.activeDocument.activeView.scrollProperties;
  
  // 计算目标滚动位置
  var targetY = (localY / scrollProps.scrollSize) * scrollProps.verticalScrollLength;
  
  // 滚动到目标位置
  app.activeDocument.activeView.scrollVertical(targetY);
};

方式 4: 触摸滑动 (移动端)

ScrollSection.prototype.onScrollVelocity = function(e) {
  if (e.vx === 0 && e.vy === 0) {
    clearInterval(this.autoScrollTimer);
    return;
  }
  
  // 使用速度持续滚动
  this.autoScrollTimer = setInterval(function() {
    this.onScrollBy({
      x: e.vx,
      y: e.vy
    });
  }.bind(this), 100);
};

3. 滚动核心方法

垂直滚动

ScrollSection.prototype.scrollVerticalWithOffset = function(offset) {
  if (!app.activeDocument.activeView.canScrollVertical(documentAnchor)) {
    return;
  }
  
  // 更新视图滚动属性
  app.activeDocument.activeView.scrollVertical(offset);
  
  // 请求重绘
  app.sectionContainer.requestReDraw();
};

// ViewLayoutBase 中的实际滚动逻辑
ViewLayoutBase.prototype.scrollVertical = function(pY) {
  var scrollProps = this.scrollProperties;
  
  // 计算新的滚动位置
  var newY = scrollProps.yOffset + pY;
  
  // 限制在有效范围内
  newY = Math.max(0, Math.min(newY, scrollProps.verticalScrollLength));
  
  // 更新滚动偏移
  scrollProps.yOffset = newY;
  
  // **关键**: 发送新的可见区域到服务器
  this.sendClientVisibleArea();
};

水平滚动

ViewLayoutBase.prototype.scrollHorizontal = function(pX, ignoreScrollbarLength) {
  var scrollProps = this.scrollProperties;
  
  var newX = scrollProps.xOffset + pX;
  newX = Math.max(0, Math.min(newX, scrollProps.horizontalScrollLength));
  
  scrollProps.xOffset = newX;
  
  // 发送可见区域更新
  this.sendClientVisibleArea();
};

二、滚动条渲染逻辑

1. 垂直滚动条绘制

ScrollSection.prototype.drawVerticalScrollBar = function() {
  var scrollProps = app.activeDocument.activeView.scrollProperties;
  
  // 计算滚动条位置和尺寸
  var startX = this.size[0] - scrollProps.usableThickness;  // 右侧位置
  var startY = scrollProps.startY;                          // 顶部位置
  var scrollBarHeight = scrollProps.scrollSize;             // 滚动条高度
  
  // 绘制滚动条轨道 (背景)
  if (this.sectionProperties.drawScrollBarRailway) {
    this.context.globalAlpha = this.sectionProperties.scrollBarRailwayAlpha;
    this.context.fillStyle = this.sectionProperties.scrollBarRailwayColor;
    this.context.fillRect(
      startX,
      0,
      this.sectionProperties.scrollBarRailwayThickness,
      this.size[1]  // 整个高度
    );
  }
  
  // 绘制滚动条本体
  this.context.globalAlpha = 1.0;
  this.context.fillStyle = "#AAAAAA";  // 滚动条颜色
  this.context.fillRect(
    startX,
    startY,
    this.sectionProperties.scrollBarThickness,
    scrollBarHeight
  );
  
  // 测试模式: 创建 DOM 元素用于自动化测试
  if (this.containerObject.testing) {
    var element = document.getElementById("test-div-vertical-scrollbar");
    if (!element) {
      element = document.createElement("div");
      element.id = "test-div-vertical-scrollbar";
      document.body.appendChild(element);
    }
    element.style.position = "absolute";
    element.style.left = startX + "px";
    element.style.top = startY + "px";
    element.style.width = this.sectionProperties.scrollBarThickness + "px";
    element.style.height = scrollBarHeight + "px";
  }
};

2. 滚动条尺寸计算

// 在 ViewLayoutBase 中计算滚动属性
ViewLayoutBase.prototype.updateScrollProperties = function() {
  var scrollProps = this.scrollProperties;
  
  // 文档总长度
  var documentHeight = this.getDocumentHeight();  // Twips 单位
  
  // 可见区域高度
  var viewportHeight = this.getViewportHeight();  // Twips 单位
  
  // 滚动条可用空间
  var availableSpace = this.size[1] - 20;  // Canvas 高度减去边距
  
  // 滚动条高度 = (视口高度 / 文档高度) * 可用空间
  scrollProps.scrollSize = Math.max(
    this.sectionProperties.minimumScrollSize,
    (viewportHeight / documentHeight) * availableSpace
  );
  
  // 滚动比例: 滚动条移动1像素,文档滚动多少 Twips
  scrollProps.verticalScrollRatio = 
    (documentHeight - viewportHeight) / (availableSpace - scrollProps.scrollSize);
  
  // 滚动长度 (文档可滚动的总距离)
  scrollProps.verticalScrollLength = documentHeight - viewportHeight;
  
  // 滚动条当前位置
  scrollProps.startY = 
    (scrollProps.yOffset / scrollProps.verticalScrollLength) * 
    (availableSpace - scrollProps.scrollSize);
};

3. 滚动条交互增强

// 鼠标悬停增粗滚动条
ScrollSection.prototype.increaseScrollBarThickness = function() {
  this.sectionProperties.scrollBarThickness = 8 * app.roundedDpiScale;
  this.sectionProperties.scrollBarRailwayThickness = 8 * app.roundedDpiScale;
};

ScrollSection.prototype.decreaseScrollBarThickness = function() {
  this.sectionProperties.scrollBarThickness = 6 * app.roundedDpiScale;
  this.sectionProperties.scrollBarRailwayThickness = 6 * app.roundedDpiScale;
};

// 检测鼠标是否在滚动条上
ScrollSection.prototype.isMouseOnScrollBar = function(point) {
  var scrollProps = app.activeDocument.activeView.scrollProperties;
  
  // 检测垂直滚动条
  var isOnVertical = point.pX >= this.size[0] - scrollProps.usableThickness;
  this.sectionProperties.mouseIsOnVerticalScrollBar = isOnVertical;
  
  if (isOnVertical) {
    this.showVerticalScrollBar();
    this.increaseScrollBarThickness();  // 悬停增粗
  } else {
    this.hideVerticalScrollBar();
    this.decreaseScrollBarThickness();
  }
};

三、增量更新 (Delta) 与瓦片请求机制

1. 可见区域更新触发瓦片请求

// 滚动时发送可见区域到服务器
ViewLayoutBase.prototype.sendClientVisibleArea = function() {
  // 获取当前可见区域 (Canvas 坐标)
  var visibleArea = app.map.getPixelBounds();
  
  // 转换为 Twips 坐标 (文档坐标系统)
  visibleArea = new cool.Bounds(
    app.map._docLayer._pixelsToTwips(visibleArea.min),
    app.map._docLayer._pixelsToTwips(visibleArea.max)
  );
  
  var size = visibleArea.getSize();
  var visibleTopLeft = visibleArea.min;
  
  // 构建 clientvisiblearea 命令
  var command = 
    "clientvisiblearea " +
    "x=" + Math.round(visibleTopLeft.x) + " " +
    "y=" + Math.round(visibleTopLeft.y) + " " +
    "width=" + Math.round(size.x) + " " +
    "height=" + Math.round(size.y);
  
  // **发送到服务器**
  app.socket.sendMessage(command);
};

// 多页视图的可见区域计算
MultiPageViewLayout.prototype.sendClientVisibleArea = function() {
  var visibleArea = this.getVisibleAreaRectangle();
  
  var command = 
    "clientvisiblearea " +
    "x=" + visibleArea.x1 + " " +
    "y=" + visibleArea.y1 + " " +
    "width=" + visibleArea.width + " " +
    "height=" + visibleArea.height;
  
  app.socket.sendMessage(command);
};

2. 服务器响应: invalidatetiles 消息

// 服务器检测到可见区域变化后,发送 invalidatetiles 消息
// 格式: "invalidatetiles: part=0 x=0 y=5120 width=21000 height=7680"

CanvasTileLayer.prototype.handleInvalidateTilesMsg = function(textMsg) {
  var payload = textMsg.substring("invalidatetiles:".length + 1);
  
  if (!payload.startsWith("EMPTY")) {
    // 解析失效区域
    var tokens = payload.split(" ");
    var invalidRect = {
      part: 0,
      x: 0,
      y: 0,
      width: 0,
      height: 0
    };
    
    for (var i = 0; i < tokens.length; i++) {
      if (tokens[i].startsWith("part=")) {
        invalidRect.part = parseInt(tokens[i].substring(5));
      } else if (tokens[i].startsWith("x=")) {
        invalidRect.x = parseInt(tokens[i].substring(2));
      } else if (tokens[i].startsWith("y=")) {
        invalidRect.y = parseInt(tokens[i].substring(2));
      } else if (tokens[i].startsWith("width=")) {
        invalidRect.width = parseInt(tokens[i].substring(6));
      } else if (tokens[i].startsWith("height=")) {
        invalidRect.height = parseInt(tokens[i].substring(7));
      }
    }
    
    // 标记受影响的瓦片为失效
    TileManager.overlapInvalidatedRectangleWithView(
      invalidRect.part,
      0,  // mode
      null,  // wireId
      invalidRect,
      textMsg
    );
  } else {
    // EMPTY 表示所有瓦片都失效,需要全部重新请求
    TileManager.invalidateAllTiles();
  }
};

3. 瓦片失效处理

TileManager.overlapInvalidatedRectangleWithView = function(
  part, mode, wireId, invalidatedRectangle, textMsg
) {
  var needsNewTiles = false;
  
  // 遍历所有已加载的瓦片
  for (var [key, tile] of this.tiles) {
    var coords = tile.coords;
    
    if (coords.part === part && coords.mode === mode) {
      // 计算瓦片的矩形区域
      var tileRectangle = this.getTileRectangle(coords);
      
      // 检查瓦片是否与失效区域重叠
      if (invalidatedRectangle.intersectsRectangle(tileRectangle)) {
        // 如果瓦片在当前视口内,标记需要新瓦片
        if (tile.distanceFromView === 0) {
          needsNewTiles = true;
        }
        
        // 标记瓦片为失效
        this.invalidateTile(key, wireId);
      }
    }
  }
  
  // 如果有失效的可见瓦片,触发更新
  if (needsNewTiles) {
    TileManager.update();  // 请求新的瓦片
  }
};

TileManager.invalidateTile = function(key, wireId) {
  var tile = this.tiles.get(key);
  if (!tile) return;
  
  tile.invalidateCount++;
  
  // 强制请求关键帧 (完整瓦片)
  tile.forceKeyframe(wireId ? wireId : tile.wireId);
};

4. 增量更新 (Delta) 实现

Delta 消息格式

// 服务器发送的 delta 消息
// 格式: "delta: nviewid=0 part=0 width=256 height=256 tileposx=0 tileposy=0 tilewidth=3840 tileheight=3840 ver=123 wireId=456"
// 后面跟着二进制数据: [keyframe_size (4 bytes)] [keyframe_image] [delta1] [delta2] ...

CanvasTileLayer.prototype._onMessage = function(textMsg, img) {
  if (textMsg.startsWith("tile:") || textMsg.startsWith("delta:")) {
    TileManager.onTileMsg(textMsg, img);
  }
};

应用 Delta

TileManager.applyDelta = function(tile, rawDeltas, deltas, keyframeDeltaSize, keyframeImage) {
  var tileSize = 256;  // 瓦片大小
  
  // 1. 如果有关键帧,先加载关键帧
  if (keyframeDeltaSize > 0 && keyframeImage) {
    tile.image = keyframeImage;  // PNG 图像
    tile.loadCount++;
    tile.deltaCount = 0;
    tile.updateCount = 0;
  } else {
    tile.deltaCount++;
  }
  
  // 2. 获取瓦片的当前像素数据
  var canvas = document.createElement("canvas");
  canvas.width = tileSize;
  canvas.height = tileSize;
  var ctx = canvas.getContext("2d");
  
  // 绘制当前瓦片图像
  if (tile.image) {
    ctx.drawImage(tile.image, 0, 0);
  }
  
  var imageData = ctx.getImageData(0, 0, tileSize, tileSize);
  var oldData = new Uint8ClampedArray(imageData.data);  // 保存旧数据
  
  // 3. 应用所有 delta
  var offset = 0;
  for (var i = 0; i < deltas.length; i++) {
    var delta = deltas[i];
    
    // 应用单个 delta 块
    var len = CanvasTileUtils.applyDeltaChunk(
      imageData,
      delta,
      oldData,
      tileSize,
      tileSize,
      false  // debug
    );
    
    offset += len;
  }
  
  // 4. 将更新后的像素数据写回 Canvas
  ctx.putImageData(imageData, 0, 0);
  
  // 5. 将 Canvas 转换为图像
  tile.image = canvas;
  
  // 6. 标记需要重绘
  tile.needsRedraw = true;
};

Delta 块解码算法

CanvasTileUtils.applyDeltaChunk = function(imgData, delta, oldData, width, height, debug) {
  var pixSize = 4;  // RGBA 4 字节
  var offset = 0;
  var pixels = imgData.data;
  
  while (offset < delta.length) {
    // 读取操作码
    var opcode = delta[offset++];
    
    if (opcode === 99) {  // 'c' - Copy (复制旧数据)
      // 格式: 'c' [count] [source_offset]
      var count = delta[offset++] | (delta[offset++] << 8);
      var srcOffset = delta[offset++] | (delta[offset++] << 8) | (delta[offset++] << 16);
      
      // 从旧数据复制像素
      for (var i = 0; i < count; i++) {
        var srcIdx = (srcOffset + i) * pixSize;
        var dstIdx = (currentPixel + i) * pixSize;
        
        pixels[dstIdx + 0] = oldData[srcIdx + 0];  // R
        pixels[dstIdx + 1] = oldData[srcIdx + 1];  // G
        pixels[dstIdx + 2] = oldData[srcIdx + 2];  // B
        pixels[dstIdx + 3] = oldData[srcIdx + 3];  // A
      }
      
      currentPixel += count;
      
    } else if (opcode === 100) {  // 'd' - Diff (差异数据)
      // 格式: 'd' [count] [pixel1_rgba] [pixel2_rgba] ...
      var count = delta[offset++] | (delta[offset++] << 8);
      
      // 直接写入新的像素数据
      for (var i = 0; i < count; i++) {
        var idx = currentPixel * pixSize;
        pixels[idx + 0] = delta[offset++];  // R
        pixels[idx + 1] = delta[offset++];  // G
        pixels[idx + 2] = delta[offset++];  // B
        pixels[idx + 3] = delta[offset++];  // A
        currentPixel++;
      }
      
    } else {
      console.error("Unknown delta opcode:", opcode);
      break;
    }
  }
  
  return offset;  // 返回处理的字节数
};

5. 完整的滚动 → 瓦片请求 → Delta 更新流程

┌─────────────────────────────────────────────────────────────┐
 1. 用户滚动 (鼠标滚轮/拖动滚动条/触摸滑动)                  
     ScrollSection.onMouseWheel()                            
     ScrollSection.scrollVerticalWithOffset(offset)          
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 2. 更新视图偏移                                             
     ViewLayoutBase.scrollVertical(offset)                   
     scrollProps.yOffset += offset                           
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 3. 发送可见区域到服务器                                     
     ViewLayoutBase.sendClientVisibleArea()                  
     WebSocket: "clientvisiblearea x=0 y=5120 width=21000   │
│                  height=7680"                                
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 4. 服务器检测新瓦片需求                                     
    - 计算哪些瓦片在新的可见区域内                           
    - 检查客户端是否已缓存这些瓦片                           
    - 如果已缓存且未修改,无需发送                            
    - 如果未缓存或已修改,发送 delta 或完整瓦片               
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 5. 服务器发送 invalidatetiles 消息                          
    WebSocket  "invalidatetiles: part=0 x=0 y=5120          │
│                 width=21000 height=7680"                     
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 6. 客户端处理失效消息                                       
     CanvasTileLayer.handleInvalidateTilesMsg()             
     TileManager.overlapInvalidatedRectangleWithView()      
     TileManager.invalidateTile(key, wireId)                
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 7. 请求新瓦片 (如果需要)                                    
     TileManager.update()                                    
     WebSocket: "tilecombine part=0 tileposx=0,256,512      │
│                  tileposy=5120,5120,5120 ..."               
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 8. 服务器发送 delta 或完整瓦片                              
    WebSocket  "delta: part=0 width=256 height=256          │
│                 wireId=123 ..."                              
    + 二进制数据: [keyframe_size][keyframe_png][delta_data]  
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 9. 客户端应用 delta                                         
     TileManager.onTileMsg(textMsg, img)                    
     TileManager.applyDelta(tile, deltas, keyframe)         
     CanvasTileUtils.applyDeltaChunk() - 逐块解码           
     更新 tile.image                                        
└─────────────────────────────────────────────────────────────┘
                          
┌─────────────────────────────────────────────────────────────┐
 10. 重绘 Canvas                                             
     app.sectionContainer.requestReDraw()                   
     ScrollSection.onDraw()                                 
     ctx.drawImage(tile.image, dx, dy)                      
     用户看到更新后的文档内容                               
└─────────────────────────────────────────────────────────────┘

总结

1. Canvas 的上拉下拉操作

  • 可以实现: 通过 ScrollSection 类处理所有滚动事件
  • 支持多种输入方式: 鼠标滚轮、拖动滚动条、点击轨道、触摸滑动
  • 核心方法: scrollVerticalWithOffset() / scrollHorizontalWithOffset()

2. 滚动条渲染

  • 使用 Canvas 2D API 直接绘制滚动条 (不是 HTML 元素)
  • 位置: 垂直滚动条在右侧, 水平滚动条在底部
  • 动态计算: 滚动条大小根据文档长度与视口大小的比例动态计算
  • 交互增强: 鼠标悬停时滚动条增粗 (6px → 8px)

3. 增量更新 (Delta) 机制

  • 触发: 滚动时发送 clientvisiblearea 命令到服务器
  • 响应: 服务器发送 invalidatetiles 标记失效区域
  • 请求: 客户端发送 tilecombine 请求新瓦片
  • 优化: 服务器发送 delta 消息而非完整瓦片
    • Delta 格式: 复制指令 ('c') + 差异数据 ('d')
    • 大幅减少网络传输 (通常节省 70-90% 带宽)
  • 应用: 客户端解码 delta 并更新现有瓦片的像素数据

这种机制实现了高效的文档滚动和实时协作编辑,类似于视频编码中的 I 帧(关键帧)和 P 帧(增量帧)技术。