fix: 全面审计修复 — 键盘/印章库/右键/资源/CSP 加固 #1
16 changed files with 292 additions and 105 deletions
46
deploy.sh
Executable file
46
deploy.sh
Executable file
|
|
@ -0,0 +1,46 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# yinji 静态站部署脚本 — 阿里云 OSS
|
||||
# 只同步应上线的白名单文件,并按资源类型设置缓存头。
|
||||
# 用法:
|
||||
# BUCKET=oss://your-bucket ./deploy.sh [--dry-run]
|
||||
# 前置: ossutil 已 config(AK/SK/endpoint)。详见 docs/DEPLOY.md
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
BUCKET="${BUCKET:-}"
|
||||
DRY_RUN=""
|
||||
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||
DRY_RUN="--dry-run"
|
||||
fi
|
||||
|
||||
if [[ -z "$BUCKET" ]]; then
|
||||
echo "错误: 需设置 BUCKET,例如 BUCKET=oss://yinji-site ./deploy.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v ossutil >/dev/null 2>&1; then
|
||||
echo "错误: 未找到 ossutil。请先安装并 ossutil config。见 docs/DEPLOY.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# 应用代码 / 入口: 随发版变化, 不缓存(每次校验)
|
||||
APP_HEADER="Cache-Control:no-cache"
|
||||
# 第三方库: 版本固定, 长缓存
|
||||
VENDOR_HEADER="Cache-Control:max-age=31536000"
|
||||
|
||||
echo "==> 同步 vendor/ (长缓存) 到 $BUCKET/vendor/"
|
||||
ossutil cp -r -f vendor/ "$BUCKET/vendor/" --meta "$VENDOR_HEADER" $DRY_RUN
|
||||
|
||||
echo "==> 同步 src/ (不缓存) 到 $BUCKET/src/"
|
||||
ossutil cp -r -f src/ "$BUCKET/src/" --meta "$APP_HEADER" $DRY_RUN
|
||||
|
||||
echo "==> 同步 public/ 到 $BUCKET/public/"
|
||||
ossutil cp -r -f public/ "$BUCKET/public/" --meta "$APP_HEADER" $DRY_RUN
|
||||
|
||||
echo "==> 同步 index.html (不缓存, 入口最后传保证一致性) 到 $BUCKET/"
|
||||
ossutil cp -f index.html "$BUCKET/index.html" --meta "$APP_HEADER" $DRY_RUN
|
||||
|
||||
echo "==> 完成。若 CDN 有旧缓存, 刷新 index.html 与 src/ 目录。"
|
||||
39
docs/AUDIT-2026-07-21.md
Normal file
39
docs/AUDIT-2026-07-21.md
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# 全面审计报告 — 2026-07-21
|
||||
|
||||
> 方法: 4 路并行子代理审计(core / modules / wiring+UI / security),逐条交叉验证后修复。
|
||||
> 提交: `fix/audit-hardening` (db73b34) | cluster-recall #5785
|
||||
> 验证闭环: `node --check` 语法全检 + `yinji-cli` 冒烟/状态/批量/端到端 + UI 截图 + CSP 违规检查
|
||||
|
||||
## 已修复(8 类, 11 文件)
|
||||
|
||||
| 严重度 | 文件 | 缺陷 | 修复 |
|
||||
|--------|------|------|------|
|
||||
| HIGH | main.js:1071 | 键盘快捷键不判焦点: 坐标框 Backspace 删印章、方向键移印章、旋转框输不了负号、文本框 Ctrl+C/V/S 被劫持 | handler 开头加焦点守卫(INPUT/TEXTAREA/SELECT/contenteditable/isComposing → return) |
|
||||
| HIGH | seal-library.js:19 | `getSavedSeals` 不校验数组,脏 localStorage 打死印章库读/写/渲染 | `Array.isArray` 校验 + `isValidSeal` 逐项过滤 + 格式异常自动重置 |
|
||||
| MED | seal-library.js:260 | `loadAndApplySeal` 无 onerror + 成功 toast 在 onload 外同步弹 | toast 移入 onload、补 onerror、handler 先于 src 赋值 |
|
||||
| MED | canvas-manager.js:113 / context-menu.js:42 | 右键不选中对象 → 菜单不出现或作用于旧对象 | 开 `fireRightClick`/`stopContextMenu`,菜单前 `findTarget` 命中置活动对象 |
|
||||
| MED | responsive.js:70-139 | 触摸手势死代码(`_canvas` 从不赋值) + body 全子树 MutationObserver 常驻空转 | 移除死代码链(fabric 自带触摸兜底),保留响应式布局 |
|
||||
| LOW | exporter/batch-processing/batch-operations/settings (×5) | blob URL `click()` 后立即 revoke → 部分浏览器下载失败 | 延迟 `setTimeout(revoke, 1000)` |
|
||||
| LOW | batch-processing.js:253 | 下载名 `.replace('.pdf')` 首次匹配+区分大小写+未 sanitize | 统一 `generateSafeExportFilename` |
|
||||
| MED(加固) | index.html:9 / security.js | CSP 缺 object/frame 限制、无转义助手 | CSP 加 `object-src 'none'; frame-ancestors 'none'`;补 `escapeHtml()` |
|
||||
| INFO | tmp-global-patch-test-a.js | 根目录遗留测试残file | 删除 |
|
||||
|
||||
## 刻意不改(附理由)
|
||||
|
||||
- **exporter 恒 `degrees(0)`**: 旋转烘焙进位图(`getRotatedCroppedImage`) + 对象 `lockRotation:true`,正常使用不可能产生非零 angle,当前不可触发。
|
||||
- **state.js `setState` 浅拷贝**: previousState 与集合/数组字段共享引用,但 batch-operations 已用 `new Set(...)` 防御拷贝规避。
|
||||
- **seal id `Date.now()` 毫秒碰撞**: 手动操作极难触发。
|
||||
- **pdf.js `isEvalSupported`**: CSP 无 `unsafe-eval`,eval 本就被拦。
|
||||
- **SVG 白名单(image/svg+xml)**: 经确认非 XSS 向量(仅经 `<img>`/fabric image 静态渲染,不执行脚本),保留功能。
|
||||
- **移除 `script-src 'unsafe-inline'`**: 需搬 index.html 内联 lucide 初始化脚本,有回归风险,建议后续单独一项处理。
|
||||
|
||||
## 安全结论
|
||||
|
||||
未发现任何可实际利用的 XSS/注入。防护可信,但可信来自"全程用 `textContent` 而非 `innerHTML` 渲染不可信数据(文件名/印章名/签名/toast/dialog)"的编码纪律,而非集中转义工具。所有 innerHTML 均为开发者硬编码静态模板。fabric 反序列化为自写、只吃应用自身内存对象,不吃外部不可信 JSON。
|
||||
|
||||
## 通用教训(见 cluster-recall #5785)
|
||||
|
||||
1. 全局键盘快捷键处理器必须判 `e.target` 是否输入控件 + `e.isComposing`。
|
||||
2. localStorage 读取 `JSON.parse` 后必须校验类型(`Array.isArray`)并逐项过滤脏数据。
|
||||
3. blob URL 下载应延迟 revoke。
|
||||
4. `image.onload` 必配 `onerror`,成功提示放 onload 内。
|
||||
75
docs/DEPLOY.md
Normal file
75
docs/DEPLOY.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 部署 Runbook — 阿里云 OSS/COS + CDN
|
||||
|
||||
> yinji 是 100% 静态站(无构建步骤,无后端,文件不出浏览器)。部署即静态资源分发。
|
||||
> 目标: 阿里云 OSS(或腾讯云 COS)静态网站托管 + CDN 加速 + HTTPS。
|
||||
|
||||
## 前置条件(阻塞项,需先落实)
|
||||
|
||||
| 事项 | 归属 | 说明 |
|
||||
|------|------|------|
|
||||
| 域名确定 | modiqi(业务) | 默认建议 `yinji.modiqi.com`;最终由 modiqi 拍板 |
|
||||
| 域名备案 | modiqi/DevOps | 国内 CDN + 自有域名**必须备案**。`yinji.modiqi.com` 依赖 `modiqi.com` 已备案(子域名可复用主体备案) |
|
||||
| 阿里云账号 + AK/SK | DevOps/modiqi | OSS bucket 创建与 AccessKey 属账号/基础设施层,wenpai 不持有需申领 |
|
||||
| DNS 解析(CNAME 到 CDN) | DevOps | 域名 CNAME 指向 CDN 加速域名 |
|
||||
| `ossutil` 工具 | wenpai(本机) | 上传工具,`ossutil config` 配 AK/SK/endpoint |
|
||||
|
||||
> wenpai 负责应用部署(资源同步 / 缓存策略 / vhost 逻辑);账号、AK/SK、DNS、备案属 DevOps/业务,需协同。
|
||||
|
||||
## 部署包边界(只上传这些)
|
||||
|
||||
```
|
||||
index.html
|
||||
src/ (全部 .js / .css)
|
||||
vendor/ (fabric / pdf.js / pdf-lib / lucide)
|
||||
public/ (favicon / 图标)
|
||||
```
|
||||
|
||||
**不上传**: `docs/` `tests/` `tools/` `*.pdf`(夹具) `*.png`(截图) `AGENTS.md` `CLAUDE.md` `README.md` `.codex/` `.git/`。
|
||||
`deploy.sh` 已按此白名单同步。
|
||||
|
||||
## 缓存策略(关键)
|
||||
|
||||
无构建=无内容哈希文件名,故按"是否随发版变化"分层:
|
||||
|
||||
| 资源 | Cache-Control | 理由 |
|
||||
|------|---------------|------|
|
||||
| `index.html` | `no-cache`(每次校验) | 入口,发版必须立即生效 |
|
||||
| `src/**` | `no-cache` 或 `max-age=300` | 应用代码,随发版变 |
|
||||
| `vendor/**` | `max-age=31536000, immutable` | 第三方库,版本固定基本不变 |
|
||||
|
||||
> 发版后如 CDN 有旧缓存,对 `index.html` + `src/` 做 CDN 刷新(refresh),`vendor/` 走预热/长缓存。
|
||||
|
||||
## 执行步骤
|
||||
|
||||
```bash
|
||||
# 1. 配置 ossutil(一次性,AK/SK 向 DevOps 申领)
|
||||
ossutil config # 填 endpoint / AccessKeyID / AccessKeySecret
|
||||
|
||||
# 2. 干跑确认将上传的文件集
|
||||
cd ~/Projects/yinji
|
||||
BUCKET=oss://<bucket-name> ./deploy.sh --dry-run
|
||||
|
||||
# 3. 正式同步(带缓存头)
|
||||
BUCKET=oss://<bucket-name> ./deploy.sh
|
||||
|
||||
# 4. OSS 控制台开启"静态网站托管": 默认首页 index.html
|
||||
# 5. 绑定 CDN 加速域名, 回源 OSS; 配 HTTPS 证书(LE 或已有通配符)
|
||||
# 6. 强制 HTTPS + HTTP/2; index.html/src 缓存按上表设置
|
||||
# 7. DNS: 域名 CNAME → CDN 加速域名(DevOps)
|
||||
```
|
||||
|
||||
## 上线验收(不能只测首页)
|
||||
|
||||
```bash
|
||||
DOMAIN=yinji.modiqi.com
|
||||
curl -skI https://$DOMAIN/ | grep -i "http/\|cache-control" # 200 + no-cache
|
||||
curl -skI https://$DOMAIN/vendor/fabric.min.js | grep -i "cache-control" # 长缓存
|
||||
# 浏览器实测: 打开文件→选印章→盖章→导出 PDF/PNG→批量; 移动端布局; 无 CSP 违规(控制台)
|
||||
```
|
||||
|
||||
> 验收标准遵循集群规范: 完整前后台流程 + 无控制台报错,不能只测首页。
|
||||
|
||||
## 备选
|
||||
|
||||
- **腾讯云 COS**: 同构,`coscli` 替 `ossutil`,静态托管 + CDN 同理。
|
||||
- **海外/全球访问**: OSS 源站 + Cloudflare 前置,或直接 Cloudflare Pages(git 驱动免运维)。
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'"
|
||||
content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; base-uri 'self'; form-action 'self'; object-src 'none'; frame-ancestors 'none'"
|
||||
/>
|
||||
<title>印迹(Yinji)- 本地文件印章工具</title>
|
||||
<meta
|
||||
|
|
@ -115,6 +115,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button id="undoBtn" class="icon-btn" title="撤销 (Ctrl+Z)" aria-label="撤销" disabled><i data-lucide="undo"></i></button>
|
||||
<button id="redoBtn" class="icon-btn" title="重做 (Ctrl+Y)" aria-label="重做" disabled><i data-lucide="redo"></i></button>
|
||||
<button id="settingsBtn" class="icon-btn" title="设置"><i data-lucide="settings"></i></button>
|
||||
<button id="helpBtn" class="icon-btn" title="键盘快捷键"><i data-lucide="help-circle"></i></button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { getState } from './state.js';
|
||||
import { dataUrlToUint8Array } from '../utils/data-url.js';
|
||||
import { generateSafeExportFilename } from '../utils/security.js';
|
||||
|
||||
// ==================== State ====================
|
||||
|
||||
|
|
@ -250,9 +251,12 @@ async function processSingleBatchFile(fileItem) {
|
|||
// Download file
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = fileItem.file.name.replace('.pdf', '_盖章版.pdf');
|
||||
// 统一走 sanitize,并按扩展名(大小写不敏感、仅结尾)正确改名,
|
||||
// 避免 "2024.pdf.report.pdf" 错位替换或大写 .PDF 不加后缀导致同名覆盖。
|
||||
link.download = generateSafeExportFilename(fileItem.file, '_盖章版');
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
|
||||
resolve();
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,32 @@ const MAX_SEALS = 20; // 最多保存20个印章
|
|||
* Get all saved seals from localStorage
|
||||
* @returns {Array} Array of seal objects
|
||||
*/
|
||||
function isValidSeal(seal) {
|
||||
return Boolean(
|
||||
seal &&
|
||||
typeof seal === 'object' &&
|
||||
typeof seal.imageData === 'string' &&
|
||||
seal.imageData.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function getSavedSeals() {
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
return data ? JSON.parse(data) : [];
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(data);
|
||||
// 校验解析结果必须是数组,且逐项过滤脏数据;
|
||||
// 否则被污染成 {}/字符串/数字的键会让下游 findIndex/forEach/length 全线崩溃。
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.warn('印章库数据格式异常,已重置');
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return [];
|
||||
}
|
||||
|
||||
return parsed.filter(isValidSeal);
|
||||
} catch (error) {
|
||||
console.error('读取印章库失败:', error);
|
||||
window.Toast?.error('读取印章库失败');
|
||||
|
|
@ -258,7 +280,6 @@ function loadAndApplySeal(sealId) {
|
|||
);
|
||||
|
||||
const sealImageElement = new Image();
|
||||
sealImageElement.src = seal.imageData;
|
||||
sealImageElement.onload = () => {
|
||||
// Apply all settings
|
||||
if (rotationSlider && rotationInput) {
|
||||
|
|
@ -278,9 +299,17 @@ function loadAndApplySeal(sealId) {
|
|||
sealRotation: seal.rotation || 0,
|
||||
sealBrightness: seal.brightness || 100,
|
||||
});
|
||||
|
||||
// 成功提示必须在 onload 内:图片损坏时不能假报成功,
|
||||
// 否则 state 未更新却提示成功,后续盖章会用旧/空印章。
|
||||
window.Toast?.success(`已加载印章 "${seal.name}"`);
|
||||
};
|
||||
|
||||
window.Toast?.success(`已加载印章 "${seal.name}"`);
|
||||
sealImageElement.onerror = () => {
|
||||
window.Toast?.error(`印章 "${seal.name}" 数据损坏,无法加载`);
|
||||
};
|
||||
|
||||
sealImageElement.src = seal.imageData;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -202,7 +202,8 @@ function exportSettings() {
|
|||
link.download = 'yinji-settings.json';
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
window.Toast?.success('设置已导出');
|
||||
}
|
||||
|
||||
|
|
|
|||
25
src/main.js
25
src/main.js
|
|
@ -905,6 +905,17 @@ function initializeEventListeners() {
|
|||
onToolbarStatusChange: updateToolbarStatus,
|
||||
});
|
||||
|
||||
// Undo / Redo buttons (disabled 状态由 window.History.updateUndoRedoButtons 维护)
|
||||
const undoBtn = document.getElementById('undoBtn');
|
||||
if (undoBtn) {
|
||||
undoBtn.addEventListener('click', () => window.History.undo());
|
||||
}
|
||||
|
||||
const redoBtn = document.getElementById('redoBtn');
|
||||
if (redoBtn) {
|
||||
redoBtn.addEventListener('click', () => window.History.redo());
|
||||
}
|
||||
|
||||
// Help button
|
||||
const helpBtn = document.getElementById('helpBtn');
|
||||
if (helpBtn) {
|
||||
|
|
@ -1069,6 +1080,20 @@ function handlePdfFileWithCallbacks(file) {
|
|||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
function handleKeyboardShortcuts(e) {
|
||||
// 焦点在输入控件或正在中文输入时,让位给原生编辑行为,
|
||||
// 否则会出现坐标框 Backspace 误删印章、方向键误移、负号/文本无法输入、Ctrl+C/V/S 被劫持等问题。
|
||||
const target = e.target;
|
||||
const tag = target?.tagName;
|
||||
if (
|
||||
e.isComposing ||
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
tag === 'SELECT' ||
|
||||
target?.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = getCurrentPageCanvas();
|
||||
const activeObject = canvas?.getActiveObject();
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ function triggerPdfDownload(bytes, filename) {
|
|||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -110,7 +110,14 @@ export async function initializeFabricCanvasForPage(pageNum, forceRecalculate =
|
|||
const originalHeight = highResViewport.height;
|
||||
|
||||
const canvasEl = document.getElementById(`canvas-${pageNum}`);
|
||||
const fabricCanvas = fabricCanvases[pageNum - 1] || new fabric.Canvas(canvasEl);
|
||||
const fabricCanvas =
|
||||
fabricCanvases[pageNum - 1] ||
|
||||
new fabric.Canvas(canvasEl, {
|
||||
// 允许右键选中/操作对象,并阻止浏览器原生右键菜单,
|
||||
// 使自定义右键菜单能作用在实际点到的印章上。
|
||||
fireRightClick: true,
|
||||
stopContextMenu: true,
|
||||
});
|
||||
|
||||
if (!fabricCanvases[pageNum - 1]) {
|
||||
fabricCanvas.originalWidth = originalWidth;
|
||||
|
|
|
|||
|
|
@ -105,7 +105,8 @@ export async function exportPDF(pdfFile) {
|
|||
|
||||
window.Loading.updateProgress(100, '完成!');
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
|
||||
window.Loading.hide();
|
||||
window.Toast.success(`已开始下载:${safeFilename}`);
|
||||
|
|
@ -175,7 +176,8 @@ export async function exportCurrentPageAsImage(sourceFile) {
|
|||
|
||||
window.Loading.updateProgress(100, '完成!');
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
// 延迟撤销:部分浏览器在下载真正开始前撤销会导致下载失败/空文件
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 1000);
|
||||
|
||||
try {
|
||||
await pdfDoc.destroy?.();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
/**
|
||||
* 响应式布局管理模块
|
||||
* 处理移动端适配、触摸手势、布局切换
|
||||
* 处理移动端适配、布局切换
|
||||
*
|
||||
* 注: 印章的触摸拖动/缩放由 fabric.js 画布原生处理。
|
||||
* 早期版本这里有一套自定义 touch 手势,但依赖从未被赋值的 canvasEl._canvas,
|
||||
* 整条链路是死代码,且配套的全文档 MutationObserver 常驻空转,已移除。
|
||||
*/
|
||||
|
||||
let isMobile = false;
|
||||
let touchStartX = 0;
|
||||
let touchStartY = 0;
|
||||
|
||||
/**
|
||||
* 检测屏幕宽度并应用响应式布局
|
||||
|
|
@ -43,100 +45,12 @@ function applyMobileLayout(mobile) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算触摸两点间距离
|
||||
*/
|
||||
function getTouchDistance(touch1, touch2) {
|
||||
const dx = touch1.clientX - touch2.clientX;
|
||||
const dy = touch1.clientY - touch2.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化触摸手势支持
|
||||
* @param {HTMLElement} canvasEl - 画布元素
|
||||
*/
|
||||
export function initTouchGestures(canvasEl) {
|
||||
if (!canvasEl) return;
|
||||
|
||||
let initialScale = 1;
|
||||
let objectBeingMoved = null;
|
||||
|
||||
canvasEl.addEventListener('touchstart', (e) => {
|
||||
if (e.touches.length === 1) {
|
||||
// 单指触摸:拖动
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
objectBeingMoved = canvasEl._canvas?.getActiveObject();
|
||||
} else if (e.touches.length === 2) {
|
||||
// 双指触摸:缩放+旋转
|
||||
initialScale = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
canvasEl.addEventListener('touchmove', (e) => {
|
||||
const canvas = canvasEl._canvas;
|
||||
if (!canvas) return;
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
// 单指拖动:移动选中对象
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (activeObject && objectBeingMoved) {
|
||||
const dx = e.touches[0].clientX - touchStartX;
|
||||
const dy = e.touches[0].clientY - touchStartY;
|
||||
|
||||
activeObject.set({
|
||||
left: activeObject.left + dx,
|
||||
top: activeObject.top + dy,
|
||||
});
|
||||
canvas.renderAll();
|
||||
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchStartY = e.touches[0].clientY;
|
||||
}
|
||||
} else if (e.touches.length === 2) {
|
||||
// 双指缩放+旋转
|
||||
e.preventDefault();
|
||||
const currentScale = getTouchDistance(e.touches[0], e.touches[1]);
|
||||
const scaleRatio = currentScale / initialScale;
|
||||
|
||||
const activeObject = canvas.getActiveObject();
|
||||
if (activeObject) {
|
||||
activeObject.set({
|
||||
scaleX: activeObject.scaleX * scaleRatio,
|
||||
scaleY: activeObject.scaleY * scaleRatio,
|
||||
});
|
||||
canvas.renderAll();
|
||||
}
|
||||
|
||||
initialScale = currentScale;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
canvasEl.addEventListener('touchend', (e) => {
|
||||
objectBeingMoved = null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化响应式布局
|
||||
*/
|
||||
export function initResponsive() {
|
||||
detectAndApplyResponsive();
|
||||
window.addEventListener('resize', detectAndApplyResponsive);
|
||||
|
||||
// 为所有画布初始化触摸手势
|
||||
const observer = new MutationObserver(() => {
|
||||
document.querySelectorAll('canvas').forEach((canvas) => {
|
||||
if (!canvas._touchInitialized) {
|
||||
initTouchGestures(canvas);
|
||||
canvas._touchInitialized = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -295,10 +295,17 @@ export async function addStraddleSeal() {
|
|||
const imgPiece = await loadFabricImage(tempPieceCanvas.toDataURL());
|
||||
imgPiece.scale(initialScale);
|
||||
const scaledPieceWidth = pieceWidth * initialScale;
|
||||
const scaledPieceHeight = sealImageElement.height * initialScale;
|
||||
// 按页面实际高度垂直居中,替换硬编码 top:400,
|
||||
// 避免固定像素在小页面出界或不同尺寸页位置不一致。
|
||||
const straddleTop = Math.max(
|
||||
0,
|
||||
(canvas.originalHeight - scaledPieceHeight) / 2
|
||||
);
|
||||
|
||||
imgPiece.set({
|
||||
left: canvas.originalWidth - scaledPieceWidth,
|
||||
top: 400,
|
||||
top: straddleTop,
|
||||
hasControls: true,
|
||||
borderColor: '#007bff',
|
||||
lockMovementX: true,
|
||||
|
|
|
|||
|
|
@ -40,7 +40,24 @@ function handleContextMenuOpen(event) {
|
|||
}
|
||||
|
||||
const canvas = getCurrentPageCanvas();
|
||||
const activeObject = canvas?.getActiveObject();
|
||||
if (!canvas) {
|
||||
hideContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
// 先把右键点到的对象设为活动对象,再读 activeObject,
|
||||
// 否则菜单会不出现(此前没选中)或作用在上一个选中的对象上。
|
||||
try {
|
||||
const target = canvas.findTarget(event);
|
||||
if (target && !target.isBackgroundImage) {
|
||||
canvas.setActiveObject(target);
|
||||
canvas.requestRenderAll();
|
||||
}
|
||||
} catch (error) {
|
||||
// findTarget 在个别 fabric 版本可能抛错,忽略后回退到当前选中态
|
||||
}
|
||||
|
||||
const activeObject = canvas.getActiveObject();
|
||||
|
||||
if (!activeObject) {
|
||||
hideContextMenu();
|
||||
|
|
|
|||
|
|
@ -60,6 +60,25 @@ export function generateSafeExportFilename(
|
|||
return `document${suffix}.${normalizedExtension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML 转义:任何需要把用户可控数据拼进 innerHTML 的地方都必须先过这里。
|
||||
* 当前代码统一用 textContent 渲染不可信数据(安全),此助手作为纵深防御,
|
||||
* 避免未来改用 innerHTML 时遗漏转义(CSP 含 unsafe-inline,注入即可执行)。
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化安全措施
|
||||
* CSP 已在 HTML meta 标签中声明,无需运行时设置
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export function run() { return foo.bar(); }
|
||||
Loading…
Add table
Add a link
Reference in a new issue