feat: add html-tools

This commit is contained in:
tukuaiai 2025-12-25 03:05:21 +08:00
parent e92bf8b21f
commit b63fc41586
6 changed files with 1556 additions and 0 deletions

74
libs/external/html-tools-main/README.md vendored Normal file
View File

@ -0,0 +1,74 @@
# HTML Tools
**HTML 工具集:集成多种独立 HTML 工具,涵盖单一或多功能设计,致力于一键高效完成任务。**
## 项目简介
HTML Tools 是一个集合各种 HTML 工具的项目,旨在帮助开发者快速、高效地完成 HTML 页面相关的任务。无论是单一功能的小工具,还是集成多种功能的工具,这个项目都能够满足您的需求。
---
## 功能特色
- **多种工具集成**:提供从简单到复杂 HTML 操作的工具。
- **高效便捷**:一键式操作,快速完成目标任务。
- **模块化设计**:工具独立,便于扩展和维护。
- **开源免费**:完全开源,欢迎贡献。
---
## 安装与使用
### 1. 克隆仓库
```bash
git clone https://github.com/fud114514/html-tools.git
cd html-tools
```
### 2. 打开工具
- 将工具直接部署在浏览器中运行,或者在本地 HTML 文件中打开。
- 无需安装额外依赖。
### 3. 贡献代码
如果您有兴趣为项目贡献代码,请按照以下步骤进行:
1. Fork 本仓库。
2. 创建自己的分支:`git checkout -b feature/your-feature`
3. 提交更改:`git commit -m 'Add some feature'`
4. 推送分支到 GitHub`git push origin feature/your-feature`
5. 提交 Pull Request。
---
## 文件结构
```plaintext
html-tools/
├── index.html # 主页面
├── tools/ # 各种独立工具文件
├── assets/ # 静态资源 (如图片、样式表)
└── README.md # 项目文档
```
---
## 参与贡献
欢迎大家为 HTML Tools 项目贡献自己的力量!无论是修复问题、添加新功能或改进文档,您的每一个贡献都对我们非常重要。
### 提交 Issue
如果您在使用过程中遇到问题,可以通过 [Issue 页面](https://github.com/fud114514/html-tools/issues) 提交问题。
### 提交 Pull Request
详细步骤请参考上文的“贡献代码”部分。
---
## 开发者
该项目由 [fud114514](https://github.com/fud114514) 开发和维护。
---
## 许可证
本项目采用 [MIT 许可证](https://opensource.org/licenses/MIT) 开源,详情请参阅 [LICENSE 文件](https://github.com/fud114514/html-tools/blob/main/LICENSE)。

View File

@ -0,0 +1,424 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EPUB CSS 清理工具 (Web版)</title>
<style>
body {
font-family: sans-serif;
line-height: 1.6;
padding: 20px;
max-width: 600px;
margin: 20px auto;
background-color: #f4f4f4;
border: 1px solid #ccc;
border-radius: 8px;
}
h1 {
text-align: center;
color: #333;
}
#drop-area {
border: 2px dashed #ccc;
border-radius: 5px;
padding: 30px;
text-align: center;
background-color: #fff;
margin-bottom: 20px;
cursor: pointer;
}
#drop-area.highlight {
border-color: dodgerblue;
}
#drop-area p {
margin: 0;
color: #555;
}
#fileInput {
display: none; /* Hide default input, use drop area or label */
}
label.file-label {
display: inline-block;
padding: 10px 15px;
background-color: dodgerblue;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
transition: background-color 0.2s;
}
label.file-label:hover {
background-color: #007ae5;
}
#status {
margin-top: 15px;
padding: 10px;
background-color: #eee;
border-radius: 4px;
min-height: 40px; /* Ensure space for messages */
text-align: center;
font-weight: bold;
}
.status-details {
font-size: 0.9em;
color: #555;
margin-top: 5px;
}
.warning {
color: #a00;
font-weight: bold;
text-align: center;
margin-top: 20px;
padding: 10px;
background-color: #fdd;
border: 1px solid #fbb;
border-radius: 4px;
}
progress {
width: 100%;
margin-top: 10px;
display: none; /* Hidden by default */
}
</style>
<!-- Include JSZip library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
<!-- Include FileSaver.js library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
</head>
<body>
<h1>EPUB CSS 清理工具 (Web版)</h1>
<p>将单个 EPUB 文件或包含多个 EPUB 的 ZIP 文件拖拽到下方区域,或点击按钮选择文件,即可移除指定的 CSS 样式。</p>
<div id="drop-area">
<!-- Accept both .epub and .zip -->
<input type="file" id="fileInput" accept=".epub,.zip">
<p>将 EPUB 或 ZIP 文件拖拽到这里</p>
<label for="fileInput" class="file-label">或者选择文件</label>
</div>
<progress id="progressBar" value="0" max="100"></progress>
<div id="status">请选择或拖拽一个 EPUB 或 ZIP 文件。</div>
<div id="statusDetails" class="status-details"></div>
<div class="warning">
<strong>重要提示:</strong> 此工具在你的浏览器中处理文件,不会上传到服务器。处理后的文件需要你手动下载。建议在处理前备份原始文件!处理 ZIP 文件时会生成一个包含所有处理后或原始EPUB 的新 ZIP 文件。
</div>
<script>
// --- Check if libraries loaded ---
if (typeof JSZip === 'undefined' || typeof saveAs === 'undefined') {
const errorMsg = '错误:无法加载必需的库 (JSZip 或 FileSaver)。请检查您的网络连接,并确保没有浏览器插件(如广告拦截器)阻止从 cdnjs.cloudflare.com 加载脚本。然后请刷新页面重试。';
document.getElementById('status').textContent = errorMsg;
document.getElementById('status').style.color = '#a00';
document.getElementById('drop-area').style.display = 'none';
document.querySelector('.warning').textContent = errorMsg;
} else {
// --- Global Variables & Constants ---
const dropArea = document.getElementById('drop-area');
const fileInput = document.getElementById('fileInput');
const statusDiv = document.getElementById('status');
const statusDetailsDiv = document.getElementById('statusDetails');
const progressBar = document.getElementById('progressBar');
const CSS_REMOVE_PATTERN = /((text-indent|line-height|font-size|height|font-family|color)\s*:\s*[^;]*;|display\s*:\s*block\s*;)/ig;
// --- Event Handlers ---
setupDragDropHandlers();
fileInput.addEventListener('change', handleFileSelect, false);
function setupDragDropHandlers() {
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, preventDefaults, false);
document.body.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
dropArea.addEventListener(eventName, () => dropArea.classList.add('highlight'), false);
});
['dragleave', 'drop'].forEach(eventName => {
dropArea.addEventListener(eventName, () => dropArea.classList.remove('highlight'), false);
});
dropArea.addEventListener('drop', handleDrop, false);
}
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e) {
handleFiles(e.dataTransfer.files);
}
function handleFileSelect(e) {
handleFiles(e.target.files);
}
// --- Main File Handling Logic ---
function handleFiles(files) {
if (files.length === 0) return;
const file = files[0]; // Process only the first file
// Check file type
const isEpub = file.name.toLowerCase().endsWith('.epub');
const isZip = file.name.toLowerCase().endsWith('.zip');
if (!isEpub && !isZip) {
updateStatus('错误:请选择一个 .epub 或 .zip 文件。', true);
resetInput();
return;
}
updateStatus(`正在读取文件: ${file.name}...`);
clearStatusDetails();
progressBar.style.display = 'block';
progressBar.value = 0;
const reader = new FileReader();
reader.onload = async (e) => {
const fileContent = e.target.result; // ArrayBuffer
try {
if (isEpub) {
updateStatus(`正在处理 EPUB 文件: ${file.name}...`);
const result = await processEpub(fileContent, file.name);
if (result.blob) {
saveAs(result.blob, result.filename);
updateStatus(`处理完成!已生成 ${result.filename}。`);
} else if (result.modified === false) {
updateStatus('信息:文件无需修改。');
}
resetInput();
} else if (isZip) {
updateStatus(`正在处理 ZIP 压缩包: ${file.name}...`);
await processZipArchive(fileContent, file.name);
}
} catch (error) {
console.error("处理失败:", error);
updateStatus(`处理失败:${error.message}`, true);
resetInput();
}
};
reader.onprogress = updateReadProgress;
reader.onerror = handleReadError;
reader.readAsArrayBuffer(file);
}
function updateReadProgress(e) {
if (e.lengthComputable) {
progressBar.value = Math.round((e.loaded / e.total) * 10); // Reading is ~10%
}
}
function handleReadError() {
updateStatus('错误:读取文件时出错。', true);
resetInput();
}
// --- ZIP Archive Processing ---
async function processZipArchive(zipFileContent, zipFilename) {
let outputZip = new JSZip();
let processedCount = 0;
let failedCount = 0;
let foundCount = 0;
let modifiedCount = 0;
let filePromises = [];
updateStatus(`正在打开 ZIP 压缩包...`);
progressBar.value = 15;
const inputZip = await JSZip.loadAsync(zipFileContent);
const totalFilesInZip = Object.keys(inputZip.files).length;
updateStatus(`在 ZIP 中查找 EPUB 文件...`);
inputZip.forEach((relativePath, zipEntry) => {
if (!zipEntry.dir && relativePath.toLowerCase().endsWith('.epub')) {
foundCount++;
updateStatusDetails(`找到 EPUB: ${relativePath}`);
// Add a promise to process this EPUB entry
const promise = zipEntry.async('arraybuffer')
.then(async (innerEpubContent) => {
try {
// Process the inner EPUB
const result = await processEpub(innerEpubContent, relativePath);
if (result.blob) {
// Add modified EPUB to output ZIP
outputZip.file(result.filename, result.blob);
modifiedCount++;
console.log(`Added cleaned ${result.filename} to output zip.`);
} else {
// Add original EPUB back if not modified
outputZip.file(relativePath, innerEpubContent);
console.log(`Added original ${relativePath} (no changes needed) to output zip.`);
}
processedCount++;
} catch (epubError) {
// Add original EPUB back if processing failed
console.error(`Error processing inner EPUB ${relativePath}:`, epubError);
updateStatusDetails(`处理 ${relativePath} 时出错,将保留原文件。`);
outputZip.file(relativePath, innerEpubContent); // Add original back
failedCount++;
}
})
.catch(extractError => {
// Handle error extracting the inner EPUB content
console.error(`Error extracting inner EPUB ${relativePath} from zip:`, extractError);
updateStatusDetails(`无法从 ZIP 中提取 ${relativePath},已跳过。`);
failedCount++;
})
.finally(() => {
// Update progress based on entries processed (success or fail)
const progress = 15 + Math.round(((processedCount + failedCount) / foundCount) * 60); // Processing EPUBs is ~60%
progressBar.value = Math.min(progress, 75); // Cap at 75 before final packing
});
filePromises.push(promise);
}
// We are currently NOT adding non-EPUB files from the source ZIP to the output ZIP.
// To do so, handle the 'else' case here and add zipEntry data to outputZip.
});
// Wait for all inner EPUB processing promises to complete
await Promise.all(filePromises);
progressBar.value = 80; // Done processing files inside zip
if (foundCount === 0) {
updateStatus('错误:在 ZIP 文件中未找到任何 EPUB 文件。', true);
resetInput();
return;
}
updateStatus(`处理了 ${foundCount} 个 EPUB 文件。正在生成输出 ZIP...`);
statusDetailsDiv.innerHTML += `<br>成功: ${processedCount}, 修改: ${modifiedCount}, 失败/跳过: ${failedCount}`;
progressBar.value = 90;
const outputZipBlob = await outputZip.generateAsync({
type: "blob",
compression: "DEFLATE",
platform: "browser"
}, (metadata) => {
progressBar.value = 90 + Math.round(metadata.percent * 0.1);
});
progressBar.value = 100;
const baseName = zipFilename.substring(0, zipFilename.lastIndexOf('.')) || zipFilename;
const outputZipFilename = `${baseName}_cleaned.zip`;
saveAs(outputZipBlob, outputZipFilename);
updateStatus(`处理完成!已生成包含处理后 EPUB 的 ${outputZipFilename}。`);
resetInput();
}
// --- Single EPUB Processing (Modified to return result) ---
async function processEpub(fileContent, originalFilename) {
// This function now returns an object:
// { blob: Blob, filename: string } if modified
// { modified: false } if no modification needed
// Throws error if processing fails critically
console.log(`Processing EPUB: ${originalFilename}`);
// Note: Progress bar updates inside this function are less meaningful now,
// as the overall progress is handled by the caller (handleFiles or processZipArchive).
// We could pass a progress callback if detailed inner progress is needed.
const zip = await JSZip.loadAsync(fileContent);
const newZip = new JSZip();
let cssFilesFound = false;
let modified = false;
let filePromises = [];
zip.forEach((relativePath, zipEntry) => {
if (zipEntry.dir) { newZip.folder(relativePath); return; }
const options = {
date: zipEntry.date,
unixPermissions: zipEntry.unixPermissions,
dosPermissions: zipEntry.dosPermissions,
comment: zipEntry.comment,
dir: zipEntry.dir,
compression: zipEntry.options?.compression === "STORE" ? "STORE" : "DEFLATE"
};
if (relativePath.toLowerCase() === 'mimetype') {
const promise = zipEntry.async('uint8array').then(data => {
newZip.file(relativePath, data, { ...options, compression: "STORE" });
});
filePromises.push(promise);
} else if (relativePath.toLowerCase().endsWith('.css')) {
cssFilesFound = true;
const promise = zipEntry.async('string').then(originalCss => {
const cleanedCss = originalCss.replace(CSS_REMOVE_PATTERN, '');
let contentToAdd = originalCss;
if (cleanedCss !== originalCss) {
modified = true;
contentToAdd = cleanedCss;
console.log(` Cleaned CSS: ${relativePath}`);
}
newZip.file(relativePath, contentToAdd, options);
}).catch(err => {
console.warn(` Could not process CSS ${relativePath} as text, keeping original. Error: ${err}`);
return zipEntry.async('uint8array').then(originalData => {
newZip.file(relativePath, originalData, options);
});
});
filePromises.push(promise);
} else {
const promise = zipEntry.async('uint8array').then(data => {
newZip.file(relativePath, data, options);
});
filePromises.push(promise);
}
});
await Promise.all(filePromises);
if (!modified) {
console.log(` EPUB ${originalFilename} - No modifications needed.`);
return { modified: false }; // Indicate no changes were made
}
console.log(` Repacking modified EPUB: ${originalFilename}`);
const newEpubBlob = await newZip.generateAsync({
type: "blob",
mimeType: "application/epub+zip",
compression: "DEFLATE",
platform: "browser"
});
const baseName = originalFilename.substring(0, originalFilename.lastIndexOf('.')) || originalFilename;
const newFilename = `${baseName}_cleaned.epub`;
return { blob: newEpubBlob, filename: newFilename }; // Return data for the caller
} // end of processEpub function
// --- UI Update Functions ---
function updateStatus(message, isError = false) {
statusDiv.textContent = message;
statusDiv.style.color = isError ? '#a00' : '#000';
if (isError) {
progressBar.style.display = 'none';
clearStatusDetails();
}
}
function updateStatusDetails(message) {
statusDetailsDiv.innerHTML += message + "<br>";
}
function clearStatusDetails() {
statusDetailsDiv.innerHTML = "";
}
function resetInput() {
fileInput.value = '';
// Hide progress bar after a delay
setTimeout(() => { progressBar.style.display = 'none'; progressBar.value = 0; }, 5000);
}
} // End of the 'else' block for library check
</script>
</body>
</html>

