mirror of
https://gitee.com/ulthon/ulthon_information.git
synced 2026-07-01 18:22:49 +08:00
- renderDomPageThumbnails()完整實現:逐頁創建DOM容器+transform縮放 - 直接複用pages[].html(含品牌header/內容/頁碼),保證預覽=輸出 - 對齊select事件處理兼容DOM預覽和JPEG預覽兩種模式 - refreshDomPage()更新valign class - 渲染進度日誌每5頁輸出
1925 lines
72 KiB
JavaScript
1925 lines
72 KiB
JavaScript
/**
|
||
* 操作日志面板模块
|
||
* 提供实时日志显示,替代 layer.load() 遮罩
|
||
*/
|
||
var PhoneImageLogPanel = (function () {
|
||
var $panel = null;
|
||
var $body = null;
|
||
var MAX_ENTRIES = 500;
|
||
var entryCount = 0;
|
||
|
||
function formatTime() {
|
||
var d = new Date();
|
||
var h = (d.getHours() < 10 ? '0' : '') + d.getHours();
|
||
var m = (d.getMinutes() < 10 ? '0' : '') + d.getMinutes();
|
||
var s = (d.getSeconds() < 10 ? '0' : '') + d.getSeconds();
|
||
return h + ':' + m + ':' + s;
|
||
}
|
||
|
||
function log(msg, level) {
|
||
if (!$body) return;
|
||
level = level || 'info';
|
||
var entryHtml = '<div class="log-entry log-' + level + '">' +
|
||
'<span class="log-time">' + formatTime() + '</span>' +
|
||
'<span class="log-msg">' + msg + '</span>' +
|
||
'</div>';
|
||
$body.append(entryHtml);
|
||
entryCount++;
|
||
if (entryCount > MAX_ENTRIES) {
|
||
$body.children().first().remove();
|
||
entryCount--;
|
||
}
|
||
$body.scrollTop($body[0].scrollHeight);
|
||
}
|
||
|
||
function clear() {
|
||
if (!$body) return;
|
||
$body.empty();
|
||
entryCount = 0;
|
||
}
|
||
|
||
function toggle() {
|
||
if (!$panel) return;
|
||
$panel.toggleClass('collapsed');
|
||
}
|
||
|
||
function collapse() {
|
||
if (!$panel) return;
|
||
$panel.addClass('collapsed');
|
||
}
|
||
|
||
function expand() {
|
||
if (!$panel) return;
|
||
$panel.removeClass('collapsed');
|
||
}
|
||
|
||
function init(container) {
|
||
var panelHtml = '<div class="log-panel" id="log-panel">' +
|
||
'<div class="log-panel-header">' +
|
||
'<span style="font-size:12px;color:#ccc;">操作日志</span>' +
|
||
'<div class="log-panel-actions">' +
|
||
'<button type="button" id="log-btn-clear" style="background:transparent;border:1px solid #555;color:#aaa;padding:1px 8px;cursor:pointer;border-radius:3px;font-size:11px;">清空</button>' +
|
||
'<button type="button" id="log-btn-toggle" style="background:transparent;border:1px solid #555;color:#aaa;padding:1px 8px;cursor:pointer;border-radius:3px;font-size:11px;">收起</button>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="log-panel-body"></div>' +
|
||
'</div>';
|
||
$(container).append(panelHtml);
|
||
$panel = $('#log-panel');
|
||
$body = $panel.find('.log-panel-body');
|
||
$('#log-btn-clear').on('click', function () { clear(); });
|
||
$('#log-btn-toggle').on('click', function () {
|
||
toggle();
|
||
$(this).text($panel.hasClass('collapsed') ? '展开' : '收起');
|
||
});
|
||
}
|
||
|
||
return { init: init, log: log, clear: clear, toggle: toggle, collapse: collapse, expand: expand };
|
||
})();
|
||
|
||
/**
|
||
* PhoneImageEngine - 手机图片排版引擎
|
||
*
|
||
* 将文章HTML内容自动分页并渲染为手机尺寸图片
|
||
* 依赖: jQuery, SnapDOM (项目已有)
|
||
*
|
||
* 渲染宽度540px, SnapDOM scale=2 输出1080px
|
||
* 小红书: 540x720 (输出1080x1440)
|
||
* 抖音: 540x960 (输出1080x1920)
|
||
*/
|
||
var PhoneImageEngine = (function () {
|
||
|
||
// ===== 配置 =====
|
||
var config = {
|
||
size: 'xiaohongshu',
|
||
watermark: '',
|
||
pageAlignments: {}, // key=页码(1-based), value='top'|'center'
|
||
sizes: {
|
||
xiaohongshu: { width: 540, height: 720 },
|
||
douyin: { width: 540, height: 960 }
|
||
},
|
||
contentPadding: 20,
|
||
fontScale: 1,
|
||
tableFontScale: 1,
|
||
useDomPreview: true
|
||
};
|
||
|
||
// ===== DOM分页预览状态 =====
|
||
var domPages = [];
|
||
var currentDomPageIndex = 0;
|
||
var totalDomPages = 0;
|
||
|
||
// ===== 文章数据 =====
|
||
var postData = {
|
||
id: 0,
|
||
title: '',
|
||
desc: '',
|
||
coverText: '', // 封面文案
|
||
content_html: '',
|
||
poster: '',
|
||
author_name: '',
|
||
site_name: '',
|
||
site_logo: '',
|
||
create_time: '',
|
||
category_name: ''
|
||
};
|
||
|
||
// ===== 分页结果 =====
|
||
var pages = [];
|
||
|
||
/**
|
||
* 确保隐藏渲染区域存在
|
||
*/
|
||
function ensureStaging() {
|
||
if ($('#render-staging').length === 0) {
|
||
$('<div id="render-staging">').css({
|
||
position: 'fixed',
|
||
left: '-9999px',
|
||
top: 0,
|
||
visibility: 'hidden'
|
||
}).appendTo('body');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化引擎
|
||
* @param {Object} options - {postId, title, desc, coverText, contentHtml, poster, authorName, createTime, categoryName}
|
||
* @param {Object} userConfig - {size, fontSize, watermark}
|
||
*/
|
||
function init(options, userConfig) {
|
||
postData.id = options.postId || 0;
|
||
postData.title = options.title || '';
|
||
postData.desc = options.desc || '';
|
||
postData.coverText = options.coverText || '';
|
||
postData.content_html = options.contentHtml || '';
|
||
postData.poster = options.poster || '';
|
||
postData.author_name = options.authorName || '';
|
||
postData.create_time = options.createTime || '';
|
||
postData.category_name = options.categoryName || '';
|
||
postData.site_name = options.siteName || '';
|
||
postData.site_logo = options.siteLogo || '';
|
||
|
||
if (userConfig) {
|
||
$.extend(config, userConfig);
|
||
}
|
||
applyFontScale();
|
||
preloadBrandLogo();
|
||
}
|
||
|
||
/**
|
||
* 应用字号缩放到CSS变量
|
||
*/
|
||
function applyFontScale() {
|
||
var scale = config.fontScale || 1;
|
||
var tableScale = config.tableFontScale || 1;
|
||
var preview = document.getElementById('render-preview');
|
||
if (preview) {
|
||
preview.style.setProperty('--pi-font-scale', scale);
|
||
preview.style.setProperty('--pi-table-font-scale', tableScale);
|
||
}
|
||
var staging = document.getElementById('render-staging');
|
||
if (staging) {
|
||
staging.style.setProperty('--pi-font-scale', scale);
|
||
staging.style.setProperty('--pi-table-font-scale', tableScale);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 渲染排版预览 - 生成分页后的HTML
|
||
* 管线: editorHtml -> parseHtmlToBlocks -> captureEditorBlocks -> generatePages -> renderThumbnails
|
||
* 带并发锁:防止多次 render 同时执行造成数据混乱
|
||
* @returns {jQuery Deferred} resolves with pages array
|
||
*/
|
||
function render() {
|
||
var deferred = $.Deferred();
|
||
|
||
// 防止并发渲染
|
||
if (render._locked) {
|
||
// 标记有待处理的渲染请求,当前渲染完成后会自动重试
|
||
PhoneImageLogPanel.log('渲染进行中,跳过本次请求', 'warn');
|
||
render._pending = true;
|
||
return deferred.reject('rendering').promise();
|
||
}
|
||
render._locked = true;
|
||
PhoneImageLogPanel.log('开始渲染...', 'info');
|
||
|
||
pages = [];
|
||
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
|
||
var pageHeight = sizeConfig.height;
|
||
var contentAreaHeight = pageHeight - (config.contentPadding * 2);
|
||
var brandHeaderOffset = (postData.site_name || postData.site_logo) ? BRAND_HEADER_HEIGHT : 0;
|
||
contentAreaHeight -= brandHeaderOffset;
|
||
|
||
// 从预览区读取已预处理的内容
|
||
syncPreview();
|
||
PhoneImageLogPanel.log('同步预览区内容', 'info');
|
||
var previewEl = document.getElementById('render-preview');
|
||
|
||
// 等待预览区内所有图片加载完成,否则 getBoundingClientRect 测高为0
|
||
PhoneImageLogPanel.log('等待图片加载...', 'info');
|
||
waitForImages(previewEl).then(function () {
|
||
var cleanHtml = previewEl ? previewEl.innerHTML : '';
|
||
var blocks = parseHtmlToBlocks(cleanHtml);
|
||
PhoneImageLogPanel.log('解析内容: ' + blocks.length + ' 个块', 'info');
|
||
|
||
// 空内容检测
|
||
if (blocks.length === 0) {
|
||
$('#paginated-preview').html('<div style="text-align:center;padding:60px 20px;color:#999;">文章正文内容为空</div>');
|
||
render._locked = false;
|
||
return deferred.resolve([]).promise();
|
||
}
|
||
|
||
// 封面页
|
||
pages.push(generateCoverPage(sizeConfig));
|
||
PhoneImageLogPanel.log('生成封面页', 'info');
|
||
|
||
// 内容分页
|
||
PhoneImageLogPanel.log('分页计算中...', 'info');
|
||
captureEditorBlocks(cleanHtml, blocks, contentAreaHeight, sizeConfig).then(function(contentPages) {
|
||
pages = pages.concat(contentPages);
|
||
|
||
// 尾页
|
||
pages.push(generateSummaryPage(sizeConfig, pages.length));
|
||
PhoneImageLogPanel.log('生成尾页', 'info');
|
||
|
||
// 页码 N/M
|
||
var totalPages = pages.length;
|
||
for (var i = 0; i < pages.length; i++) {
|
||
if (pages[i].type === 'content') {
|
||
pages[i].html = pages[i].html.replace(
|
||
'<span>' + pages[i].pageNum + '</span>',
|
||
'<span class="page-number-indicator">' + (i + 1) + '/' + totalPages + '</span>'
|
||
);
|
||
}
|
||
}
|
||
|
||
// 渲染缩略图
|
||
if (config.useDomPreview) {
|
||
renderDomPages();
|
||
PhoneImageLogPanel.log('DOM预览渲染完成,共 ' + pages.length + ' 页', 'success');
|
||
render._locked = false;
|
||
if (render._pending) {
|
||
render._pending = false;
|
||
render().catch(function() {});
|
||
}
|
||
deferred.resolve(pages);
|
||
} else {
|
||
renderThumbnails(sizeConfig).then(function() {
|
||
PhoneImageLogPanel.log('渲染完成,共 ' + pages.length + ' 页', 'success');
|
||
render._locked = false;
|
||
if (render._pending) {
|
||
render._pending = false;
|
||
render().catch(function() {});
|
||
}
|
||
deferred.resolve(pages);
|
||
}).catch(function(err) {
|
||
PhoneImageLogPanel.log('渲染失败: ' + err, 'error');
|
||
render._locked = false;
|
||
deferred.reject(err);
|
||
});
|
||
}
|
||
}).catch(function(err) {
|
||
PhoneImageLogPanel.log('分页计算失败: ' + err, 'error');
|
||
render._locked = false;
|
||
deferred.reject(err);
|
||
});
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* 预处理HTML内容
|
||
*/
|
||
function preprocessContent(html) {
|
||
if (!html) return '';
|
||
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||
|
||
// 移除不支持的媒体标签
|
||
html = html.replace(/<iframe[\s\S]*?<\/iframe>/gi, '[媒体内容]');
|
||
html = html.replace(/<video[\s\S]*?<\/video>/gi, '[媒体内容]');
|
||
html = html.replace(/<svg[\s\S]*?<\/svg>/gi, '');
|
||
html = html.replace(/<embed[^>]*>/gi, '');
|
||
html = html.replace(/<object[\s\S]*?<\/object>/gi, '');
|
||
|
||
// 清除空段落
|
||
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
|
||
|
||
// 移除内联font-size,让CSS变量控制字号
|
||
html = html.replace(/\s*font-size\s*:\s*[^;'"<>]+;?/gi, '');
|
||
|
||
// 处理图片: 保留原始尺寸到data属性, 强制 max-width:100%
|
||
html = html.replace(/<img([^>]*?)>/gi, function (match, attrs) {
|
||
// 移除内联style
|
||
attrs = attrs.replace(/\s*style\s*=\s*"[^"]*"/gi, '');
|
||
attrs = attrs.replace(/\s*style\s*=\s*'[^']*'/gi, '');
|
||
// 提取原始width/height值, 保存到data属性
|
||
var widthVal = '';
|
||
var heightVal = '';
|
||
var widthMatch = attrs.match(/\swidth\s*=\s*"([^"]*)"/i) ||
|
||
attrs.match(/\swidth\s*=\s*'([^']*)'/i);
|
||
var heightMatch = attrs.match(/\sheight\s*=\s*"([^"]*)"/i) ||
|
||
attrs.match(/\sheight\s*=\s*'([^']*)'/i);
|
||
if (widthMatch) widthVal = widthMatch[1];
|
||
if (heightMatch) heightVal = heightMatch[1];
|
||
// 移除原始width/height属性
|
||
attrs = attrs.replace(/\s*width\s*=\s*"[^"]*"/gi, '');
|
||
attrs = attrs.replace(/\s*width\s*=\s*'[^']*'/gi, '');
|
||
attrs = attrs.replace(/\s*height\s*=\s*"[^"]*"/gi, '');
|
||
attrs = attrs.replace(/\s*height\s*=\s*'[^']*'/gi, '');
|
||
// 添加data属性保存原始尺寸(用于比例计算)
|
||
if (widthVal) attrs += ' data-original-width="' + widthVal + '"';
|
||
if (heightVal) attrs += ' data-original-height="' + heightVal + '"';
|
||
return '<img' + attrs + ' style="max-width:100%;height:auto;display:block;margin:10px 0;">';
|
||
});
|
||
|
||
return html;
|
||
}
|
||
|
||
/**
|
||
* 同步渲染预览区 - 将编辑器HTML写入预览区
|
||
*/
|
||
function syncPreview() {
|
||
var html = window.phoneImageEditor ? window.phoneImageEditor.getHtml() : postData.content_html;
|
||
var cleanHtml = preprocessContent(html);
|
||
$('#render-preview').html(cleanHtml);
|
||
}
|
||
|
||
/**
|
||
* 等待容器内所有图片加载完成
|
||
* 图片未加载时 getBoundingClientRect() 高度为0,导致分页计算错误
|
||
* @param {HTMLElement} containerEl
|
||
* @returns {jQuery Deferred}
|
||
*/
|
||
function waitForImages(containerEl) {
|
||
var deferred = $.Deferred();
|
||
var imgs = containerEl ? containerEl.querySelectorAll('img') : [];
|
||
if (imgs.length === 0) {
|
||
return deferred.resolve().promise();
|
||
}
|
||
var loaded = 0;
|
||
var total = imgs.length;
|
||
var timer = setTimeout(function () {
|
||
// 超时保底:3秒后强制继续
|
||
deferred.resolve();
|
||
}, 3000);
|
||
|
||
function checkDone() {
|
||
loaded++;
|
||
if (loaded >= total) {
|
||
clearTimeout(timer);
|
||
deferred.resolve();
|
||
}
|
||
}
|
||
|
||
for (var i = 0; i < imgs.length; i++) {
|
||
if (imgs[i].complete && imgs[i].naturalHeight > 0) {
|
||
checkDone();
|
||
} else {
|
||
imgs[i].onload = checkDone;
|
||
imgs[i].onerror = checkDone;
|
||
}
|
||
}
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* 将HTML解析为块级元素数组
|
||
* 每个块: { type, html, estimatedHeight }
|
||
*/
|
||
function parseHtmlToBlocks(html) {
|
||
var blocks = [];
|
||
if (!html) return blocks;
|
||
|
||
var $temp = $('<div>').html(html);
|
||
|
||
// 展平嵌套的包装元素 (div, section, article, main, header, footer)
|
||
// 将其内部内容提取为直接子元素
|
||
var wrapperTags = ['div', 'section', 'article', 'main', 'header', 'footer', 'span'];
|
||
var changed = true;
|
||
var maxIterations = 10;
|
||
while (changed && maxIterations > 0) {
|
||
changed = false;
|
||
maxIterations--;
|
||
$temp.children().each(function () {
|
||
var $el = $(this);
|
||
var tagName = $el.prop('tagName').toLowerCase();
|
||
if ($.inArray(tagName, wrapperTags) !== -1) {
|
||
// 将此包装元素的子内容替换自身
|
||
var $children = $el.children();
|
||
if ($children.length > 0) {
|
||
$el.replaceWith($children);
|
||
changed = true;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
var children = $temp.children();
|
||
|
||
if (children.length === 0) {
|
||
// 纯文本内容,按段落分割
|
||
var text = $temp.text().trim();
|
||
if (text) {
|
||
var paragraphs = text.split(/\n\s*\n|\n/);
|
||
for (var i = 0; i < paragraphs.length; i++) {
|
||
var p = paragraphs[i].trim();
|
||
if (p) {
|
||
blocks.push({
|
||
type: 'p',
|
||
html: '<p>' + p + '</p>',
|
||
estimatedHeight: 40
|
||
});
|
||
}
|
||
}
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
children.each(function () {
|
||
var $el = $(this);
|
||
var tagName = $el.prop('tagName').toLowerCase();
|
||
var block = {
|
||
type: tagName,
|
||
html: $el[0].outerHTML
|
||
};
|
||
|
||
switch (tagName) {
|
||
case 'hr':
|
||
block.type = 'page-break';
|
||
block.html = '';
|
||
block.estimatedHeight = 0;
|
||
break;
|
||
case 'h1':
|
||
case 'h2':
|
||
case 'h3':
|
||
case 'h4':
|
||
case 'h5':
|
||
case 'h6':
|
||
block.type = tagName;
|
||
block.estimatedHeight = 40;
|
||
break;
|
||
case 'table':
|
||
block.type = 'table';
|
||
block.html = $el[0].outerHTML;
|
||
block.estimatedHeight = 0;
|
||
block.needsMeasurement = true;
|
||
break;
|
||
case 'pre':
|
||
block.type = 'pre';
|
||
block.html = $el[0].outerHTML;
|
||
block.estimatedHeight = 0;
|
||
block.needsMeasurement = true;
|
||
break;
|
||
case 'img':
|
||
// 尝试从data属性计算比例高度, 否则标记需要DOM测量
|
||
var imgW = parseInt($el.attr('data-original-width'), 10);
|
||
var imgH = parseInt($el.attr('data-original-height'), 10);
|
||
if (imgW > 0 && imgH > 0) {
|
||
var imgRatio = 500 / imgW;
|
||
block.estimatedHeight = Math.round(imgH * imgRatio) + 20;
|
||
} else {
|
||
block.estimatedHeight = 0;
|
||
block.needsMeasurement = true;
|
||
}
|
||
break;
|
||
case 'ul':
|
||
case 'ol':
|
||
block.estimatedHeight = 40;
|
||
break;
|
||
case 'blockquote':
|
||
block.estimatedHeight = 40;
|
||
break;
|
||
case 'p':
|
||
default:
|
||
block.type = 'p';
|
||
block.estimatedHeight = 40;
|
||
break;
|
||
}
|
||
|
||
blocks.push(block);
|
||
});
|
||
|
||
return blocks;
|
||
}
|
||
|
||
// ===== wangeditor 集成层 =====
|
||
|
||
/**
|
||
* 获取 wangeditor 编辑区的子元素
|
||
* @returns {Array} DOM元素数组
|
||
*/
|
||
function getEditorChildren() {
|
||
var editorArea = document.querySelector('#editor-text-area [data-slate-editor]');
|
||
if (!editorArea) return [];
|
||
|
||
var children = editorArea.children;
|
||
// wangeditor v5: [data-slate-editor] 下可能有一层 div 包装
|
||
// 如果只有一个 child 且是 div,则取其子元素
|
||
if (children.length === 1 && children[0].tagName.toLowerCase() === 'div') {
|
||
return Array.prototype.slice.call(children[0].children);
|
||
}
|
||
return Array.prototype.slice.call(children);
|
||
}
|
||
|
||
/**
|
||
* 从 wangeditor 内容构建分页数据
|
||
* 完整实现:从编辑器DOM截图测高 -> 按分割线分组 -> 按高度分页 -> 生成staging HTML
|
||
* @param {string} html - 编辑器HTML内容
|
||
* @param {Array} blocks - parseHtmlToBlocks 解析后的块数组
|
||
* @param {number} contentAreaHeight - 内容区可用高度(px)
|
||
* @param {Object} sizeConfig - {width, height}
|
||
* @returns {jQuery Deferred} resolves with contentPages array
|
||
*/
|
||
function captureEditorBlocks(html, blocks, contentAreaHeight, sizeConfig) {
|
||
var deferred = $.Deferred();
|
||
|
||
// 按 page-break 分组
|
||
var groups = [];
|
||
var currentGroup = { blocks: [], domIndices: [] };
|
||
var domIdx = 0;
|
||
|
||
for (var i = 0; i < blocks.length; i++) {
|
||
if (blocks[i].type === 'page-break') {
|
||
if (currentGroup.blocks.length > 0) groups.push(currentGroup);
|
||
currentGroup = { blocks: [], domIndices: [] };
|
||
domIdx++;
|
||
continue;
|
||
}
|
||
currentGroup.blocks.push(blocks[i]);
|
||
currentGroup.domIndices.push(domIdx);
|
||
domIdx++;
|
||
}
|
||
if (currentGroup.blocks.length > 0) groups.push(currentGroup);
|
||
|
||
if (groups.length === 0) {
|
||
deferred.resolve([]);
|
||
return deferred.promise();
|
||
}
|
||
|
||
// 从 #render-preview 获取子元素(已预处理的干净内容)
|
||
var previewEl = document.getElementById('render-preview');
|
||
if (!previewEl) {
|
||
// fallback: 如果预览区不存在,给所有block设默认高度
|
||
for (var k = 0; k < blocks.length; k++) {
|
||
if (!blocks[k].estimatedHeight) blocks[k].estimatedHeight = 40;
|
||
}
|
||
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
|
||
deferred.resolve(contentPages);
|
||
return deferred.promise();
|
||
}
|
||
|
||
var previewChildren = Array.prototype.slice.call(previewEl.children);
|
||
|
||
// 逐 block 精确测高(包含 margin,否则分页偏小导致内容溢出)
|
||
for (var gi = 0; gi < groups.length; gi++) {
|
||
var group = groups[gi];
|
||
|
||
for (var j = 0; j < group.blocks.length; j++) {
|
||
var di = group.domIndices[j];
|
||
if (di < previewChildren.length) {
|
||
var el = previewChildren[di];
|
||
var rect = el.getBoundingClientRect();
|
||
var cs = window.getComputedStyle(el);
|
||
var mt = parseFloat(cs.marginTop) || 0;
|
||
var mb = parseFloat(cs.marginBottom) || 0;
|
||
group.blocks[j].estimatedHeight = Math.round(rect.height + mt + mb);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 给尚未设置高度的 block 设默认值
|
||
for (var k = 0; k < blocks.length; k++) {
|
||
if (!blocks[k].estimatedHeight) {
|
||
blocks[k].estimatedHeight = 40;
|
||
}
|
||
}
|
||
|
||
var contentPages = paginateContent(blocks, contentAreaHeight, sizeConfig);
|
||
deferred.resolve(contentPages);
|
||
return deferred.promise();
|
||
}
|
||
|
||
// ===== 分页核心算法 =====
|
||
|
||
/**
|
||
* 将块级元素按高度累加,超过可用高度时分页
|
||
* 支持 <hr> 强制分页和超大块拆分(超长文本拆成多段)
|
||
*/
|
||
function paginateContent(blocks, contentAreaHeight, sizeConfig) {
|
||
var contentPages = [];
|
||
var currentPageBlocks = [];
|
||
var currentHeight = 0;
|
||
var pageNumber = 1;
|
||
|
||
for (var i = 0; i < blocks.length; i++) {
|
||
var block = blocks[i];
|
||
|
||
// 强制分页标记
|
||
if (block.type === 'page-break') {
|
||
// 结束当前页
|
||
if (currentPageBlocks.length > 0) {
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, false
|
||
));
|
||
currentPageBlocks = [];
|
||
currentHeight = 0;
|
||
pageNumber++;
|
||
}
|
||
continue; // 跳过这个 page-break 块本身
|
||
}
|
||
|
||
// 超大块处理:单个块超过整页高度时需要拆分
|
||
if (block.estimatedHeight > contentAreaHeight) {
|
||
// 检查当前页最后一个块是否为标题,若是则回退使其与超大块第一部分同页
|
||
var lastBlockInPage = currentPageBlocks.length > 0 ? currentPageBlocks[currentPageBlocks.length - 1] : null;
|
||
var lastBlockIsHeading = lastBlockInPage && (lastBlockInPage.type === 'h2' || lastBlockInPage.type === 'h3' || lastBlockInPage.type === 'h4');
|
||
|
||
if (lastBlockIsHeading) {
|
||
currentPageBlocks.pop();
|
||
currentHeight -= lastBlockInPage.estimatedHeight;
|
||
}
|
||
|
||
// 先把当前页已有的内容推出去
|
||
if (currentPageBlocks.length > 0) {
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, false
|
||
));
|
||
currentPageBlocks = [];
|
||
currentHeight = 0;
|
||
pageNumber++;
|
||
}
|
||
|
||
// 超大块拆分后,若有回退的标题,加到新页开头
|
||
if (lastBlockIsHeading) {
|
||
currentPageBlocks = [lastBlockInPage];
|
||
currentHeight = lastBlockInPage.estimatedHeight;
|
||
}
|
||
|
||
// 尝试拆分超大块
|
||
var splitBlocks = splitOversizedBlock(block, contentAreaHeight);
|
||
for (var s = 0; s < splitBlocks.length; s++) {
|
||
var sb = splitBlocks[s];
|
||
if (currentHeight + sb.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, false
|
||
));
|
||
currentPageBlocks = [];
|
||
currentHeight = 0;
|
||
pageNumber++;
|
||
}
|
||
currentPageBlocks.push(sb);
|
||
currentHeight += sb.estimatedHeight;
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// 正常块:判断是否需要换页
|
||
var isHeading = block.type === 'h2' || block.type === 'h3' || block.type === 'h4';
|
||
var nextBlock = (i + 1 < blocks.length) ? blocks[i + 1] : null;
|
||
|
||
if (currentHeight + block.estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
|
||
var remainingSpace = contentAreaHeight - currentHeight;
|
||
// 检查是否可以按句拆分来填满剩余空间
|
||
var canSplit = (block.type === 'p' || block.type === 'blockquote') &&
|
||
block.estimatedHeight <= contentAreaHeight && remainingSpace > 50;
|
||
|
||
if (canSplit) {
|
||
// 按句拆分,逐步填入页面
|
||
var splitParts = splitOversizedBlock(block, contentAreaHeight);
|
||
if (splitParts.length > 1) {
|
||
// 逐句填入当前页剩余空间
|
||
for (var sp = 0; sp < splitParts.length; sp++) {
|
||
if (currentHeight + splitParts[sp].estimatedHeight > contentAreaHeight && currentPageBlocks.length > 0) {
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, false
|
||
));
|
||
currentPageBlocks = [];
|
||
currentHeight = 0;
|
||
pageNumber++;
|
||
}
|
||
currentPageBlocks.push(splitParts[sp]);
|
||
currentHeight += splitParts[sp].estimatedHeight;
|
||
}
|
||
// 跳过后面的 currentPageBlocks.push(block),用拆分后的块代替
|
||
continue;
|
||
}
|
||
}
|
||
// 不可拆分(img/pre/table/h)或拆分失败,直接换页
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, false
|
||
));
|
||
currentPageBlocks = [];
|
||
currentHeight = 0;
|
||
pageNumber++;
|
||
}
|
||
|
||
currentPageBlocks.push(block);
|
||
currentHeight += block.estimatedHeight;
|
||
|
||
// Orphan control: 标题后至少跟一个内容块,若下一个块放不下则标题移到下一页
|
||
if (isHeading && nextBlock) {
|
||
if (currentHeight + nextBlock.estimatedHeight > contentAreaHeight) {
|
||
currentPageBlocks.pop();
|
||
currentHeight -= block.estimatedHeight;
|
||
if (currentPageBlocks.length > 0) {
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, false
|
||
));
|
||
pageNumber++;
|
||
}
|
||
currentPageBlocks = [block];
|
||
currentHeight = block.estimatedHeight;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 最后一页
|
||
if (currentPageBlocks.length > 0) {
|
||
contentPages.push(generateContentPage(
|
||
currentPageBlocks, pageNumber, sizeConfig, true
|
||
));
|
||
}
|
||
|
||
return contentPages;
|
||
}
|
||
|
||
/**
|
||
* 拆分超大块(超长段落文本拆成多段)
|
||
* @param {Object} block - 块级元素
|
||
* @param {number} pageHeight - 可用内容高度
|
||
* @returns {Array} 拆分后的块数组
|
||
*/
|
||
/**
|
||
* 将超大表格按行拆分为多个表格块
|
||
* 保留表头(thead或第一行tr)在每个拆分后的表格中
|
||
* @param {Object} block - 表格块 { type:'table', html, estimatedHeight }
|
||
* @param {number} pageHeight - 可用内容高度
|
||
* @returns {Array} 拆分后的块数组
|
||
*/
|
||
function splitTableByRows(block, pageHeight) {
|
||
var $table = $(block.html);
|
||
var $allRows = $table.find('tr');
|
||
if ($allRows.length <= 1) return [block];
|
||
|
||
// 提取表头(thead 或第一行)
|
||
var $thead = $table.find('thead');
|
||
var headerHtml = '';
|
||
var dataStartIdx = 0;
|
||
|
||
if ($thead.length > 0) {
|
||
headerHtml = '<thead>' + $thead.html() + '</thead>';
|
||
dataStartIdx = 0; // 数据行从 thead 之后开始
|
||
// 检查 tbody
|
||
var $tbody = $table.find('tbody');
|
||
if ($tbody.length > 0) {
|
||
$allRows = $tbody.find('tr');
|
||
}
|
||
} else {
|
||
// 第一行作为表头
|
||
headerHtml = '<tr>' + $allRows.eq(0).html() + '</tr>';
|
||
dataStartIdx = 1;
|
||
}
|
||
|
||
// 估算每行高度
|
||
var rowCount = $allRows.length - dataStartIdx;
|
||
if (rowCount <= 0) return [block];
|
||
var perRowHeight = block.estimatedHeight / ($allRows.length);
|
||
|
||
// 表头高度
|
||
var headerHeight = perRowHeight;
|
||
// 每页能放的数据行数
|
||
var rowsPerPage = Math.floor((pageHeight - headerHeight) / perRowHeight);
|
||
if (rowsPerPage < 1) rowsPerPage = 1;
|
||
|
||
// 保留原始表格属性(class, style 等)
|
||
var tableAttrs = '';
|
||
var attrs = $table[0].attributes;
|
||
for (var a = 0; a < attrs.length; a++) {
|
||
if (attrs[a].name !== 'class' || attrs[a].value.indexOf('w-e') === -1) {
|
||
tableAttrs += ' ' + attrs[a].name + '="' + attrs[a].value + '"';
|
||
}
|
||
}
|
||
|
||
var result = [];
|
||
for (var r = dataStartIdx; r < $allRows.length; r += rowsPerPage) {
|
||
var chunkHtml = '<table' + tableAttrs + '>';
|
||
chunkHtml += headerHtml;
|
||
chunkHtml += '<tbody>';
|
||
var end = Math.min(r + rowsPerPage, $allRows.length);
|
||
for (var ri = r; ri < end; ri++) {
|
||
chunkHtml += '<tr>' + $allRows.eq(ri).html() + '</tr>';
|
||
}
|
||
chunkHtml += '</tbody></table>';
|
||
|
||
var chunkRows = end - r + (headerHtml ? 1 : 0);
|
||
result.push({
|
||
type: 'table',
|
||
html: chunkHtml,
|
||
estimatedHeight: Math.round(chunkRows * perRowHeight)
|
||
});
|
||
}
|
||
|
||
return result.length > 0 ? result : [block];
|
||
}
|
||
|
||
function splitOversizedBlock(block, pageHeight) {
|
||
// 图片块不拆分,保留原样(会被截断但保持完整)
|
||
if (block.type === 'img') {
|
||
return [block];
|
||
}
|
||
|
||
// 表格块:按行拆分
|
||
if (block.type === 'table') {
|
||
return splitTableByRows(block, pageHeight);
|
||
}
|
||
|
||
// 代码块不拆分,保留原样
|
||
if (block.type === 'pre') {
|
||
return [block];
|
||
}
|
||
|
||
// 文本类块:按句子拆分
|
||
var text = '';
|
||
var wrapperTag = 'p';
|
||
if (block.type === 'blockquote') {
|
||
text = $(block.html).text();
|
||
wrapperTag = 'blockquote';
|
||
} else {
|
||
text = $(block.html).text();
|
||
}
|
||
|
||
if (!text || text.length === 0) {
|
||
return [block];
|
||
}
|
||
|
||
// 按句子拆分:句号、问号、叹号、换行(兼容不支持 lookbehind 的浏览器)
|
||
var effectiveFontSize = 14 * (config.fontScale || 1);
|
||
var parts = text.split(/([。!?\n])/);
|
||
var sentences = [];
|
||
var current = '';
|
||
for (var si = 0; si < parts.length; si++) {
|
||
current += parts[si];
|
||
if (/[。!?\n]/.test(parts[si]) && current.length > 0) {
|
||
sentences.push(current);
|
||
current = '';
|
||
}
|
||
}
|
||
if (current) sentences.push(current);
|
||
|
||
if (sentences.length <= 1) {
|
||
// 无法按句子拆分,按固定字符数拆分
|
||
sentences = [];
|
||
var chunkSize = Math.floor(500 / effectiveFontSize) *
|
||
Math.floor(pageHeight / (effectiveFontSize * 1.8));
|
||
if (chunkSize < 10) chunkSize = 10;
|
||
for (var ci = 0; ci < text.length; ci += chunkSize) {
|
||
sentences.push(text.substring(ci, ci + chunkSize));
|
||
}
|
||
}
|
||
|
||
var result = [];
|
||
for (var j = 0; j < sentences.length; j++) {
|
||
var s = sentences[j].trim();
|
||
if (!s) continue;
|
||
var lineChars = Math.floor(500 / effectiveFontSize);
|
||
if (lineChars < 1) lineChars = 1;
|
||
var lines = Math.ceil(s.length / lineChars);
|
||
var h = lines * effectiveFontSize * 1.8 + 10;
|
||
result.push({
|
||
type: wrapperTag === 'blockquote' ? 'blockquote' : 'p',
|
||
html: '<' + wrapperTag + '>' + s + '</' + wrapperTag + '>',
|
||
estimatedHeight: h
|
||
});
|
||
}
|
||
|
||
return result.length > 0 ? result : [block];
|
||
}
|
||
|
||
// ===== 页面HTML生成 =====
|
||
|
||
/**
|
||
* 检查是否有有效的封面图(排除默认头像占位图)
|
||
*/
|
||
function hasValidPoster() {
|
||
if (!postData.poster) return false;
|
||
// 排除默认头像占位图
|
||
var defaultPatterns = ['/static/images/avatar.png', '/static/images/avatar.jpeg', '/static/images/avatar.jpg'];
|
||
for (var i = 0; i < defaultPatterns.length; i++) {
|
||
if (postData.poster.indexOf(defaultPatterns[i]) !== -1) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ===== 品牌头部 =====
|
||
var BRAND_HEADER_HEIGHT = 36;
|
||
var brandLogoBase64 = null;
|
||
|
||
function generateBrandHeader() {
|
||
if (!postData.site_name && !postData.site_logo) return '';
|
||
|
||
var html = '<div class="brand-header" style="height:' + BRAND_HEADER_HEIGHT + 'px;">';
|
||
|
||
if (postData.site_logo) {
|
||
var logoSrc = brandLogoBase64 || postData.site_logo;
|
||
html += '<img class="brand-logo" src="' + escapeHtml(logoSrc) + '" alt="" onerror="this.style.display=\'none\'">';
|
||
}
|
||
|
||
if (postData.site_name) {
|
||
html += '<span class="brand-name">' + escapeHtml(postData.site_name) + '</span>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
function preloadBrandLogo() {
|
||
if (!postData.site_logo) return;
|
||
if (postData.site_logo.indexOf('avatar.png') !== -1) {
|
||
postData.site_logo = '';
|
||
return;
|
||
}
|
||
try {
|
||
var img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = function() {
|
||
var canvas = document.createElement('canvas');
|
||
canvas.width = img.naturalWidth;
|
||
canvas.height = img.naturalHeight;
|
||
canvas.getContext('2d').drawImage(img, 0, 0);
|
||
brandLogoBase64 = canvas.toDataURL('image/png');
|
||
};
|
||
img.src = postData.site_logo;
|
||
} catch(e) {
|
||
// base64 conversion failed, use original URL
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 生成封面页HTML
|
||
*/
|
||
function generateCoverPage(sizeConfig) {
|
||
var hasCover = hasValidPoster();
|
||
var html = '<div class="phone-image-page page-cover';
|
||
if (hasCover) {
|
||
html += ' has-cover-image';
|
||
} else {
|
||
html += ' no-cover-image';
|
||
}
|
||
html += '" style="width:' + sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
|
||
html += generateBrandHeader();
|
||
|
||
if (hasCover) {
|
||
html += '<img class="cover-image" src="' + escapeHtml(postData.poster) + '" alt="" onerror="this.style.display=\'none\'">';
|
||
html += '<div class="cover-title">' + escapeHtml(postData.title) + '</div>';
|
||
if (postData.desc) {
|
||
html += '<div class="cover-subtitle">' + escapeHtml(postData.desc) + '</div>';
|
||
}
|
||
// 封面文案(有封面图)
|
||
if (postData.coverText) {
|
||
html += '<div class="cover-text-inline">' + escapeHtml(postData.coverText) + '</div>';
|
||
}
|
||
html += '<div class="cover-meta">';
|
||
if (postData.category_name) {
|
||
html += escapeHtml(postData.category_name) + ' | ';
|
||
}
|
||
if (postData.author_name) {
|
||
html += escapeHtml(postData.author_name) + ' | ';
|
||
}
|
||
if (postData.create_time) {
|
||
html += postData.create_time;
|
||
}
|
||
html += '</div>';
|
||
} else {
|
||
// 无封面图: 大标题+摘要+装饰线条排版
|
||
html += '<div class="cover-decor-line cover-decor-top"></div>';
|
||
html += '<div class="cover-no-img-content">';
|
||
html += '<div class="cover-no-img-title">' + escapeHtml(postData.title) + '</div>';
|
||
html += '<div class="cover-decor-divider"></div>';
|
||
if (postData.desc) {
|
||
html += '<div class="cover-no-img-desc">' + escapeHtml(postData.desc) + '</div>';
|
||
}
|
||
// 封面文案(无封面图)
|
||
if (postData.coverText) {
|
||
html += '<div class="cover-text-inline cover-text-no-img">' + escapeHtml(postData.coverText) + '</div>';
|
||
}
|
||
html += '<div class="cover-no-img-meta">';
|
||
if (postData.category_name) {
|
||
html += '<span>' + escapeHtml(postData.category_name) + '</span>';
|
||
}
|
||
if (postData.author_name) {
|
||
html += '<span>' + escapeHtml(postData.author_name) + '</span>';
|
||
}
|
||
if (postData.create_time) {
|
||
html += '<span>' + postData.create_time + '</span>';
|
||
}
|
||
html += '</div>';
|
||
html += '</div>';
|
||
html += '<div class="cover-decor-line cover-decor-bottom"></div>';
|
||
}
|
||
|
||
// 水印
|
||
if (config.watermark) {
|
||
html += '<div class="page-watermark">' + escapeHtml(config.watermark) + '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
return { type: 'cover', html: html };
|
||
}
|
||
|
||
/**
|
||
* 生成内容页HTML(含逐页对齐支持)
|
||
*/
|
||
function generateContentPage(blocks, pageNum, sizeConfig, isLast) {
|
||
// 读取逐页对齐配置
|
||
var alignment = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top';
|
||
var valignClass = '';
|
||
if (alignment === 'center') {
|
||
valignClass = ' valign-center';
|
||
} else if (alignment === 'bottom') {
|
||
valignClass = ' valign-bottom';
|
||
}
|
||
|
||
var html = '<div class="phone-image-page page-body' + valignClass + '" style="width:' +
|
||
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
|
||
html += generateBrandHeader();
|
||
|
||
// 页头(仅第一页内容页显示标题)
|
||
if (pageNum === 1) {
|
||
html += '<div class="page-header">';
|
||
html += '<div class="page-title">' + escapeHtml(postData.title) + '</div>';
|
||
html += '</div>';
|
||
}
|
||
|
||
// 作者声明(所有内容页)
|
||
if (postData.author_name) {
|
||
html += '<div class="author-credit">文/' + escapeHtml(postData.author_name) + '</div>';
|
||
}
|
||
|
||
// 正文内容区
|
||
html += '<div class="page-content">';
|
||
for (var i = 0; i < blocks.length; i++) {
|
||
html += blocks[i].html;
|
||
}
|
||
html += '</div>';
|
||
|
||
// 页脚 - 占位,render() 中会替换页码
|
||
html += '<div class="page-footer">';
|
||
html += '<span>' + escapeHtml(postData.author_name || '') + '</span>';
|
||
html += '<span>' + pageNum + '</span>';
|
||
html += '</div>';
|
||
|
||
// 水印
|
||
if (config.watermark) {
|
||
html += '<div class="page-watermark">' + escapeHtml(config.watermark) + '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
return { type: 'content', html: html, pageNum: pageNum };
|
||
}
|
||
|
||
/**
|
||
* 生成尾页/总结页HTML
|
||
*/
|
||
function generateSummaryPage(sizeConfig, totalPages) {
|
||
var html = '<div class="phone-image-page page-summary" style="width:' +
|
||
sizeConfig.width + 'px;height:' + sizeConfig.height + 'px;">';
|
||
html += generateBrandHeader();
|
||
html += '<div class="summary-title">感谢阅读</div>';
|
||
html += '<div class="summary-text">' + escapeHtml(postData.title) + '</div>';
|
||
|
||
if (postData.desc) {
|
||
html += '<div class="summary-text">' + escapeHtml(postData.desc) + '</div>';
|
||
}
|
||
|
||
html += '<div class="summary-footer">';
|
||
html += '共 ' + totalPages + ' 页';
|
||
if (postData.author_name) {
|
||
html += ' | ' + escapeHtml(postData.author_name);
|
||
}
|
||
html += '</div>';
|
||
|
||
// 水印
|
||
if (config.watermark) {
|
||
html += '<div class="page-watermark">' + escapeHtml(config.watermark) + '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
return { type: 'summary', html: html };
|
||
}
|
||
|
||
// ===== 纯图片页优化 =====
|
||
|
||
/**
|
||
* @deprecated 纯图片页已统一走SnapDOM截图流程,此函数不再被调用
|
||
* 检测staging区域中的页面元素是否为纯图片页
|
||
* 纯图片页: .page-body 内 .page-content 只含1个img且无文字内容
|
||
* @param {jQuery} $pageElem - .phone-image-page 元素
|
||
* @returns {boolean}
|
||
*/
|
||
function isPureImagePage($pageElem) {
|
||
// 仅内容页
|
||
if (!$pageElem.hasClass('page-body')) return false;
|
||
var $content = $pageElem.find('.page-content');
|
||
if (!$content.length) return false;
|
||
|
||
// 检查是否有文字内容(排除纯空白文本节点)
|
||
var textContent = $content.text().replace(/\s/g, '');
|
||
if (textContent.length > 0) return false;
|
||
|
||
// 检查img数量: 必须恰好1个img
|
||
var $imgs = $content.find('img');
|
||
if ($imgs.length !== 1) return false;
|
||
|
||
// 跳过由表格/代码块转换而来的图片(不应被当作纯图片页处理)
|
||
if ($imgs.first().attr('data-converted') === 'true') return false;
|
||
|
||
// img必须有有效src
|
||
var src = $imgs.first().attr('src');
|
||
return src && src.length > 0;
|
||
}
|
||
|
||
/**
|
||
* @deprecated 纯图片页已统一走SnapDOM截图流程,此函数不再被调用
|
||
* 从纯图片页元素中获取img的src
|
||
* @param {jQuery} $pageElem
|
||
* @returns {string}
|
||
*/
|
||
function getPureImageSrc($pageElem) {
|
||
var $img = $pageElem.find('.page-content img').first();
|
||
return $img.attr('src') || '';
|
||
}
|
||
|
||
/**
|
||
* @deprecated 纯图片页已统一走SnapDOM截图流程,此函数不再被调用
|
||
* 将原始图片绘制到canvas(用于高质量保存路径)
|
||
* @param {string} src - 图片src
|
||
* @param {number} width - 目标宽度
|
||
* @param {number} height - 目标高度
|
||
* @returns {jQuery Deferred} resolves with canvas
|
||
*/
|
||
function renderPureImageToCanvas(src, width, height) {
|
||
var deferred = $.Deferred();
|
||
var img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = function () {
|
||
if (img.naturalWidth === 0 || img.naturalHeight === 0) {
|
||
deferred.reject('Image has zero dimensions');
|
||
return;
|
||
}
|
||
var canvas = document.createElement('canvas');
|
||
// 使用原图尺寸,保持原始清晰度
|
||
canvas.width = img.naturalWidth;
|
||
canvas.height = img.naturalHeight;
|
||
var ctx = canvas.getContext('2d');
|
||
// 白色背景
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
// 按原图尺寸绘制,不缩放
|
||
ctx.drawImage(img, 0, 0, img.naturalWidth, img.naturalHeight);
|
||
deferred.resolve(canvas);
|
||
};
|
||
img.onerror = function () {
|
||
deferred.reject('Image load failed');
|
||
};
|
||
img.src = src;
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* 共享截图核心逻辑 - 将pages渲染到staging并逐页截图
|
||
* @param {Object} opts - { scale, outputCanvas, quality, sizeConfig }
|
||
* scale: SnapDOM缩放 (1=缩略图, 2=保存)
|
||
* outputCanvas: true=输出canvas对象, false=输出dataURL字符串
|
||
* quality: JPEG质量 (0-1)
|
||
* sizeConfig: 尺寸配置(用于纯图片页canvas渲染)
|
||
* @returns {jQuery Deferred} resolves with array of dataURL strings or canvas objects
|
||
*/
|
||
function doCapturePages(opts, pageProgressCallback) {
|
||
var deferred = $.Deferred();
|
||
ensureStaging();
|
||
applyFontScale();
|
||
|
||
var $staging = $('#render-staging');
|
||
$staging.empty();
|
||
|
||
// 渲染所有页面到隐藏区域
|
||
var sizeClass = 'size-' + config.size;
|
||
var $wrapper = $('<div class="phone-image-container ' + sizeClass + '">');
|
||
for (var i = 0; i < pages.length; i++) {
|
||
$wrapper.append(pages[i].html);
|
||
}
|
||
$staging.append($wrapper);
|
||
|
||
// 等待字体和DOM就绪
|
||
var fontsReady = (typeof document.fonts !== 'undefined' && document.fonts.ready)
|
||
? document.fonts.ready
|
||
: $.Deferred().resolve().promise();
|
||
|
||
fontsReady.catch(function () {
|
||
// 字体加载失败不影响截图,继续执行
|
||
}).then(function () {
|
||
requestAnimationFrame(function () {
|
||
// SnapDOM会跳过visibility:hidden的元素,临时切换为可见
|
||
$staging.css({ visibility: 'visible' });
|
||
// 表格和代码块已在中间栏中转为图片,无需再次转换
|
||
runCaptureLoop($staging, opts, deferred, pageProgressCallback);
|
||
});
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* 执行串行截图循环
|
||
* @param {jQuery} $staging - staging元素
|
||
* @param {Object} opts - 截图选项
|
||
* @param {jQuery Deferred} deferred - 外部deferred
|
||
*/
|
||
function runCaptureLoop($staging, opts, deferred, pageProgressCallback) {
|
||
var $pageElems = $staging.find('.phone-image-page');
|
||
if ($pageElems.length === 0) {
|
||
$staging.css({ visibility: 'hidden' });
|
||
if (opts.outputCanvas) {
|
||
deferred.reject('没有可渲染的页面');
|
||
} else {
|
||
deferred.resolve([]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
var results = [];
|
||
var idx = 0;
|
||
var total = $pageElems.length;
|
||
|
||
function captureNext() {
|
||
if (idx >= total) {
|
||
$staging.empty();
|
||
$staging.css({ visibility: 'hidden' });
|
||
deferred.resolve(results);
|
||
return;
|
||
}
|
||
|
||
var $elem = $pageElems.eq(idx);
|
||
|
||
// 纯图片页统一走SnapDOM截图,不再跳过截图流程
|
||
// 这样纯图片页也会包含品牌header和页码
|
||
|
||
// 统一截图流程
|
||
capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, function () { if (opts.streaming && opts.onPageReady) opts.onPageReady(results[results.length - 1], idx); if (pageProgressCallback) pageProgressCallback(idx + 1, total); idx++; captureNext(); });
|
||
}
|
||
|
||
captureNext();
|
||
}
|
||
|
||
/**
|
||
* 用SnapDOM截图单页
|
||
*/
|
||
function capturePageViaHtml2Canvas($elem, $staging, opts, results, deferred, onDone) {
|
||
snapdom.toCanvas($elem[0], {
|
||
scale: opts.scale,
|
||
backgroundColor: '#ffffff'
|
||
}).then(function (canvas) {
|
||
if (opts.outputCanvas) {
|
||
results.push(canvas);
|
||
} else {
|
||
results.push(canvas.toDataURL('image/jpeg', opts.quality));
|
||
}
|
||
onDone();
|
||
}).catch(function (err) {
|
||
$staging.empty();
|
||
$staging.css({ visibility: 'hidden' });
|
||
deferred.reject('截图失败: ' + err);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 渲染到隐藏区域 -> 截图 -> 显示缩略图
|
||
* @param {Object} sizeConfig
|
||
* @returns {jQuery Deferred} resolves with pages array
|
||
*/
|
||
function renderThumbnails(sizeConfig) {
|
||
var deferred = $.Deferred();
|
||
|
||
// 流式渲染:先清空容器,再逐页追加缩略图
|
||
initThumbnails(sizeConfig);
|
||
|
||
doCapturePages({
|
||
scale: 1,
|
||
outputCanvas: false,
|
||
quality: 0.85,
|
||
sizeConfig: sizeConfig,
|
||
streaming: true,
|
||
onPageReady: function (dataUrl, pageIndex) {
|
||
appendThumbnail(dataUrl, pageIndex, sizeConfig, pages[pageIndex]);
|
||
}
|
||
}, function (current, total) {
|
||
PhoneImageLogPanel.log('截图: 第 ' + current + '/' + total + ' 页', 'info');
|
||
}).then(function (dataUrls) {
|
||
updateThumbnailPageNumbers(dataUrls.length);
|
||
deferred.resolve(pages);
|
||
}).catch(function (err) {
|
||
deferred.reject(err);
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* DOM分页预览 - 将pages[]转换为domPages格式
|
||
* 替代canvas截图方案,直接操作DOM渲染
|
||
*/
|
||
function renderDomPages() {
|
||
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
|
||
|
||
// 清空domPages
|
||
domPages = [];
|
||
|
||
// 如果没有pages数据,返回
|
||
if (!pages || pages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 遍历pages,创建domPage对象
|
||
for (var i = 0; i < pages.length; i++) {
|
||
var page = pages[i];
|
||
var domPage = {
|
||
html: page.html || '',
|
||
type: page.type || 'content',
|
||
pageNum: page.pageNum || (i + 1),
|
||
valign: (config.pageAlignments && config.pageAlignments[i]) || 'top',
|
||
isPureImage: page.type === 'pure-image'
|
||
};
|
||
domPages.push(domPage);
|
||
}
|
||
|
||
totalDomPages = domPages.length;
|
||
currentDomPageIndex = 0;
|
||
|
||
// 调用渲染
|
||
renderDomPageThumbnails(sizeConfig);
|
||
}
|
||
|
||
/**
|
||
* DOM分页预览 - 渲染所有页面的缩略图
|
||
* 将domPages[]逐页创建为DOM元素,替代JPEG截图方案
|
||
* 每页使用transform:scale()缩放显示,保证与最终输出一致
|
||
* @param {Object} sizeConfig - {width, height}
|
||
*/
|
||
function renderDomPageThumbnails(sizeConfig) {
|
||
var $preview = $('#paginated-preview');
|
||
$preview.empty();
|
||
|
||
if (!domPages || domPages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
// 确保size class在父容器上(CSS尺寸约束依赖此class)
|
||
var $parent = $preview.closest('.paginated-preview-area');
|
||
$parent.removeClass('size-xiaohongshu size-douyin').addClass('size-' + config.size);
|
||
|
||
// 计算缩略图缩放比例:容器高度 - 80(页码标签+上下padding)
|
||
var containerHeight = $preview.parent().height() || 700;
|
||
var scaleRatio = (containerHeight - 80) / sizeConfig.height;
|
||
var thumbWidth = Math.round(sizeConfig.width * scaleRatio);
|
||
|
||
// 设置预览容器为水平滚动布局
|
||
$preview.css({
|
||
'display': 'flex',
|
||
'flex-direction': 'row',
|
||
'gap': '20px',
|
||
'padding': '20px',
|
||
'flex-wrap': 'nowrap',
|
||
'overflow-x': 'auto'
|
||
});
|
||
|
||
var totalPages = domPages.length;
|
||
PhoneImageLogPanel.log('开始渲染DOM预览,共 ' + totalPages + ' 页', 'info');
|
||
|
||
// 逐页创建DOM缩略图
|
||
for (var i = 0; i < domPages.length; i++) {
|
||
var domPage = domPages[i];
|
||
var pageType = domPage.type;
|
||
|
||
// 创建缩略图包装器(参照appendThumbnail的交互层模式)
|
||
var $thumbItem = $('<div class="preview-thumb-item" data-page-index="' + i + '"></div>').css({
|
||
'flex-shrink': '0',
|
||
'width': thumbWidth + 'px',
|
||
'position': 'relative'
|
||
});
|
||
|
||
// 创建DOM分页容器(实际尺寸,通过transform缩放显示为缩略图)
|
||
var $container = $('<div class="dom-page-container"></div>').css({
|
||
'transform': 'scale(' + scaleRatio + ')',
|
||
'transform-origin': 'top left',
|
||
'width': sizeConfig.width + 'px',
|
||
'height': sizeConfig.height + 'px'
|
||
});
|
||
|
||
// 纯图片页特殊处理
|
||
if (domPage.isPureImage) {
|
||
$container.addClass('dom-page-pure-image');
|
||
}
|
||
|
||
// 将完整页面HTML放入容器
|
||
// pages[].html 是完整的 .phone-image-page 结构(含品牌header、内容、页码、水印)
|
||
$container.html(domPage.html);
|
||
|
||
$thumbItem.append($container);
|
||
|
||
// 页码标签(N/M格式)
|
||
var $pageNum = $('<span class="preview-thumb-page-num"></span>').text((i + 1) + '/' + totalPages);
|
||
$thumbItem.append($pageNum);
|
||
|
||
// 对齐select(仅内容页显示)
|
||
if (pageType === 'content') {
|
||
var pageNum = domPage.pageNum || (i + 1);
|
||
var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top';
|
||
var $select = $('<select class="thumb-alignment-select" data-page-index="' + i + '" data-page-num="' + pageNum + '">' +
|
||
'<option value="top"' + (currentAlign === 'top' ? ' selected' : '') + '>置顶</option>' +
|
||
'<option value="center"' + (currentAlign === 'center' ? ' selected' : '') + '>居中</option>' +
|
||
'<option value="bottom"' + (currentAlign === 'bottom' ? ' selected' : '') + '>底部</option>' +
|
||
'</select>');
|
||
$thumbItem.append($select);
|
||
}
|
||
|
||
$preview.append($thumbItem);
|
||
|
||
// 渲染进度日志(每5页或最后一页输出)
|
||
if ((i + 1) % 5 === 0 || i === domPages.length - 1) {
|
||
PhoneImageLogPanel.log('渲染DOM页面 ' + (i + 1) + '/' + totalPages, 'info');
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DOM分页预览 - 刷新指定页面的对齐方式
|
||
* 更新 .phone-image-page 上的 valign class
|
||
* @param {number} pageIndex - 页面索引(0-based)
|
||
*/
|
||
function refreshDomPage(pageIndex) {
|
||
var $containers = $('#paginated-preview .dom-page-container');
|
||
if (pageIndex >= 0 && pageIndex < $containers.length) {
|
||
var $phonePage = $containers.eq(pageIndex).find('.phone-image-page');
|
||
if ($phonePage.length) {
|
||
$phonePage.removeClass('valign-top valign-center valign-bottom');
|
||
var alignment = (config.pageAlignments && config.pageAlignments[pageIndex + 1]) || 'top';
|
||
$phonePage.addClass('valign-' + alignment);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 初始化缩略图容器(流式渲染第一步:清空容器)
|
||
* @param {Object} sizeConfig
|
||
*/
|
||
function initThumbnails(sizeConfig) {
|
||
var $preview = $('#paginated-preview');
|
||
if (!$preview.length) return;
|
||
$preview.empty();
|
||
}
|
||
|
||
/**
|
||
* 追加单个缩略图到预览区(流式渲染:每页截图完成后立即追加)
|
||
* @param {string} dataUrl - base64 图片数据或图片src
|
||
* @param {number} pageIndex - 页面索引(0-based)
|
||
* @param {Object} sizeConfig
|
||
* @param {Object} pageData - 页面对象 { type, pageNum, ... }
|
||
*/
|
||
function appendThumbnail(dataUrl, pageIndex, sizeConfig, pageData) {
|
||
var $preview = $('#paginated-preview');
|
||
if (!$preview.length) return;
|
||
|
||
// 计算缩放比例:缩略图高度 = 容器高度 - 40(页码标签) - 40(上下padding)
|
||
var containerHeight = $preview.parent().height() || 700;
|
||
var scaleRatio = (containerHeight - 80) / sizeConfig.height;
|
||
var thumbWidth = Math.round(sizeConfig.width * scaleRatio);
|
||
|
||
var $item = $('<div class="preview-thumb-item" data-page-index="' + pageIndex + '">');
|
||
$item.css('width', thumbWidth + 'px');
|
||
|
||
var $img = $('<img class="preview-thumb-img">');
|
||
$img.attr('src', dataUrl);
|
||
$img.css('width', thumbWidth + 'px');
|
||
$item.append($img);
|
||
|
||
// 页码(初始只显示序号,全部完成后更新为 N/M)
|
||
var $pageNum = $('<span class="preview-thumb-page-num">' + (pageIndex + 1) + '</span>');
|
||
$item.append($pageNum);
|
||
|
||
// 对齐下拉(仅内容页)
|
||
if (pageData && pageData.type === 'content') {
|
||
var pageNum = pageData.pageNum || (pageIndex);
|
||
var currentAlign = (config.pageAlignments && config.pageAlignments[pageNum]) || 'top';
|
||
var $select = $('<select class="thumb-alignment-select" data-page-index="' + pageIndex + '" data-page-num="' + pageNum + '">' +
|
||
'<option value="top"' + (currentAlign === 'top' ? ' selected' : '') + '>置顶</option>' +
|
||
'<option value="center"' + (currentAlign === 'center' ? ' selected' : '') + '>居中</option>' +
|
||
'<option value="bottom"' + (currentAlign === 'bottom' ? ' selected' : '') + '>底部</option>' +
|
||
'</select>');
|
||
$item.append($select);
|
||
}
|
||
|
||
$preview.append($item);
|
||
}
|
||
|
||
/**
|
||
* 更新所有缩略图页码为 N/M 格式(流式渲染全部完成后调用)
|
||
* @param {number} totalPages
|
||
*/
|
||
function updateThumbnailPageNumbers(totalPages) {
|
||
var $items = $('#paginated-preview .preview-thumb-item');
|
||
$items.each(function (i) {
|
||
$(this).find('.preview-thumb-page-num').text((i + 1) + '/' + totalPages);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 将页面渲染到隐藏区域并高质量截图(用于保存)
|
||
* @returns {jQuery Deferred} resolves with canvas array
|
||
*/
|
||
function capturePagesFromStaging() {
|
||
var sizeConfig = config.sizes[config.size] || config.sizes.xiaohongshu;
|
||
return doCapturePages({
|
||
scale: 2,
|
||
outputCanvas: true,
|
||
quality: 0.92,
|
||
sizeConfig: sizeConfig
|
||
});
|
||
}
|
||
|
||
// ===== 图片生成 =====
|
||
|
||
/**
|
||
* 逐页截图生成图片(使用隐藏渲染区域高质量截图)
|
||
* @param {Function} [onProgress] 进度回调 function(currentIndex, totalPages, canvas)
|
||
* @returns {Promise} jQuery Deferred, resolves with array of canvas objects
|
||
*/
|
||
function generateImages(onProgress) {
|
||
var deferred = $.Deferred();
|
||
|
||
if (pages.length === 0) {
|
||
deferred.reject('没有可渲染的页面');
|
||
return deferred.promise();
|
||
}
|
||
|
||
if (onProgress) onProgress(0, pages.length, null);
|
||
|
||
capturePagesFromStaging().then(function (canvases) {
|
||
if (onProgress) onProgress(canvases.length, canvases.length, null);
|
||
deferred.resolve(canvases);
|
||
}).catch(function (err) {
|
||
deferred.reject(err);
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* 将canvas转为base64并保存到服务端
|
||
* @param {number} postId 文章ID
|
||
* @param {Object} saveConfig 配置信息
|
||
* @param {Function} [onProgress] 生成进度回调 function(current, total, canvas)
|
||
* @returns {Promise} jQuery Deferred
|
||
*/
|
||
function saveImages(postId, saveConfig, onProgress) {
|
||
var deferred = $.Deferred();
|
||
|
||
// 第一步:生成分页图片和长图
|
||
capturePagesFromStaging().then(function (canvases) {
|
||
if (onProgress) onProgress(canvases.length, canvases.length, null);
|
||
var pagesData = [];
|
||
for (var i = 0; i < canvases.length; i++) {
|
||
pagesData.push(canvases[i].toDataURL('image/jpeg', 0.92));
|
||
}
|
||
|
||
// 第二步:生成长图
|
||
return generateLongImageBase64().then(function(longBase64) {
|
||
return { pages: pagesData, longImage: longBase64 };
|
||
});
|
||
}).then(function(result) {
|
||
var pagesData = result.pages;
|
||
var longImageData = result.longImage;
|
||
|
||
// 检查数据总大小,防止超大文章导致请求失败
|
||
var totalSize = 0;
|
||
for (var i = 0; i < pagesData.length; i++) {
|
||
if (pagesData[i]) totalSize += pagesData[i].length;
|
||
}
|
||
if (longImageData) totalSize += longImageData.length;
|
||
if (totalSize > 16 * 1024 * 1024) {
|
||
layer.msg('数据量过大(超过16MB),请减少内容或联系管理员');
|
||
deferred.reject('数据量过大(超过16MB)');
|
||
return;
|
||
}
|
||
|
||
// 第三步:发送到后端
|
||
$.ajax({
|
||
url: '/index.php/admin/post/savePostOutput',
|
||
type: 'POST',
|
||
data: JSON.stringify({
|
||
post_id: postId,
|
||
output_type: 'phone_image',
|
||
config: saveConfig || config,
|
||
content_html: postData.content_html,
|
||
pages: pagesData,
|
||
long_image: longImageData
|
||
}),
|
||
contentType: 'application/json',
|
||
success: function (result) {
|
||
if (result.code === 0) {
|
||
deferred.resolve(result.data);
|
||
} else {
|
||
deferred.reject(result.msg || '保存失败');
|
||
}
|
||
},
|
||
error: function (xhr) {
|
||
// 容错:后端可能返回200 JSON + 500错误页面的混合响应
|
||
// 尝试从响应文本开头提取有效JSON
|
||
try {
|
||
var text = xhr.responseText || '';
|
||
var jsonEnd = text.indexOf('}');
|
||
if (jsonEnd > 0) {
|
||
var jsonStr = text.substring(0, jsonEnd + 1);
|
||
var parsed = JSON.parse(jsonStr);
|
||
if (parsed && parsed.code === 0) {
|
||
deferred.resolve(parsed.data || {});
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) { /* 解析失败,走正常错误流程 */ }
|
||
deferred.reject('网络错误: ' + xhr.statusText);
|
||
}
|
||
});
|
||
}).catch(function (err) {
|
||
deferred.reject(err);
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
/**
|
||
* 保存排版配置到服务端(不生成图片)
|
||
* @param {number} postId 文章ID
|
||
* @param {Object} saveConfig 配置信息 {size, fontSize, watermark, content_html}
|
||
* @param {string} url 保存接口URL
|
||
* @returns {Promise} jQuery Deferred, resolves with {output_id}
|
||
*/
|
||
function saveConfig(postId, saveConfig, url) {
|
||
var deferred = $.Deferred();
|
||
|
||
var payload = {
|
||
post_id: postId,
|
||
output_type: 'phone_image',
|
||
config: {
|
||
size: saveConfig.size || config.size,
|
||
watermark: saveConfig.watermark || config.watermark,
|
||
fontScale: config.fontScale || 1,
|
||
tableFontScale: config.tableFontScale || 1,
|
||
pageAlignments: config.pageAlignments || {}
|
||
},
|
||
content_html: saveConfig.content_html || postData.content_html
|
||
};
|
||
|
||
$.ajax({
|
||
url: url || '',
|
||
type: 'POST',
|
||
data: JSON.stringify(payload),
|
||
contentType: 'application/json',
|
||
success: function (result) {
|
||
if (result.code === 0) {
|
||
deferred.resolve(result.data || {});
|
||
} else {
|
||
deferred.reject(result.msg || '保存失败');
|
||
}
|
||
},
|
||
error: function (xhr) {
|
||
// 容错:后端可能返回200 JSON + 500错误页面的混合响应
|
||
try {
|
||
var text = xhr.responseText || '';
|
||
var jsonEnd = text.indexOf('}');
|
||
if (jsonEnd > 0) {
|
||
var jsonStr = text.substring(0, jsonEnd + 1);
|
||
var parsed = JSON.parse(jsonStr);
|
||
if (parsed && parsed.code === 0) {
|
||
deferred.resolve(parsed.data || {});
|
||
return;
|
||
}
|
||
}
|
||
} catch (e) { /* 解析失败,走正常错误流程 */ }
|
||
deferred.reject('网络错误: ' + xhr.statusText);
|
||
}
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
// ===== 逐页对齐 =====
|
||
|
||
/**
|
||
* 设置指定页的对齐方式
|
||
* @param {number} pageNum 页码(1-based)
|
||
* @param {string} align 'top'|'center'
|
||
*/
|
||
function setPageAlignment(pageNum, align) {
|
||
if (!config.pageAlignments) {
|
||
config.pageAlignments = {};
|
||
}
|
||
config.pageAlignments[pageNum] = align;
|
||
}
|
||
|
||
// ===== 长图base64生成(不触发下载) =====
|
||
|
||
/**
|
||
* 生成长图base64(不触发下载)
|
||
* @returns {jQuery Deferred} resolves with base64 string or null
|
||
*/
|
||
function generateLongImageBase64() {
|
||
var deferred = $.Deferred();
|
||
|
||
var editorChildren = getEditorChildren();
|
||
if (editorChildren.length === 0) {
|
||
deferred.resolve(null);
|
||
return deferred.promise();
|
||
}
|
||
|
||
var $container = $('<div>').css({
|
||
width: '540px',
|
||
position: 'fixed',
|
||
left: '-9999px',
|
||
top: '0',
|
||
visibility: 'visible',
|
||
background: '#ffffff',
|
||
padding: '20px',
|
||
boxSizing: 'border-box',
|
||
fontSize: '14px',
|
||
lineHeight: '1.8'
|
||
});
|
||
|
||
for (var i = 0; i < editorChildren.length; i++) {
|
||
var el = editorChildren[i];
|
||
var isDivider = el.getAttribute('data-w-e-type') === 'divider' ||
|
||
el.tagName.toLowerCase() === 'hr';
|
||
if (!isDivider) {
|
||
$container.append(el.cloneNode(true));
|
||
}
|
||
}
|
||
|
||
$('body').append($container);
|
||
|
||
requestAnimationFrame(function () {
|
||
snapdom.toCanvas($container[0], {
|
||
scale: 2,
|
||
backgroundColor: '#ffffff'
|
||
}).then(function (canvas) {
|
||
$container.remove();
|
||
// 使用JPEG压缩减少体积
|
||
deferred.resolve(canvas.toDataURL('image/jpeg', 0.85));
|
||
}).catch(function (err) {
|
||
$container.remove();
|
||
// 长图生成失败不阻断主流程
|
||
console.warn('长图生成失败:', err);
|
||
deferred.resolve(null);
|
||
});
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
// ===== 导出长图 =====
|
||
|
||
/**
|
||
* 导出长图:从 wangeditor DOM 截取完整长图
|
||
* @returns {jQuery Deferred} resolves with canvas
|
||
*/
|
||
function exportLongImage() {
|
||
var deferred = $.Deferred();
|
||
|
||
if (render._locked) {
|
||
deferred.reject('rendering');
|
||
return deferred.promise();
|
||
}
|
||
|
||
var editorChildren = getEditorChildren();
|
||
if (editorChildren.length === 0) {
|
||
deferred.reject('编辑器内容为空');
|
||
return deferred.promise();
|
||
}
|
||
|
||
// 创建临时容器,宽度540px与编辑器一致
|
||
var $container = $('<div>').css({
|
||
width: '540px',
|
||
position: 'fixed',
|
||
left: '-9999px',
|
||
top: '0',
|
||
visibility: 'visible',
|
||
background: '#ffffff',
|
||
padding: '20px',
|
||
boxSizing: 'border-box',
|
||
fontSize: '14px',
|
||
lineHeight: '1.8'
|
||
});
|
||
|
||
// 克隆所有非 divider 的子元素
|
||
for (var i = 0; i < editorChildren.length; i++) {
|
||
var el = editorChildren[i];
|
||
var isDivider = el.getAttribute('data-w-e-type') === 'divider' ||
|
||
el.tagName.toLowerCase() === 'hr';
|
||
if (!isDivider) {
|
||
$container.append(el.cloneNode(true));
|
||
}
|
||
}
|
||
|
||
$('body').append($container);
|
||
|
||
requestAnimationFrame(function () {
|
||
snapdom.toCanvas($container[0], {
|
||
scale: 2,
|
||
backgroundColor: '#ffffff'
|
||
}).then(function (canvas) {
|
||
$container.remove();
|
||
var link = document.createElement('a');
|
||
link.download = 'phone-image-long-' + Date.now() + '.png';
|
||
link.href = canvas.toDataURL('image/png');
|
||
link.click();
|
||
deferred.resolve(canvas);
|
||
}).catch(function (err) {
|
||
$container.remove();
|
||
deferred.reject('长图导出失败: ' + err);
|
||
});
|
||
});
|
||
|
||
return deferred.promise();
|
||
}
|
||
|
||
// ===== 工具方法 =====
|
||
|
||
/**
|
||
* HTML转义
|
||
*/
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
var div = document.createElement('div');
|
||
div.appendChild(document.createTextNode(text));
|
||
return div.innerHTML;
|
||
}
|
||
|
||
// ===== 对齐下拉事件委托(兼容JPEG预览和DOM预览两种模式) =====
|
||
$(document).on('change', '.thumb-alignment-select', function () {
|
||
var $select = $(this);
|
||
var pageNum = parseInt($select.attr('data-page-num'), 10);
|
||
var align = $select.val();
|
||
|
||
setPageAlignment(pageNum, align);
|
||
|
||
var $thumbItem = $select.closest('.preview-thumb-item');
|
||
|
||
// DOM预览模式:更新 .phone-image-page 的 valign class
|
||
var $phonePage = $thumbItem.find('.dom-page-container .phone-image-page');
|
||
if ($phonePage.length) {
|
||
$phonePage.removeClass('valign-top valign-center valign-bottom').addClass('valign-' + align);
|
||
return;
|
||
}
|
||
|
||
// JPEG预览模式:更新 img 的 align-self
|
||
var $thumbImg = $thumbItem.find('.preview-thumb-img');
|
||
if (align === 'center') {
|
||
$thumbImg.css({ 'align-self': 'center' });
|
||
} else if (align === 'bottom') {
|
||
$thumbImg.css({ 'align-self': 'flex-end' });
|
||
} else {
|
||
$thumbImg.css({ 'align-self': 'flex-start' });
|
||
}
|
||
});
|
||
|
||
// ===== 公开API =====
|
||
return {
|
||
init: init,
|
||
render: render,
|
||
generateImages: generateImages,
|
||
saveImages: saveImages,
|
||
saveConfig: saveConfig,
|
||
syncPreview: syncPreview,
|
||
setPageAlignment: setPageAlignment,
|
||
exportLongImage: exportLongImage,
|
||
renderDomPages: renderDomPages,
|
||
renderDomPageThumbnails: renderDomPageThumbnails,
|
||
refreshDomPage: refreshDomPage,
|
||
logPanel: PhoneImageLogPanel,
|
||
getContentHtml: function () {
|
||
if (window.phoneImageEditor) {
|
||
return window.phoneImageEditor.getHtml();
|
||
}
|
||
return postData.content_html;
|
||
},
|
||
updateConfig: function (newConfig) {
|
||
if (newConfig) {
|
||
$.extend(config, newConfig);
|
||
}
|
||
applyFontScale();
|
||
}
|
||
};
|
||
})();
|