docs(collabora): organize deployment guides and fix proxy chain

This commit is contained in:
wren
2026-05-11 17:54:39 +08:00
parent dcc0f3c30d
commit f788149ca7
16 changed files with 2959 additions and 15 deletions
@@ -0,0 +1,632 @@
## 一、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 并更新现有瓦片的像素数据
这种机制实现&#x4E86;**&#x9AD8;效的文档滚动和实时协作编辑**,类似于视频编码中的 I 帧(关键帧)和 P 帧(增量帧)技术。