View File

@ -0,0 +1,303 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown Editor</title>
<style>
:root {
--border-color: #eee;
--bg-color: #fafafa;
--text-color: #333;
--btn-border: #ddd;
--code-bg: #f5f5f5;
--scrollbar-color: #ccc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
height: 100vh;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
overflow: hidden;
}
.header {
padding: 8px 20px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
align-items: center;
font-size: 14px;
color: var(--text-color);
}
.toolbar {
display: flex;
gap: 8px;
}
.btn {
border: 1px solid var(--btn-border);
background: white;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
color: var(--text-color);
min-width: 80px;
text-align: center;
transition: background-color 0.2s ease;
}
.btn:hover {
background: var(--code-bg);
}
.content {
display: flex;
flex: 1;
overflow: hidden;
}
#preview, #editor {
width: 50%;
height: 100%;
padding: 20px;
line-height: 1.6;
}
#preview {
color: var(--text-color);
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
#editor {
font-family: monospace;
font-size: 14px;
border: none;
outline: none;
resize: none;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-color) transparent;
}
#editor::-webkit-scrollbar {
width: 8px;
}
#editor::-webkit-scrollbar-thumb {
background-color: var(--scrollbar-color);
border-radius: 4px;
}
#editor::-webkit-scrollbar-track {
background-color: transparent;
}
.preview-content {
height: auto;
min-height: 100%;
padding-right: 8px;
}
#preview h1, #preview h2, #preview h3 {
margin: 16px 0;
font-weight: 500;
}
#preview h1:first-child {
margin-top: 0;
}
#preview p {
margin: 12px 0;
}
#preview ul, #preview ol {
padding-left: 24px;
margin: 12px 0;
}
#preview code {
background-color: var(--code-bg);
padding: 2px 4px;
border-radius: 3px;
font-size: 0.9em;
}
#preview pre code {
display: block;
padding: 12px;
overflow-x: auto;
line-height: 1.45;
}
.toast {
position: fixed;
top: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
display: none;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="header">
<div class="toolbar">
<button class="btn" id="copyBtn">一键复制</button>
<button class="btn" id="exportBtn">导出文本</button>
</div>
</div>
<div class="content">
<div id="preview">
<div class="preview-content"></div>
</div>
<textarea id="editor" placeholder="在这里输入 Markdown 文本..."></textarea>
</div>
<div id="toast" class="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const editor = document.getElementById('editor');
const preview = document.getElementById('preview');
const previewContent = preview.querySelector('.preview-content');
const toast = document.getElementById('toast');
const copyBtn = document.getElementById('copyBtn');
const exportBtn = document.getElementById('exportBtn');
// Initialize marked with safe options
marked.setOptions({
breaks: true,
gfm: true,
headerIds: true,
sanitize: false,
highlight: function(code, lang) {
return code;
}
});
function showToast(message, duration = 2000) {
toast.textContent = message;
toast.style.display = 'block';
setTimeout(() => {
toast.style.display = 'none';
}, duration);
}
function updatePreview() {
try {
const markdownText = editor.value;
const htmlContent = marked.parse(markdownText);
previewContent.innerHTML = htmlContent;
} catch (error) {
console.error('Markdown parsing error:', error);
previewContent.innerHTML = '<p style="color: red;">Error parsing markdown</p>';
}
}
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
const debouncedUpdate = debounce(updatePreview, 150);
async function copyText() {
try {
await navigator.clipboard.writeText(editor.value);
showToast('已复制到剪贴板');
} catch (err) {
console.error('Copy failed:', err);
showToast('复制失败');
}
}
function exportTxt() {
try {
const text = editor.value;
const blob = new Blob([text], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const timestamp = new Date().toISOString().slice(0,19).replace(/[-:]/g, '');
a.href = url;
a.download = `markdown_${timestamp}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('已导出为文本文件');
} catch (err) {
console.error('Export failed:', err);
showToast('导出失败');
}
}
// Event Listeners
editor.addEventListener('input', debouncedUpdate);
copyBtn.addEventListener('click', copyText);
exportBtn.addEventListener('click', exportTxt);
// Sync scroll
editor.addEventListener('scroll', () => {
const percentage = editor.scrollTop / (editor.scrollHeight - editor.clientHeight);
preview.scrollTop = percentage * (preview.scrollHeight - preview.clientHeight);
});
// Initial content
const initialText = `# Markdown 编辑器
## 欢迎使用!
这是一个简单的 Markdown 编辑器。
### 主要功能:
1. 实时预览
2. 一键复制内容
3. 导出文本文件
4. 支持常用 Markdown 语法
### 快速开始:
- 在右侧输入 Markdown 文本
- 在左侧查看实时预览
- 使用顶部按钮进行操作
\`\`\`markdown
# 示例代码
**粗体** 和 *斜体*
- 列表项
- 另一个列表项
\`\`\``;
editor.value = initialText;
updatePreview();
});
</script>
</body>
</html>

View File

@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>任务卡片生成器</title>
<style>
body {
display: flex;
flex-direction: column;
justify-content: flex-start; /* 从顶部开始排列 */
align-items: center;
min-height: 100vh;
background: #121212; /* 深色背景,让卡片在预览时效果更一致 */
margin: 0;
font-family: 'Arial', 'Helvetica Neue', Helvetica, sans-serif; /* 更现代的字体栈 */
padding: 30px 20px; /* 给body一些padding */
box-sizing: border-box;
}
.export-container {
background: #282828; /* 图片中外部容器的深灰色 */
padding: 20px; /* 容器内边距 */
border-radius: 8px;
display: inline-block;
box-shadow: 0 8px 20px rgba(0,0,0,0.3); /* 给容器一些阴影 */
}
.card {
width: 370px; /* 调整宽度以适应两列布局和间距 */
background: #000000; /* 卡片纯黑背景 */
padding: 25px;
box-sizing: border-box;
color: #ffffff;
border-radius: 6px; /* 给卡片本身也加一点圆角 */
}
.card-header {
border-bottom: 1px solid #444444; /* 更细、颜色更深的分割线 */
padding-bottom: 15px;
margin-bottom: 20px;
}
.task-id {
font-size: 14px;
color: #888888; /* 灰色ID文字 */
margin-bottom: 8px; /* ID和标题间距 */
min-height: 1.2em; /* 确保空的时候也有高度placeholder可以显示 */
}
.task-title {
font-size: 24px;
font-weight: bold;
color: #ffffff;
word-wrap: break-word;
min-height: 1.2em;
}
.task-description {
font-size: 15px;
color: #e0e0e0;
line-height: 1.6;
word-wrap: break-word;
margin-top: 15px;
margin-bottom: 25px;
min-height: 45px; /* 描述区域最小高度 */
padding: 5px 2px; /* 轻微内边距 */
}
.task-details {
display: grid;
grid-template-columns: 1fr 1fr; /* 固定两列 */
gap: 15px;
font-size: 14px;
margin-top: 20px;
}
.detail-item {
background-color: #1c1c1c; /* 详情项的背景色,比卡片黑底亮一点 */
padding: 12px;
border-radius: 6px;
}
/* 如果最后一个元素是奇数个中的最后一个例如第3个则让它占据整行 */
.detail-item:last-child:nth-child(odd) {
grid-column: 1 / -1; /* 跨越所有列 */
}
.detail-item label {
font-weight: normal;
color: #aaaaaa; /* 标签文字颜色 */
display: block;
margin-bottom: 8px;
font-size: 13px;
}
.detail-item .value.editable,
.detail-item input[type="date"].value {
color: #ffffff;
background-color: #000000; /* 值区域纯黑背景 */
border: 1px solid #333333; /* 值区域的细边框 */
width: 100%;
padding: 8px 10px; /* 值区域内边距 */
min-height: 1.5em; /* 确保有高度 */
border-radius: 4px;
font-size: 15px;
line-height: 1.4;
box-sizing: border-box; /* padding和border不增加额外宽度 */
}
.detail-item input[type="date"].value {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
position: relative;
}
.detail-item input[type="date"].value::-webkit-calendar-picker-indicator {
filter: invert(0.8) brightness(0.8);
cursor: pointer;
opacity: 0.7;
}
.detail-item .value.editable {
position: relative;
}
.detail-item .value.editable:empty::before {
content: attr(data-placeholder);
color: #555555;
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
width: calc(100% - 20px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editable {
position: relative;
min-height: 1.2em;
}
.editable:empty:not(:focus)::before {
content: attr(data-placeholder);
color: #666666;
position: absolute;
left: 0;
top: 0;
pointer-events: none;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-id.editable:empty:not(:focus)::before,
.task-title.editable:empty:not(:focus)::before,
.task-description.editable:empty:not(:focus)::before {
left: 2px;
top: 2px;
}
.editable:focus {
outline: 1px dashed #777777;
background-color: #0a0a0a;
}
[contenteditable] {
-webkit-user-select: text;
user-select: text;
}
[contenteditable]:focus {
background-color: #0a0a0a !important;
}
::selection {
background-color: #444444;
color: #ffffff;
}
.button-container {
margin-top: 25px;
text-align: center;
}
.export-btn {
padding: 12px 25px;
background: #1c1c1c;
color: #ffffff;
border: 1px solid #444444;
cursor: pointer;
font-size: 16px;
border-radius: 6px;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.export-btn:hover {
background: #282828;
border-color: #555555;
}
</style>
</head>
<body>
<div class="export-container" id="export-container">
<div id="taskCard" class="card">
<div class="card-header">
<div class="task-id editable" contenteditable="true" data-placeholder="任务ID (可选)"></div>
<div class="task-title editable" contenteditable="true" data-placeholder="点击输入任务标题"></div>
</div>
<div class="task-description editable" contenteditable="true" data-placeholder="点击输入任务详细描述..."></div>
<div class="task-details">
<div class="detail-item">
<label for="assignee-value">负责人:</label>
<div id="assignee-value" class="value editable" contenteditable="true" data-placeholder="未分配"></div>
</div>
<!-- 标签现在是第二个 -->
<div class="detail-item">
<label for="tags-value">标签:</label>
<div id="tags-value" class="value editable" contenteditable="true" data-placeholder="例如: 项目A, Bug"></div>
</div>
<!-- 截止日期现在是第三个,会自动占据整行 -->
<div class="detail-item">
<label for="dueDate-value">截止日期:</label>
<input type="date" id="dueDate-value" class="value">
</div>
</div>
</div>
</div>
<div class="button-container">
<button class="export-btn" onclick="exportToPng()">导出为PNG</button>
</div>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
<script>
function sanitizeFilename(name) {
return name.replace(/[^a-z0-9_\-.\u4e00-\u9fa5\s]/gi, '').replace(/\s+/g, '_');
}
function exportToPng() {
if (window.getSelection) {
if (window.getSelection().empty) { window.getSelection().empty(); }
else if (window.getSelection().removeAllRanges) { window.getSelection().removeAllRanges(); }
} else if (document.selection) { document.selection.empty(); }
const container = document.getElementById('export-container');
// const card = document.getElementById('taskCard'); // card variable not strictly needed here
const originalDueDate = document.getElementById('dueDate-value').value;
html2canvas(container, {
backgroundColor: null,
scale: 2,
useCORS: true,
logging: false, // Changed to false to reduce console noise unless debugging
onclone: (documentClone) => {
// const clonedCard = documentClone.getElementById('taskCard'); // clonedCard not strictly needed
const dueDateInputClone = documentClone.getElementById('dueDate-value');
if (dueDateInputClone) {
dueDateInputClone.value = originalDueDate;
if (!originalDueDate) {
dueDateInputClone.style.color = '#555555';
} else {
dueDateInputClone.style.color = '#ffffff';
}
}
const editables = documentClone.querySelectorAll('.editable');
editables.forEach(el => {
// Placeholder handling is mostly done by CSS (:empty::before)
// If specific adjustments needed for clone, add here.
// For example, ensuring text color if content exists:
// if (el.textContent.trim() !== '' && el.classList.contains('value')) {
// el.style.color = '#ffffff';
// }
});
}
}).then(canvas => {
const link = document.createElement('a');
const taskTitleElement = document.getElementById('task-title');
let filename = '任务卡片.png';
if (taskTitleElement && taskTitleElement.textContent.trim() !== '') {
const sanitizedTitle = sanitizeFilename(taskTitleElement.textContent.trim());
if (sanitizedTitle) { // Ensure title isn't just invalid chars
filename = sanitizedTitle + '.png';
}
}
link.download = filename;
link.href = canvas.toDataURL('image/png');
link.click();
}).catch(err => {
console.error("导出PNG失败:", err);
alert("导出PNG失败请查看控制台获取更多信息。");
});
}
document.querySelectorAll('.editable').forEach(element => {
element.addEventListener('focus', function() {
// CSS handles placeholder via :not(:focus)::before
});
element.addEventListener('blur', function() {
// CSS handles placeholder
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,320 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Markdown 编辑与预览 (带同步滚动和滑块)</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"></script>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
background: #f0f0f0;
margin: 0;
font-family: 'Arial', sans-serif;
padding-top: 20px;
overflow: hidden; /* Prevent body scrollbars if editor is full height */
}
.editor-container {
display: flex;
width: 90%; /* Use percentage for better responsiveness */
max-width: 1200px; /* Max width for large screens */
height: calc(100vh - 100px); /* Adjust height to fill more of the viewport */
background: #333333;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
position: relative; /* For resizer positioning if needed */
}
.markdown-editor, .preview-pane {
padding: 20px;
box-sizing: border-box;
overflow-y: auto;
height: 100%; /* Fill the container height */
}
.markdown-editor {
/* flex: 1; Will be set by JS */
background: #222222;
color: #e0e0e0;
border: none;
resize: none;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
outline: none;
}
.resizer {
flex: 0 0 10px; /* Don't grow, don't shrink, base width 10px */
background: #555555;
cursor: col-resize;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
user-select: none; /* Prevent text selection during drag */
}
.resizer::before { /* Optional: visual indicator for dragging */
content: '⋮';
color: #aaa;
font-size: 18px;
line-height: 0;
}
.preview-pane {
/* flex: 1; Will be set by JS */
background: #000000;
color: #ffffff;
/* border-left: 2px solid #666666; /* Replaced by resizer */
word-wrap: break-word;
}
/* Styling for rendered Markdown elements in preview-pane */
.preview-pane h1, .preview-pane h2, .preview-pane h3, .preview-pane h4, .preview-pane h5, .preview-pane h6 {
color: #ffffff;
border-bottom: 1px solid #444444;
padding-bottom: 5px;
margin-top: 1em;
margin-bottom: 0.5em;
}
.preview-pane h1 { font-size: 24px; font-weight: bold; border-bottom: 2px solid #666666; padding-bottom: 10px; margin-bottom: 15px;}
.preview-pane p {
font-size: 16px;
line-height: 1.5;
margin-bottom: 1em;
}
.preview-pane ul, .preview-pane ol {
margin-left: 20px;
margin-bottom: 1em;
}
.preview-pane li {
margin-bottom: 0.5em;
}
.preview-pane blockquote {
border-left: 4px solid #555555;
padding-left: 10px;
color: #aaaaaa;
margin-left: 0;
margin-right: 0;
margin-bottom: 1em;
}
.preview-pane pre {
background-color: #1e1e1e;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
}
.preview-pane code {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
background-color: #282c34;
padding: 2px 4px;
border-radius: 3px;
font-size: 0.9em;
}
.preview-pane pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
.preview-pane table {
border-collapse: collapse;
width: 100%;
margin-bottom: 1em;
}
.preview-pane th, .preview-pane td {
border: 1px solid #666666;
padding: 8px;
text-align: left;
}
.preview-pane th {
background-color: #1a1a1a;
}
.preview-pane img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.button-container {
margin-top: 20px;
text-align: center;
}
.export-btn {
padding: 10px 20px;
background: #000000;
color: #ffffff;
border: 2px solid #666666;
cursor: pointer;
font-size: 16px;
}
.export-btn:hover {
background: #333333;
}
</style>
</head>
<body>
<div class="editor-container">
<textarea id="markdown-input" class="markdown-editor" placeholder="在此输入 Markdown... 例如:\n# 标题\n\n- 列表项1\n- 列表项2\n\n**加粗文本**"></textarea>
<div id="resizer" class="resizer"></div>
<div id="preview-output" class="preview-pane"></div>
</div>
<div class="button-container">
<button class="export-btn" onclick="exportToPng()">导出为PNG</button>
</div>
<script>
const markdownInput = document.getElementById('markdown-input');
const previewOutput = document.getElementById('preview-output');
const resizer = document.getElementById('resizer');
const editorContainer = document.querySelector('.editor-container');
// Initial flex distribution
markdownInput.style.flex = '1';
previewOutput.style.flex = '1';
const converter = new showdown.Converter({
tables: true,
strikethrough: true,
tasklists: true,
ghCompatibleHeaderId: true,
simpleLineBreaks: true
});
converter.setFlavor('github');
function renderMarkdown() {
const markdownText = markdownInput.value;
const html = converter.makeHtml(markdownText);
previewOutput.innerHTML = html;
}
markdownInput.addEventListener('input', renderMarkdown);
renderMarkdown(); // Initial render
// --- Resizer Logic ---
let isResizing = false;
let startX, startEditorWidth, startPreviewWidth;
resizer.addEventListener('mousedown', (e) => {
e.preventDefault(); // Prevent text selection, etc.
isResizing = true;
startX = e.clientX;
startEditorWidth = markdownInput.offsetWidth;
startPreviewWidth = previewOutput.offsetWidth;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', stopResize);
});
function handleMouseMove(e) {
if (!isResizing) return;
const dx = e.clientX - startX;
let newEditorWidth = startEditorWidth + dx;
let newPreviewWidth = startPreviewWidth - dx;
const minWidth = 50; // Minimum width for panes
if (newEditorWidth < minWidth) {
newEditorWidth = minWidth;
newPreviewWidth = startEditorWidth + startPreviewWidth - minWidth;
} else if (newPreviewWidth < minWidth) {
newPreviewWidth = minWidth;
newEditorWidth = startEditorWidth + startPreviewWidth - minWidth;
}
const totalWidth = editorContainer.offsetWidth - resizer.offsetWidth;
markdownInput.style.flex = `0 0 ${newEditorWidth}px`;
previewOutput.style.flex = `0 0 ${newPreviewWidth}px`;
}
function stopResize() {
isResizing = false;
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', stopResize);
}
// --- Synchronized Scrolling ---
let scrollTimeout;
let isSyncingEditor = false;
let isSyncingPreview = false;
markdownInput.addEventListener('scroll', () => {
if (isSyncingEditor) {
isSyncingEditor = false; // Reset for next manual scroll
return;
}
if (isResizing) return; // Don't sync scroll while resizing
isSyncingPreview = true; // Prevent preview scroll from triggering editor scroll
const scrollPercentage = markdownInput.scrollTop / (markdownInput.scrollHeight - markdownInput.clientHeight);
if (isNaN(scrollPercentage)) return; // Avoid NaN if scrollHeight equals clientHeight
previewOutput.scrollTop = scrollPercentage * (previewOutput.scrollHeight - previewOutput.clientHeight);
// Clear any existing timeout to avoid race conditions
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
isSyncingPreview = false;
}, 100); // Adjust delay as needed
});
previewOutput.addEventListener('scroll', () => {
if (isSyncingPreview) {
isSyncingPreview = false; // Reset for next manual scroll
return;
}
if (isResizing) return; // Don't sync scroll while resizing
isSyncingEditor = true; // Prevent editor scroll from triggering preview scroll
const scrollPercentage = previewOutput.scrollTop / (previewOutput.scrollHeight - previewOutput.clientHeight);
if (isNaN(scrollPercentage)) return;
markdownInput.scrollTop = scrollPercentage * (markdownInput.scrollHeight - markdownInput.clientHeight);
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
isSyncingEditor = false;
}, 100); // Adjust delay as needed
});
function exportToPng() {
const previewPane = document.getElementById('preview-output');
const originalHeight = previewPane.style.height;
const originalOverflowY = previewPane.style.overflowY;
previewPane.style.height = previewPane.scrollHeight + 'px';
previewPane.style.overflowY = 'visible';
previewPane.style.backgroundColor = '#000000';
html2canvas(previewPane, {
backgroundColor: '#000000',
scale: 2,
useCORS: true,
logging: false,
onclone: (documentClone) => {
const clonedPreviewPane = documentClone.getElementById('preview-output');
if (clonedPreviewPane) {
clonedPreviewPane.style.backgroundColor = '#000000';
clonedPreviewPane.style.color = '#ffffff';
clonedPreviewPane.style.height = clonedPreviewPane.scrollHeight + 'px';
clonedPreviewPane.style.overflowY = 'visible';
}
}
}).then(canvas => {
const link = document.createElement('a');
link.download = 'markdown_card.png';
link.href = canvas.toDataURL('image/png');
link.click();
}).finally(() => {
previewPane.style.height = originalHeight;
previewPane.style.overflowY = originalOverflowY;
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,146 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>小红书内容卡片</title>
<style>
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f0f0f0;
margin: 0;
font-family: 'Arial', sans-serif;
}
.export-container {
background: #333333;
padding: 10px;
border-radius: 8px;
}
.card {
width: 320px;
background: #000000;
border: 2px solid #666666; /* 改为灰色边框 */
box-shadow: 4px 4px 0 #666666; /* 改为灰色阴影 */
padding: 20px;
box-sizing: border-box;
}
.title {
font-size: 24px;
font-weight: bold;
color: #ffffff;
border-bottom: 2px solid #666666; /* 改为灰色边框 */
padding-bottom: 10px;
margin-bottom: 15px;
word-wrap: break-word;
background-color: #000000;
}
.content {
font-size: 16px;
color: #ffffff;
line-height: 1.5;
word-wrap: break-word;
background-color: #000000;
}
.editable {
position: relative;
min-height: 1em;
}
.editable:empty:before {
content: attr(data-placeholder);
color: #666666;
position: absolute;
pointer-events: none;
}
.editable:focus {
outline: none;
background-color: #000000;
}
[contenteditable] {
-webkit-user-select: text;
user-select: text;
background-color: #000000 !important;
}
[contenteditable]:focus {
background-color: #000000 !important;
}
::selection {
background-color: #333333;
color: #ffffff;
}
.button-container {
margin-top: 20px;
text-align: center;
}
.export-btn {
padding: 10px 20px;
background: #000000;
color: #ffffff;
border: 2px solid #666666; /* 添加灰色边框保持一致性 */
cursor: pointer;
font-size: 16px;
}
.export-btn:hover {
background: #333333;
}
</style>
</head>
<body>
<div class="export-container" id="export-container">
<div id="card" class="card">
<div class="title editable" contenteditable="true" data-placeholder="点击输入标题"></div>
<div class="content editable" contenteditable="true" data-placeholder="点击输入内容"></div>
</div>
</div>
<div class="button-container">
<button class="export-btn" onclick="exportToPng()">导出为PNG</button>
</div>
<script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script>
<script>
function exportToPng() {
// 清除选中状态
window.getSelection().removeAllRanges();
const container = document.getElementById('export-container');
html2canvas(container, {
backgroundColor: '#333333',
scale: 2,
useCORS: true,
logging: false,
removeContainer: true,
foreignObjectRendering: false,
onclone: (document) => {
const editables = document.querySelectorAll('.editable');
editables.forEach(el => {
// 确保克隆的元素保持相同的样式
el.style.backgroundColor = '#000000';
if (el.classList.contains('title')) {
el.style.borderBottom = '2px solid #666666';
}
});
// 确保卡片样式在克隆中保持一致
const card = document.querySelector('.card');
card.style.border = '2px solid #666666';
card.style.boxShadow = '4px 4px 0 #666666';
}
}).then(canvas => {
const link = document.createElement('a');
link.download = 'xiaohongshu_card.png';
link.href = canvas.toDataURL('image/png');
link.click();
});
}
// 监听编辑事件,保持样式一致性
document.querySelectorAll('.editable').forEach(element => {
element.addEventListener('input', function() {
this.style.backgroundColor = '#000000';
});
});
</script>
</body>
</html>