## 一、Canvas 滚动实现 (上拉下拉操作) ### 1. 核心滚动类: `ScrollSection` ```javascript // 初始化滚动属性 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: 鼠标滚轮** ```javascript // 监听滚轮事件 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: 拖动滚动条** ```javascript 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: 点击滚动条轨道** ```javascript 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: 触摸滑动 (移动端)** ```javascript 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. 滚动核心方法 #### **垂直滚动** ```javascript 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(); }; ``` #### **水平滚动** ```javascript 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. 垂直滚动条绘制 ```javascript 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. 滚动条尺寸计算 ```javascript // 在 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. 滚动条交互增强 ```javascript // 鼠标悬停增粗滚动条 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. 可见区域更新触发瓦片请求 ```javascript // 滚动时发送可见区域到服务器 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 消息 ```javascript // 服务器检测到可见区域变化后,发送 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. 瓦片失效处理 ```javascript 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 消息格式** ```javascript // 服务器发送的 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** ```javascript 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 块解码算法** ```javascript 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 更新流程 ```javascript ┌─────────────────────────────────────────────────────────────┐ │ 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 帧(增量帧)技术。